mirror of
https://github.com/pgsty/minio.git
synced 2026-10-01 15:25:58 +03:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d0693cb8c | |||
| bf59e3f222 | |||
| 41aa846097 | |||
| 4093fa0d78 | |||
| 13bf126ebd | |||
| 1cf529ce8a | |||
| 9b76a21675 | |||
| 89637554d6 | |||
| 2dd1e00da4 | |||
| 5d955b5b74 | |||
| f7808a172c | |||
| acc9b514f5 | |||
| 31ba01c5d5 | |||
| 48ec10312f | |||
| 114dc10529 | |||
| 461e9a7210 | |||
| fcbb93e895 | |||
| 62cf066ff5 | |||
| 1ee64a8d89 | |||
| 01aaef2b50 | |||
| bcc62afe3d | |||
| 5c57658163 |
@@ -140,7 +140,7 @@ jobs:
|
||||
mkdir -p "${context}/dockerscripts"
|
||||
tar -xzf "${archive}" -C "${context}" silo
|
||||
cp Dockerfile.goreleaser Dockerfile.distroless LICENSE NOTICE CREDITS "${context}/"
|
||||
cp dockerscripts/docker-entrypoint.sh dockerscripts/download-static-curl.sh \
|
||||
cp dockerscripts/docker-entrypoint.sh dockerscripts/build-static-curl.sh \
|
||||
"${context}/dockerscripts/"
|
||||
done
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
- "Dockerfile.distroless"
|
||||
- "cmd/healthcheck-main.go"
|
||||
- "cmd/main.go"
|
||||
- "dockerscripts/download-static-curl.sh"
|
||||
- "dockerscripts/build-static-curl.sh"
|
||||
- "dockerscripts/docker-entrypoint.sh"
|
||||
- "dockerscripts/docker-entrypoint_test.sh"
|
||||
- "silo.service"
|
||||
@@ -41,6 +41,28 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
curl:
|
||||
name: Static curl (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build and exercise curl in an empty runtime
|
||||
run: |
|
||||
docker build --build-arg TARGETARCH=${{ matrix.arch }} \
|
||||
--target curl-runtime -f Dockerfile.goreleaser -t silo-curl-test .
|
||||
docker run --rm silo-curl-test --version | tee curl-version.txt
|
||||
grep -F 'curl 8.22.0 ' curl-version.txt
|
||||
grep -F 'HTTP2' curl-version.txt
|
||||
docker run --rm silo-curl-test --fail --silent --show-error \
|
||||
--connect-timeout 15 --max-time 60 https://curl.se/robots.txt
|
||||
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -490,7 +512,7 @@ jobs:
|
||||
bash -n buildscripts/package/lifecycle_test.sh
|
||||
buildscripts/package/lifecycle_test.sh
|
||||
bash -n dockerscripts/docker-entrypoint_test.sh
|
||||
bash -n dockerscripts/download-static-curl.sh
|
||||
bash -n dockerscripts/build-static-curl.sh
|
||||
dockerscripts/docker-entrypoint_test.sh
|
||||
go run ./buildscripts/rebrand-guard
|
||||
buildscripts/verify-rebrand.sh
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
- name: Install govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.7.0
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.8.0
|
||||
echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Run govulncheck
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased — main as of 2026-09-13
|
||||
|
||||
The coordinated source is merged through `5d955b5b7444f8a3ab550ce92713607998f89c0d`.
|
||||
**The latest published Server remains 20260903.** These changes are not in its
|
||||
binaries, packages or images. See the [component matrix](https://silo.pgsty.com/compatibility/versions/)
|
||||
and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-09-03T13-18-01Z...5d955b5b7444f8a3ab550ce92713607998f89c0d).
|
||||
|
||||
### Authorization and security
|
||||
|
||||
- Reject unsigned `x-amz-*` request headers that could turn a signed PUT into a
|
||||
copy of another object accessible to the signer (SN-2026-011). The latest
|
||||
public Server is affected; the fix is on main. See [the advisory ledger](docs/security/advisories.md).
|
||||
- Align signed request fields with policy conditions and enforce header-only
|
||||
presigned payload checksums. See [the signed-header review](https://silo.pgsty.com/blog/design/signed-header-coverage/).
|
||||
- **Breaking policy semantics:** separate self-service `admin:ChangeMyPassword`
|
||||
from `admin:CreateUser`. Built-in read-only policies follow the split. Preserve
|
||||
both denies if the previous combined restriction must survive upgrades or
|
||||
rollback. Saved policies are not rewritten. Deploy with the matching Console
|
||||
and pkg; see [the migration guide](docs/iam/password-permissions.md).
|
||||
|
||||
### Object storage and replication
|
||||
|
||||
- Reconcile ordinary single-object version DELETE across all pools, including
|
||||
null versions, delete markers and unqualified directory-marker DELETE. This
|
||||
applies the deletion to every resolved pool copy under existing quorum
|
||||
rules. Pending outbound delete replication retains versions until the
|
||||
existing replication worker completes their purge; a successful response
|
||||
does not imply immediate physical removal from every drive. Unreadable
|
||||
pools now consistently return 503 instead of depending on pool traversal
|
||||
order; insufficient read quorum returns `SlowDownRead`. This extends the
|
||||
existing failure surface. Retry after recovery.
|
||||
Cleanup failures also return an error. Batch deletion already fans out across
|
||||
pools; replication and scanner cleanup keep their existing contracts. See
|
||||
[scope and limitations](docs/bucket/lifecycle/access-tiering-removal.md#version-deletion-scope).
|
||||
|
||||
- Remove the opt-in GET-frequency pool-tiering feature from PR #60, including
|
||||
its tracker, mover, scanner hooks, configuration, XML actions and metrics.
|
||||
Accept and ignore retired configuration/XML and preserve ordinary statistics
|
||||
when reading v9 caches. See [migration notes](docs/bucket/lifecycle/access-tiering-removal.md).
|
||||
The [decision record](docs/investigations/access-tiering-revert.md) preserves
|
||||
the feature's introduction, subsequent fixes, rollback scope and review history.
|
||||
- Preserve the independent multi-pool write, metadata, healing and conditional
|
||||
deletion fixes from PR #178, including shared remote-tier reference protection.
|
||||
- Enforce `If-Match` on DELETE, preserve retention and independently ordered
|
||||
Object Lock/tag updates, and correctly retransmit encrypted replicas.
|
||||
- Preserve plaintext part sizes and raw SSE-C replicas; prevent SSE-C
|
||||
compression, honor key-rotation checksums, and complete attributes pagination.
|
||||
- Repair federated CopyObject checksums, destination timestamps, reserved
|
||||
metadata, encrypted-object forwarding, legal hold and KMS context.
|
||||
- Make resync counters, target selection, cancellation and worker lifetimes
|
||||
reflect actual work; complete delete-marker purges and report bounded MRF drops.
|
||||
- Converge bucket metadata with deterministic source state, deletion tombstones,
|
||||
creation time recovery and diagnostics. The mixed-version export gate requires
|
||||
coordinated upgrades before tombstones are exported. See [the #77 record](docs/investigations/issue-77-current.md).
|
||||
- Include per-bucket CORS in metadata export/import, close metadata publication
|
||||
and logger races, and report effective bucket quotas in metrics.
|
||||
|
||||
### Console, dependencies and delivery
|
||||
|
||||
- Restore embedded Console login over loopback TLS, trusted-proxy handling and
|
||||
all four WebSocket connection limits. Preserve Go TLS defaults across transports.
|
||||
- Directly require `github.com/pgsty/silo-pkg/v3` v3.14.0; select Console
|
||||
`v0.0.0-20260913015128-417559bb2c97` and MC
|
||||
`v0.0.0-20260913012246-4f609a4da3bb` with explicit PGSTY replacements.
|
||||
- Pin upstream minio-go `v7.3.1-0.20260910142817-60bd07042d49`; refresh Go x/*
|
||||
modules and security fixes including bounded AMQP frame handling. Keep Go
|
||||
1.27.1 and go-systemd v22.6.0's NetBSD compatibility replacement.
|
||||
- Refresh container base digests and build static curl 8.22.0 from verified
|
||||
source for both Linux architectures. Pin the actual mcli 20260913 archives and
|
||||
hashes. Helm's client image follows that release; its Server image still names
|
||||
the latest published Server 20260903.
|
||||
|
||||
The dependency update passed the final candidate's Go, vulnerability and Test
|
||||
Release workflows; native curl builds passed on both architectures. A local
|
||||
ARM64 image passed startup, health, S3 transfer and embedded Console checks.
|
||||
These checks do not publish a Server tag or production image and do not replace
|
||||
cluster upgrade/rollback acceptance for the next release. Dated investigations
|
||||
retain the exact source and runtime boundaries they tested.
|
||||
|
||||
## RELEASE.2026-09-03T13-18-01Z
|
||||
|
||||
Published source: `9b11dc9469e650815b775cb47b039610644f5da4`.
|
||||
[Complete release notes](https://silo.pgsty.com/blog/release/silo-20260903/) ·
|
||||
[GitHub release](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-09-03T13-18-01Z)
|
||||
|
||||
This release ships Go 1.27.1, silo-pkg v3.13.2, upstream minio-go `0e78d3f18efe`,
|
||||
mcli 20260903 and embedded Console source `464a59d73ada` (v2.3.0 version identity).
|
||||
Installing the newer standalone mcli or Console does not replace components
|
||||
inside this existing Server binary or image.
|
||||
|
||||
Earlier releases: [release archive](https://github.com/pgsty/silo/releases).
|
||||
+1
-1
@@ -62,7 +62,7 @@ the Git history and [NOTICE](NOTICE); this record covers activity in the PGSTY r
|
||||
| :-- | :-- | :-- |
|
||||
| [@Vonng](https://github.com/Vonng) | Maintains SILO, Console, mcli, shared packages, releases, and documentation | 85 merged PRs across five repositories; [complete maintainer record](#maintainer-record) |
|
||||
| [@h5vx](https://github.com/h5vx) | Implemented per-bucket CORS configuration and enforcement | [pgsty/silo#71](https://github.com/pgsty/silo/pull/71) — Merged ([e4e3007da](https://github.com/pgsty/silo/commit/e4e3007da6d7d1198a6a050e34f84566d40a9654)) |
|
||||
| [@mrjavadseydi](https://github.com/mrjavadseydi) | Fixed effective bucket quota metrics; proposed access-frequency ILM | [pgsty/silo#132](https://github.com/pgsty/silo/pull/132) — Merged ([ad873c735](https://github.com/pgsty/silo/commit/ad873c73571b121293791f13f8dc46ddf5264d60))<br>[pgsty/silo#60](https://github.com/pgsty/silo/pull/60) — Open |
|
||||
| [@mrjavadseydi](https://github.com/mrjavadseydi) | Fixed effective bucket quota metrics; proposed access-frequency ILM | [pgsty/silo#132](https://github.com/pgsty/silo/pull/132) — Merged ([ad873c735](https://github.com/pgsty/silo/commit/ad873c73571b121293791f13f8dc46ddf5264d60))<br>[pgsty/silo#60](https://github.com/pgsty/silo/pull/60) — Merged; access-frequency feature subsequently removed |
|
||||
| [@Dansyuqri](https://github.com/Dansyuqri) | Added ChecksumType to multipart completion responses | [pgsty/silo#57](https://github.com/pgsty/silo/pull/57) — Merged ([a96116b12](https://github.com/pgsty/silo/commit/a96116b128bbf2aa42f85eafbf75eb6636cd36ee)) |
|
||||
| [@ycjlin](https://github.com/ycjlin) | Fixed missing-bucket ListObjects semantics | [pgsty/silo#37](https://github.com/pgsty/silo/pull/37) — Merged ([49c8aeac4](https://github.com/pgsty/silo/commit/49c8aeac403916f52f8588bbe8ee42753d86eeef)) |
|
||||
| [@pinginfo](https://github.com/pinginfo) | Repaired bucket notification streaming | [pgsty/silo#34](https://github.com/pgsty/silo/pull/34) — Merged ([b7f52ca43](https://github.com/pgsty/silo/commit/b7f52ca4336bc45a48b81b39c8983a5e6882a6fa)) |
|
||||
|
||||
+19
-11
@@ -1,4 +1,15 @@
|
||||
FROM golang:1.27.1-alpine AS build
|
||||
FROM golang:1.27.1-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS curl-build
|
||||
ARG TARGETARCH
|
||||
COPY dockerscripts/build-static-curl.sh /build/build-static-curl
|
||||
RUN /bin/sh /build/build-static-curl
|
||||
|
||||
# Exercise the exact shipped curl without a dynamic loader or shared libraries.
|
||||
FROM scratch AS curl-runtime
|
||||
COPY --from=curl-build /go/bin/curl /curl
|
||||
COPY --from=curl-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
ENTRYPOINT ["/curl"]
|
||||
|
||||
FROM golang:1.27.1-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS build
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -6,9 +17,9 @@ ENV GOPATH=/go
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
ARG MC_REPO=pgsty/mc
|
||||
ARG MC_VERSION=RELEASE.2026-09-03T07-13-05Z
|
||||
ARG MC_AMD64_SHA256=cd7fcd449bb6b52e2eb727431ba6975b1e5d90df011a75869020ea9ac9e2b2a8
|
||||
ARG MC_ARM64_SHA256=7962afc37c3e60e5758b19e819cb62d2f340ee655fad7b067e2ac9bc5716c2e8
|
||||
ARG MC_VERSION=RELEASE.2026-09-13T00-00-00Z
|
||||
ARG MC_AMD64_SHA256=9d2a92de9c7b887d9b944fe9ddce68d23f1b6df3415092e737594e56593f5e2b
|
||||
ARG MC_ARM64_SHA256=3d82e9ea6c601c4cb44fe5dd5f2ad1b7d7d64369378110f9ada9c524688a452a
|
||||
|
||||
RUN apk add -U --no-cache \
|
||||
ca-certificates \
|
||||
@@ -56,18 +67,14 @@ RUN apk add -U --no-cache \
|
||||
chmod +x /go/bin/mcli && \
|
||||
ln -sf mcli /go/bin/mc
|
||||
|
||||
COPY dockerscripts/download-static-curl.sh /build/download-static-curl
|
||||
RUN chmod +x /build/download-static-curl && \
|
||||
/build/download-static-curl
|
||||
|
||||
FROM registry.access.redhat.com/ubi9/ubi:latest AS certs
|
||||
FROM registry.access.redhat.com/ubi9/ubi:latest@sha256:206b65b8ee0f04b992818c9a51b29081b14974630d4850bc358097d0c44ea156 AS certs
|
||||
RUN dnf -y install ca-certificates && \
|
||||
update-ca-trust && \
|
||||
cp /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem /tmp/ca-certificates.crt && \
|
||||
dnf clean all && \
|
||||
rm -rf /var/cache/dnf
|
||||
|
||||
FROM registry.access.redhat.com/ubi9/ubi-micro:latest
|
||||
FROM registry.access.redhat.com/ubi9/ubi-micro:latest@sha256:f332c99eb8f798a8486821c91937f10ad64ee83d7e739303be2df051040918f6
|
||||
|
||||
LABEL org.opencontainers.image.title="Silo" \
|
||||
org.opencontainers.image.description="S3-Interface Libre Object Storage" \
|
||||
@@ -88,7 +95,8 @@ ENV MINIO_ACCESS_KEY_FILE=access_key \
|
||||
COPY --from=certs /tmp/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY silo /usr/bin/silo
|
||||
COPY --from=build /go/bin/mcli /usr/bin/mcli
|
||||
COPY --from=build /go/bin/curl* /usr/bin/
|
||||
COPY --from=curl-build /go/bin/curl /usr/bin/curl
|
||||
COPY --from=curl-build /go/share/curl /licenses/curl
|
||||
COPY dockerscripts/docker-entrypoint.sh /usr/bin/docker-entrypoint.sh
|
||||
COPY LICENSE /licenses/LICENSE
|
||||
COPY NOTICE /licenses/NOTICE
|
||||
|
||||
@@ -216,7 +216,7 @@ docker: checks build-debugging ## builds the local Linux Silo container image
|
||||
--ldflags "$(LDFLAGS)" -o "$$context/silo"; \
|
||||
mkdir -p "$$context/dockerscripts"; \
|
||||
cp Dockerfile.goreleaser LICENSE NOTICE CREDITS "$$context/"; \
|
||||
cp dockerscripts/docker-entrypoint.sh dockerscripts/download-static-curl.sh \
|
||||
cp dockerscripts/docker-entrypoint.sh dockerscripts/build-static-curl.sh \
|
||||
"$$context/dockerscripts/"; \
|
||||
docker build -q --no-cache --platform linux/$(GOARCH) -t $(TAG) --build-arg TARGETARCH=$(GOARCH) \
|
||||
-f "$$context/Dockerfile.goreleaser" "$$context"
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
> [!NOTE]
|
||||
> Renamed from `pgsty/minio` to `pgsty/silo`, default branch `master` → `main`, on 2026-08-06. Artifacts under the original MinIO identity stay published on the archived [`minio`](https://github.com/pgsty/silo/tree/minio) branch and in releases up to [`RELEASE.2026-08-04T00-00-00Z`](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-08-04T00-00-00Z).
|
||||
|
||||
## Current release and main branch
|
||||
|
||||
The latest published Server is [20260903](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-09-03T13-18-01Z).
|
||||
As of 2026-09-13, the main branch has newer security, storage, Console and
|
||||
shared-package changes that have not shipped in a Server release. See
|
||||
[CHANGELOG.md](CHANGELOG.md) and the [component version matrix](https://silo.pgsty.com/compatibility/versions/)
|
||||
for the exact release/source boundary, including SN-2026-011 and password-policy migration.
|
||||
|
||||
## Overview
|
||||
|
||||
PGSTY SILO keeps one maintained release line of the open-source MinIO server alive after upstream ended community distribution: builds, packages, multi-arch images, security fixes, and the full web console. Pigsty runs it in production as its PostgreSQL backup repository.
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
> [!NOTE]
|
||||
> 2026-08-06,本仓库由 `pgsty/minio` 更名为 `pgsty/silo`,默认分支由 `master` 更名为 `main`。以原 MinIO 形态维持的归档构件仍位于归档的 [`minio`](https://github.com/pgsty/silo/tree/minio) 分支,以及截止 [`RELEASE.2026-08-04T00-00-00Z`](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-08-04T00-00-00Z) 的历次发布中。
|
||||
|
||||
## 当前发行版与主分支
|
||||
|
||||
最新已发布的 Server 仍为 [20260903](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-09-03T13-18-01Z)。
|
||||
截至 2026-09-13,主分支已合入更新的安全、存储、Console 与共享包改动,但尚未发布新 Server。
|
||||
准确的已发布/源码边界见 [CHANGELOG.md](CHANGELOG.md) 与[组件版本矩阵](https://silo.pgsty.com/zh/compatibility/versions/),
|
||||
其中包括 SN-2026-011 修复状态与密码权限迁移要求。
|
||||
|
||||
## 概述
|
||||
|
||||
上游停止社区发行后,Silo 为开源 MinIO 服务端维护一条持续可用的版本线:构建、软件包、多架构镜像、安全修复与完整 Web 控制台。Pigsty 在生产环境中用它承载 PostgreSQL 备份存储。
|
||||
|
||||
@@ -40,7 +40,7 @@ if [ -n "${MCLI_BIN:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release=${MCLI_RELEASE:-RELEASE.2026-09-03T07-13-05Z}
|
||||
release=${MCLI_RELEASE:-RELEASE.2026-09-13T00-00-00Z}
|
||||
version_hyphen=${release#RELEASE.}
|
||||
package_version=$(printf '%s\n' "${version_hyphen}" | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2})-([0-9]{2})-([0-9]{2})Z$/\1\2\3\4\5\6.0.0/')
|
||||
if [ "${package_version}" = "${version_hyphen}" ]; then
|
||||
|
||||
@@ -289,16 +289,6 @@
|
||||
"MINIO_IDENTITY_TLS_SKIP_VERIFY",
|
||||
"MINIO_IDENTITY_TLS_STS_EXPIRY",
|
||||
"MINIO_IDLE_TIMEOUT",
|
||||
"MINIO_ILM_ACCESS_BINS",
|
||||
"MINIO_ILM_ACCESS_BIN_WIDTH",
|
||||
"MINIO_ILM_ACCESS_FLUSH",
|
||||
"MINIO_ILM_ACCESS_MAX_SIZE",
|
||||
"MINIO_ILM_ACCESS_MAX_TRACKED",
|
||||
"MINIO_ILM_ACCESS_MIN_RESIDENCY",
|
||||
"MINIO_ILM_ACCESS_POOLS",
|
||||
"MINIO_ILM_ACCESS_PROMOTE_WATERMARK",
|
||||
"MINIO_ILM_ACCESS_TIERING",
|
||||
"MINIO_ILM_ACCESS_WORKERS",
|
||||
"MINIO_ILM_EXPIRATION_WORKERS",
|
||||
"MINIO_ILM_TRANSITION_WORKERS",
|
||||
"MINIO_INTERFACE",
|
||||
@@ -512,6 +502,7 @@
|
||||
"MINIO_SITE_COMMENT",
|
||||
"MINIO_SITE_NAME",
|
||||
"MINIO_SITE_REGION",
|
||||
"MINIO_SITE_REPLICATION_METADATA_TOMBSTONES",
|
||||
"MINIO_STORAGE_CLASS_COMMENT",
|
||||
"MINIO_STORAGE_CLASS_INLINE_BLOCK",
|
||||
"MINIO_STORAGE_CLASS_OPTIMIZE",
|
||||
@@ -744,7 +735,6 @@
|
||||
"/idp/ldap/policy/{operation}",
|
||||
"/idp/openid/list-access-keys-bulk",
|
||||
"/ilm",
|
||||
"/ilm/access",
|
||||
"/import-bucket-metadata",
|
||||
"/import-iam",
|
||||
"/import-iam-v2",
|
||||
@@ -914,6 +904,7 @@
|
||||
".minio.sys/config/config.json",
|
||||
".minio.sys/config/hello.txt",
|
||||
".minio.sys/config/iam/${username}/identity.json",
|
||||
".minio.sys/config/ilm/access",
|
||||
".minio.sys/format.json",
|
||||
".minio.sys/multipart",
|
||||
".minio.sys/multipart/bucket/object/uploads.json",
|
||||
|
||||
@@ -36,7 +36,7 @@ for file in \
|
||||
buildscripts/verify-helm-migration.sh \
|
||||
Dockerfile.goreleaser \
|
||||
Dockerfile.distroless \
|
||||
dockerscripts/download-static-curl.sh \
|
||||
dockerscripts/build-static-curl.sh \
|
||||
dockerscripts/docker-entrypoint.sh \
|
||||
helm/silo/Chart.yaml \
|
||||
helm/silo/values.yaml \
|
||||
@@ -101,7 +101,7 @@ require_text Dockerfile.goreleaser "Published checksum drift"
|
||||
require_text Dockerfile.distroless 'COPY --chmod=0755 silo /usr/bin/silo'
|
||||
require_text Dockerfile.distroless 'ENTRYPOINT ["/usr/bin/silo"]'
|
||||
require_text Dockerfile.distroless '"/usr/bin/silo", "healthcheck", "ready"'
|
||||
require_text dockerscripts/download-static-curl.sh "sha256sum -c"
|
||||
require_text dockerscripts/build-static-curl.sh "sha256sum -c"
|
||||
require_text helm/silo/Chart.yaml "name: silo"
|
||||
require_text helm/silo/values.yaml "repository: pgsty/silo"
|
||||
require_text helm/silo/templates/deployment.yaml "/usr/bin/docker-entrypoint.sh silo server"
|
||||
|
||||
@@ -76,7 +76,7 @@ func (a adminAPIHandlers) PutBucketQuotaConfigHandler(w http.ResponseWriter, r *
|
||||
return
|
||||
}
|
||||
|
||||
quotaConfig, err := parseBucketQuota(bucket, data)
|
||||
_, err = parseBucketQuota(bucket, data)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
@@ -94,9 +94,6 @@ func (a adminAPIHandlers) PutBucketQuotaConfigHandler(w http.ResponseWriter, r *
|
||||
Quota: data,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
if quotaConfig.Size == 0 && quotaConfig.Quota == 0 {
|
||||
bucketMeta.Quota = nil
|
||||
}
|
||||
|
||||
// Call site replication hook.
|
||||
replLogIf(ctx, globalSiteReplicationSys.BucketMetaHook(ctx, bucketMeta))
|
||||
@@ -438,7 +435,7 @@ func (a adminAPIHandlers) ExportBucketMetadataHandler(w http.ResponseWriter, r *
|
||||
writeErrorResponse(ctx, w, exportError(ctx, err, cfgFile, bucket), r.URL)
|
||||
return
|
||||
}
|
||||
configData, err := json.Marshal(config)
|
||||
configData, err := canonicalBucketPolicy(config)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, exportError(ctx, err, cfgFile, bucket), r.URL)
|
||||
return
|
||||
@@ -922,7 +919,7 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
|
||||
continue
|
||||
}
|
||||
|
||||
configData, err := json.Marshal(bucketPolicy)
|
||||
configData, err := canonicalBucketPolicy(bucketPolicy)
|
||||
if err != nil {
|
||||
rpt.SetStatus(bucket, fileName, err)
|
||||
continue
|
||||
@@ -1081,18 +1078,39 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
|
||||
continue
|
||||
}
|
||||
var merged BucketMetadata
|
||||
var commitAt time.Time
|
||||
err := func() error {
|
||||
lockCtx, unlock, err := lockBucketMetadata(ctx, objectAPI, bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
merged, err = loadBucketMetadataParse(lockCtx, objectAPI, bucket, true)
|
||||
merged, err = loadBucketMetadataParse(lockCtx, objectAPI, bucket, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureBucketMetadataCreated(lockCtx, objectAPI, &merged); err != nil {
|
||||
return err
|
||||
}
|
||||
commitAt = UTCNow()
|
||||
for _, file := range replicatedBucketConfigs {
|
||||
if _, ok := fields[file]; ok {
|
||||
commitAt = localBucketConfigUpdatedAt(merged, file, commitAt)
|
||||
}
|
||||
}
|
||||
applyImportedBucketMetadata(&merged, *meta, fields)
|
||||
return globalBucketMetadataSys.saveMetadata(lockCtx, objectAPI, merged)
|
||||
for _, file := range replicatedBucketConfigs {
|
||||
if _, ok := fields[file]; !ok {
|
||||
continue
|
||||
}
|
||||
data, at := replicatedBucketConfig(&merged, file)
|
||||
payload, _, err := bucketConfigPayload(bucket, file, *data, len(merged.ObjectLockConfigXML) != 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*data, *at = payload, commitAt
|
||||
}
|
||||
return globalBucketMetadataSys.saveMetadata(lockCtx, objectAPI, &merged)
|
||||
}()
|
||||
if err != nil {
|
||||
rpt.SetStatus(bucket, "", err)
|
||||
@@ -1100,7 +1118,7 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
|
||||
}
|
||||
*meta = merged
|
||||
globalNotificationSys.LoadBucketMetadata(bgContext(ctx), bucket)
|
||||
hook := madmin.SRBucketMeta{Bucket: bucket, UpdatedAt: updatedAt}
|
||||
hook := madmin.SRBucketMeta{Bucket: bucket, UpdatedAt: commitAt}
|
||||
var hookNeeded bool
|
||||
if _, ok := fields[bucketQuotaConfigFile]; ok {
|
||||
hook.Quota = meta.QuotaConfigJSON
|
||||
@@ -1108,7 +1126,7 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
|
||||
}
|
||||
if _, ok := fields[bucketPolicyConfig]; ok {
|
||||
hook.Policy = meta.PolicyConfigJSON
|
||||
hookNeeded = true
|
||||
hookNeeded = hookNeeded || len(hook.Policy) != 0
|
||||
}
|
||||
if _, ok := fields[bucketVersioningConfig]; ok {
|
||||
hook.Versioning = enc(meta.VersioningConfigXML)
|
||||
@@ -1129,6 +1147,12 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
|
||||
if hookNeeded {
|
||||
err = globalSiteReplicationSys.BucketMetaHook(ctx, hook)
|
||||
}
|
||||
if _, ok := fields[bucketPolicyConfig]; ok && len(meta.PolicyConfigJSON) == 0 {
|
||||
// An omitted bulk Policy cannot express deletion.
|
||||
err = errors.Join(err, globalSiteReplicationSys.BucketMetaHook(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypePolicy, Bucket: bucket, UpdatedAt: commitAt,
|
||||
}))
|
||||
}
|
||||
if _, ok := fields[bucketCorsConfig]; ok {
|
||||
// CORS carries its own timestamp, so it replicates through the
|
||||
// dedicated event rather than the shared bucket metadata hook. It
|
||||
|
||||
@@ -467,25 +467,6 @@ func testAdversarialHealCorsPropagatesNewerEqualValueTimestamp(obj ObjectLayer,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdversarialBucketMetadataComparisonIsBase64CaseSensitive(t *testing.T) {
|
||||
upper := "QQ=="
|
||||
lower := "qQ=="
|
||||
upperBytes, err := base64.StdEncoding.Strict().DecodeString(upper)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lowerBytes, err := base64.StdEncoding.Strict().DecodeString(lower)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(upperBytes) == string(lowerBytes) {
|
||||
t.Fatal("test inputs unexpectedly decode to the same bytes")
|
||||
}
|
||||
if isBucketMetadataEqual(&upper, &lower) {
|
||||
t.Fatal("different decoded payloads were treated as equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteReplicationStatusDetectsCorsTimestampMismatch(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t,
|
||||
|
||||
@@ -39,7 +39,6 @@ const (
|
||||
lcEventSrc_s3PutObject
|
||||
lcEventSrc_s3CopyObject
|
||||
lcEventSrc_s3CompleteMultipartUpload
|
||||
lcEventSrc_AccessTier
|
||||
)
|
||||
|
||||
//revive:enable:var-naming
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7/pkg/tags"
|
||||
bucketsse "github.com/minio/minio/internal/bucket/encryption"
|
||||
objectlock "github.com/minio/minio/internal/bucket/object/lock"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
// Only these fields share the site-replication source-time ordering contract.
|
||||
// Bulk apply/import process Object Lock before Versioning, whose effective
|
||||
// document depends on it. Periodic heal retains its existing type order.
|
||||
var replicatedBucketConfigs = [...]string{
|
||||
objectLockConfig, bucketVersioningConfig, bucketPolicyConfig,
|
||||
bucketTaggingConfig, bucketSSEConfig, bucketQuotaConfigFile,
|
||||
}
|
||||
|
||||
func replicatedBucketConfig(meta *BucketMetadata, file string) (*[]byte, *time.Time) {
|
||||
switch file {
|
||||
case bucketPolicyConfig:
|
||||
return &meta.PolicyConfigJSON, &meta.PolicyConfigUpdatedAt
|
||||
case bucketTaggingConfig:
|
||||
return &meta.TaggingConfigXML, &meta.TaggingConfigUpdatedAt
|
||||
case bucketSSEConfig:
|
||||
return &meta.EncryptionConfigXML, &meta.EncryptionConfigUpdatedAt
|
||||
case bucketQuotaConfigFile:
|
||||
return &meta.QuotaConfigJSON, &meta.QuotaConfigUpdatedAt
|
||||
case bucketVersioningConfig:
|
||||
return &meta.VersioningConfigXML, &meta.VersioningConfigUpdatedAt
|
||||
case objectLockConfig:
|
||||
return &meta.ObjectLockConfigXML, &meta.ObjectLockConfigUpdatedAt
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Callers that only need to know whether a file is under the contract must not
|
||||
// probe replicatedBucketConfig with a throwaway BucketMetadata.
|
||||
func isReplicatedBucketConfig(file string) bool {
|
||||
for _, replicated := range replicatedBucketConfigs {
|
||||
if replicated == file {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func bucketConfigUpdateOnly(file string) bool {
|
||||
return file == bucketVersioningConfig || file == objectLockConfig
|
||||
}
|
||||
|
||||
// Reuse the persistence rule before comparison, so an accepted Versioning
|
||||
// event and the document Save actually writes have the same comparison key.
|
||||
func effectiveBucketVersioning(data []byte, lockEnabled bool) []byte {
|
||||
if lockEnabled {
|
||||
config, err := versioning.ParseConfig(bytes.NewReader(data))
|
||||
if err != nil || !config.Enabled() || config.PrefixesExcluded() {
|
||||
return enabledBucketVersioningConfig
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// A parsed policy still contains map-backed sets with nondeterministic Marshal
|
||||
// order. Sort every set array recursively, including statements and conditions.
|
||||
// RawMessage keeps integer values intact; decoding through float64 would not.
|
||||
func canonicalBucketPolicyJSON(data json.RawMessage) (json.RawMessage, error) {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
switch data[0] {
|
||||
case '{':
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range obj {
|
||||
var err error
|
||||
obj[key], err = canonicalBucketPolicyJSON(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return json.Marshal(obj)
|
||||
case '[':
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal(data, &arr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range arr {
|
||||
var err error
|
||||
arr[i], err = canonicalBucketPolicyJSON(arr[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Slice(arr, func(i, j int) bool { return bytes.Compare(arr[i], arr[j]) < 0 })
|
||||
return json.Marshal(arr)
|
||||
default:
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return compact.Bytes(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Encode the validated policy fields explicitly: BPStatement's required
|
||||
// Action/Resource tags otherwise try to marshal empty sets for the supported
|
||||
// NotAction/NotResource alternatives. This stays within the existing schema.
|
||||
func canonicalBucketPolicy(cfg *policy.BucketPolicy) ([]byte, error) {
|
||||
if cfg.IsEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
doc := map[string]any{"Version": cfg.Version}
|
||||
if cfg.ID != "" {
|
||||
doc["ID"] = cfg.ID
|
||||
}
|
||||
statements := make([]map[string]any, 0, len(cfg.Statements))
|
||||
for _, st := range cfg.Statements {
|
||||
statement := map[string]any{"Effect": st.Effect, "Principal": st.Principal}
|
||||
if st.SID != "" {
|
||||
statement["Sid"] = st.SID
|
||||
}
|
||||
if len(st.Actions) != 0 {
|
||||
statement["Action"] = st.Actions
|
||||
}
|
||||
if len(st.NotActions) != 0 {
|
||||
statement["NotAction"] = st.NotActions
|
||||
}
|
||||
if len(st.Resources) != 0 {
|
||||
statement["Resource"] = st.Resources
|
||||
}
|
||||
if len(st.NotResources) != 0 {
|
||||
statement["NotResource"] = st.NotResources
|
||||
}
|
||||
if len(st.Conditions) != 0 {
|
||||
statement["Condition"] = st.Conditions
|
||||
}
|
||||
statements = append(statements, statement)
|
||||
}
|
||||
doc["Statement"] = statements
|
||||
data, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return canonicalBucketPolicyJSON(data)
|
||||
}
|
||||
|
||||
// Validate with the same parsers as Save. Policy's established empty-policy
|
||||
// semantics are deletion; a parsed zero quota is still a live document.
|
||||
func bucketConfigPayload(bucket, file string, data []byte, lockEnabled bool) ([]byte, []byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var err error
|
||||
key := data
|
||||
switch file {
|
||||
case bucketPolicyConfig:
|
||||
var cfg *policy.BucketPolicy
|
||||
cfg, err = policy.ParseBucketPolicyConfig(bytes.NewReader(data), bucket)
|
||||
if err == nil {
|
||||
if cfg.IsEmpty() {
|
||||
return nil, nil, nil
|
||||
}
|
||||
key, err = canonicalBucketPolicy(cfg)
|
||||
}
|
||||
case bucketQuotaConfigFile:
|
||||
cfg, parseErr := parseBucketQuota(bucket, data)
|
||||
err = parseErr
|
||||
if err == nil {
|
||||
key, err = json.Marshal(cfg)
|
||||
}
|
||||
case bucketTaggingConfig:
|
||||
_, err = tags.ParseBucketXML(bytes.NewReader(data))
|
||||
case bucketSSEConfig:
|
||||
_, err = bucketsse.ParseBucketSSEConfig(bytes.NewReader(data))
|
||||
case objectLockConfig:
|
||||
_, err = objectlock.ParseObjectLockConfig(bytes.NewReader(data))
|
||||
case bucketVersioningConfig:
|
||||
data = effectiveBucketVersioning(data, lockEnabled)
|
||||
key = data
|
||||
_, err = versioning.ParseConfig(bytes.NewReader(data))
|
||||
}
|
||||
return data, key, err
|
||||
}
|
||||
|
||||
type bucketConfigState struct {
|
||||
data, key []byte
|
||||
at time.Time
|
||||
real, valid bool
|
||||
}
|
||||
|
||||
func newBucketConfigState(bucket, file string, data []byte, at, created time.Time, lockEnabled bool) (bucketConfigState, error) {
|
||||
data, key, err := bucketConfigPayload(bucket, file, data, lockEnabled)
|
||||
if err != nil {
|
||||
return bucketConfigState{}, err
|
||||
}
|
||||
if at.IsZero() {
|
||||
at = created
|
||||
}
|
||||
valid := !created.IsZero() && !at.Before(created)
|
||||
modified := valid && at.After(created)
|
||||
if bucketConfigUpdateOnly(file) && len(data) == 0 {
|
||||
modified = false
|
||||
}
|
||||
return bucketConfigState{data: data, key: key, at: at, real: modified, valid: valid}, nil
|
||||
}
|
||||
|
||||
func (s bucketConfigState) candidate() bool {
|
||||
return s.valid && (s.real || len(s.data) != 0)
|
||||
}
|
||||
|
||||
func compareBucketConfigStates(a, b bucketConfigState) int {
|
||||
if a.valid != b.valid {
|
||||
if a.valid {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if a.real != b.real {
|
||||
if a.real {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if a.real {
|
||||
if n := a.at.Compare(b.at); n != 0 {
|
||||
return n
|
||||
}
|
||||
// At equal source time a real deletion wins, preventing resurrection.
|
||||
if (len(a.data) == 0) != (len(b.data) == 0) {
|
||||
if len(a.data) == 0 {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return bytes.Compare(a.key, b.key)
|
||||
}
|
||||
|
||||
func localBucketConfigUpdatedAt(meta BucketMetadata, file string, now time.Time) time.Time {
|
||||
_, at := replicatedBucketConfig(&meta, file)
|
||||
for _, lower := range []time.Time{meta.Created, *at} {
|
||||
if !now.After(lower) {
|
||||
now = lower.Add(time.Nanosecond)
|
||||
}
|
||||
}
|
||||
return now.UTC()
|
||||
}
|
||||
|
||||
func ensureBucketMetadataCreated(ctx context.Context, obj ObjectLayer, meta *BucketMetadata) error {
|
||||
if !meta.Created.IsZero() {
|
||||
return nil
|
||||
}
|
||||
info, err := obj.GetBucketInfo(ctx, meta.Name, BucketOptions{NoMetadata: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Created.IsZero() {
|
||||
return errors.New("bucket metadata creation time is unknown")
|
||||
}
|
||||
meta.Created = info.Created.UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyBucketConfig runs under metadata.lock, on freshly loaded metadata. It
|
||||
// changes only the selected field; the caller persists once after all checks.
|
||||
func applyBucketConfig(meta *BucketMetadata, file string, data []byte, at time.Time) (bool, error) {
|
||||
if bucketConfigUpdateOnly(file) && len(data) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
current, currentAt := replicatedBucketConfig(meta, file)
|
||||
lockEnabled := len(meta.ObjectLockConfigXML) != 0
|
||||
incoming, err := newBucketConfigState(meta.Name, file, data, at, meta.Created, lockEnabled)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !incoming.candidate() {
|
||||
return false, nil
|
||||
}
|
||||
local, err := newBucketConfigState(meta.Name, file, *current, *currentAt, meta.Created, lockEnabled)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if compareBucketConfigStates(incoming, local) <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
*current, *currentAt = bytes.Clone(incoming.data), incoming.at.UTC()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func rebaseBucketConfigDefaults(meta *BucketMetadata, oldCreated time.Time) {
|
||||
if meta.Created.Equal(oldCreated) {
|
||||
return
|
||||
}
|
||||
// These six fields alone use Created to distinguish a baseline from a
|
||||
// tombstone. Preserve actual source times when adopting an existing bucket.
|
||||
for _, file := range replicatedBucketConfigs {
|
||||
_, at := replicatedBucketConfig(meta, file)
|
||||
if at.IsZero() || at.Equal(oldCreated) {
|
||||
*at = meta.Created
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
-52
@@ -126,19 +126,42 @@ func (sys *BucketMetadataSys) Set(bucket string, meta BucketMetadata) {
|
||||
}
|
||||
}
|
||||
|
||||
func (sys *BucketMetadataSys) updateAndParse(ctx context.Context, bucket string, configFile string, configData []byte, parse, lifecycleDelete bool) (updatedAt time.Time, err error) {
|
||||
// bucketMetadataUpdate returns the committed snapshot to the caller. meta and
|
||||
// updatedAt hold the saved state only when changed is true. Local writes always
|
||||
// change state, because localBucketConfigUpdatedAt is strictly greater than the
|
||||
// current field time, so their handlers can broadcast meta without rechecking.
|
||||
type bucketMetadataUpdate struct {
|
||||
meta BucketMetadata
|
||||
updatedAt time.Time
|
||||
changed bool
|
||||
}
|
||||
|
||||
func (sys *BucketMetadataSys) updateAndParse(ctx context.Context, bucket, configFile string, configData []byte, parse, lifecycleDelete bool) (time.Time, error) {
|
||||
result, err := sys.updateAndParseMetadata(ctx, bucket, configFile, configData, parse, lifecycleDelete, nil)
|
||||
return result.updatedAt, err
|
||||
}
|
||||
|
||||
func (sys *BucketMetadataSys) updateAndParseMetadata(ctx context.Context, bucket string, configFile string, configData []byte, parse, lifecycleDelete bool, sourceTime *time.Time) (result bucketMetadataUpdate, err error) {
|
||||
objAPI := newObjectLayerFn()
|
||||
if objAPI == nil {
|
||||
return updatedAt, errServerNotInitialized
|
||||
return result, errServerNotInitialized
|
||||
}
|
||||
|
||||
if isMinioMetaBucketName(bucket) {
|
||||
return updatedAt, errInvalidArgument
|
||||
return result, errInvalidArgument
|
||||
}
|
||||
// Load deletions without parsed caches (notably quota), and compare the
|
||||
// six replicated fields against the raw document under the same lock.
|
||||
if isReplicatedBucketConfig(configFile) {
|
||||
parse = false
|
||||
if bucketConfigUpdateOnly(configFile) && len(configData) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
notifyCtx := ctx
|
||||
ctx, unlock, err := lockBucketMetadata(ctx, objAPI, bucket)
|
||||
if err != nil {
|
||||
return updatedAt, err
|
||||
return result, err
|
||||
}
|
||||
|
||||
err = func() error {
|
||||
@@ -158,55 +181,73 @@ func (sys *BucketMetadataSys) updateAndParse(ctx context.Context, bucket string,
|
||||
return err
|
||||
}
|
||||
}
|
||||
updatedAt = UTCNow()
|
||||
switch configFile {
|
||||
case bucketPolicyConfig:
|
||||
meta.PolicyConfigJSON = configData
|
||||
meta.PolicyConfigUpdatedAt = updatedAt
|
||||
case bucketNotificationConfig:
|
||||
meta.NotificationConfigXML = configData
|
||||
meta.NotificationConfigUpdatedAt = updatedAt
|
||||
case bucketLifecycleConfig:
|
||||
meta.LifecycleConfigXML = configData
|
||||
meta.LifecycleConfigUpdatedAt = updatedAt
|
||||
case bucketSSEConfig:
|
||||
meta.EncryptionConfigXML = configData
|
||||
meta.EncryptionConfigUpdatedAt = updatedAt
|
||||
case bucketTaggingConfig:
|
||||
meta.TaggingConfigXML = configData
|
||||
meta.TaggingConfigUpdatedAt = updatedAt
|
||||
case bucketQuotaConfigFile:
|
||||
meta.QuotaConfigJSON = configData
|
||||
meta.QuotaConfigUpdatedAt = updatedAt
|
||||
case objectLockConfig:
|
||||
meta.ObjectLockConfigXML = configData
|
||||
meta.ObjectLockConfigUpdatedAt = updatedAt
|
||||
case bucketVersioningConfig:
|
||||
meta.VersioningConfigXML = configData
|
||||
meta.VersioningConfigUpdatedAt = updatedAt
|
||||
case bucketReplicationConfig:
|
||||
meta.ReplicationConfigXML = configData
|
||||
meta.ReplicationConfigUpdatedAt = updatedAt
|
||||
case bucketTargetsFile:
|
||||
meta.BucketTargetsConfigJSON, meta.BucketTargetsConfigMetaJSON, err = encryptBucketMetadata(ctx, meta.Name, configData, kms.Context{
|
||||
bucket: meta.Name,
|
||||
bucketTargetsFile: bucketTargetsFile,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error encrypting bucket target metadata %w", err)
|
||||
updatedAt := UTCNow()
|
||||
if isReplicatedBucketConfig(configFile) {
|
||||
if err := ensureBucketMetadataCreated(ctx, objAPI, &meta); err != nil {
|
||||
var at time.Time
|
||||
if sourceTime != nil {
|
||||
at = *sourceTime
|
||||
}
|
||||
logBucketConfigReplication(ctx, bucket, configFile, "indeterminate", at, meta.Created, err.Error())
|
||||
return err
|
||||
}
|
||||
if sourceTime == nil || sourceTime.IsZero() {
|
||||
updatedAt = localBucketConfigUpdatedAt(meta, configFile, updatedAt)
|
||||
if sourceTime != nil {
|
||||
logBucketConfigReplication(ctx, bucket, configFile, "legacy-zero", *sourceTime, meta.Created, "assigned local source time")
|
||||
}
|
||||
} else {
|
||||
updatedAt = sourceTime.UTC()
|
||||
}
|
||||
if updatedAt.Before(meta.Created) {
|
||||
logBucketConfigReplication(ctx, bucket, configFile, "before-created", updatedAt, meta.Created, "peer event")
|
||||
return nil
|
||||
}
|
||||
changed, err := applyBucketConfig(&meta, configFile, configData, updatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
switch configFile {
|
||||
case bucketNotificationConfig:
|
||||
meta.NotificationConfigXML = configData
|
||||
meta.NotificationConfigUpdatedAt = updatedAt
|
||||
case bucketLifecycleConfig:
|
||||
meta.LifecycleConfigXML = configData
|
||||
meta.LifecycleConfigUpdatedAt = updatedAt
|
||||
case bucketReplicationConfig:
|
||||
meta.ReplicationConfigXML = configData
|
||||
meta.ReplicationConfigUpdatedAt = updatedAt
|
||||
case bucketTargetsFile:
|
||||
meta.BucketTargetsConfigJSON, meta.BucketTargetsConfigMetaJSON, err = encryptBucketMetadata(ctx, meta.Name, configData, kms.Context{
|
||||
bucket: meta.Name,
|
||||
bucketTargetsFile: bucketTargetsFile,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error encrypting bucket target metadata %w", err)
|
||||
}
|
||||
meta.BucketTargetsConfigUpdatedAt = updatedAt
|
||||
meta.BucketTargetsConfigMetaUpdatedAt = updatedAt
|
||||
default:
|
||||
return fmt.Errorf("Unknown bucket %s metadata update requested %s", bucket, configFile)
|
||||
}
|
||||
meta.BucketTargetsConfigUpdatedAt = updatedAt
|
||||
meta.BucketTargetsConfigMetaUpdatedAt = updatedAt
|
||||
default:
|
||||
return fmt.Errorf("Unknown bucket %s metadata update requested %s", bucket, configFile)
|
||||
}
|
||||
return sys.saveMetadata(ctx, objAPI, meta)
|
||||
if err := sys.saveMetadata(ctx, objAPI, &meta); err != nil {
|
||||
return err
|
||||
}
|
||||
result = bucketMetadataUpdate{meta: meta, updatedAt: updatedAt, changed: true}
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
return updatedAt, err
|
||||
return result, err
|
||||
}
|
||||
globalNotificationSys.LoadBucketMetadata(bgContext(notifyCtx), bucket) // Do not use caller context here
|
||||
return updatedAt, nil
|
||||
if result.changed {
|
||||
globalNotificationSys.LoadBucketMetadata(bgContext(notifyCtx), bucket)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (sys *BucketMetadataSys) save(ctx context.Context, meta BucketMetadata) error {
|
||||
@@ -219,7 +260,7 @@ func (sys *BucketMetadataSys) save(ctx context.Context, meta BucketMetadata) err
|
||||
return errInvalidArgument
|
||||
}
|
||||
|
||||
if err := sys.saveMetadata(ctx, objAPI, meta); err != nil {
|
||||
if err := sys.saveMetadata(ctx, objAPI, &meta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -229,7 +270,7 @@ func (sys *BucketMetadataSys) save(ctx context.Context, meta BucketMetadata) err
|
||||
|
||||
// saveMetadata persists and publishes metadata locally. Callers performing a
|
||||
// read-modify-write must hold metadata.lock and release it before peer fan-out.
|
||||
func (sys *BucketMetadataSys) saveMetadata(ctx context.Context, objAPI ObjectLayer, meta BucketMetadata) error {
|
||||
func (sys *BucketMetadataSys) saveMetadata(ctx context.Context, objAPI ObjectLayer, meta *BucketMetadata) error {
|
||||
// A writer may have queued for metadata.lock before DeleteBucket completed.
|
||||
// Recheck the physical bucket under that lock, before recreating metadata.
|
||||
if _, err := objAPI.GetBucketInfo(ctx, meta.Name, BucketOptions{NoMetadata: true}); err != nil {
|
||||
@@ -238,7 +279,7 @@ func (sys *BucketMetadataSys) saveMetadata(ctx context.Context, objAPI ObjectLay
|
||||
if err := meta.Save(ctx, objAPI); err != nil {
|
||||
return err
|
||||
}
|
||||
sys.Set(meta.Name, meta)
|
||||
sys.Set(meta.Name, *meta)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -351,7 +392,7 @@ func (sys *BucketMetadataSys) UpdateExpiryLCConfig(ctx context.Context, bucket s
|
||||
}
|
||||
meta.LifecycleConfigXML = configData
|
||||
meta.LifecycleConfigUpdatedAt = UTCNow()
|
||||
return sys.saveMetadata(ctx, objAPI, meta)
|
||||
return sys.saveMetadata(ctx, objAPI, &meta)
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -388,15 +388,7 @@ func (b *BucketMetadata) parseAllConfigs(ctx context.Context, objectAPI ObjectLa
|
||||
} else {
|
||||
b.objectLockConfig = nil
|
||||
}
|
||||
if b.objectLockConfig != nil {
|
||||
// Object Lock requires every object to be versioned. Whatever the lock
|
||||
// document contains, a suspended or prefix-excluded versioning document
|
||||
// is replaced by plain Enabled versioning; Save persists the result.
|
||||
config, versioningErr := versioning.ParseConfig(bytes.NewReader(b.VersioningConfigXML))
|
||||
if versioningErr != nil || !config.Enabled() || config.PrefixesExcluded() {
|
||||
b.VersioningConfigXML = enabledBucketVersioningConfig
|
||||
}
|
||||
}
|
||||
b.VersioningConfigXML = effectiveBucketVersioning(b.VersioningConfigXML, b.objectLockConfig != nil)
|
||||
|
||||
if len(b.VersioningConfigXML) != 0 {
|
||||
b.versioningConfig, err = versioning.ParseConfig(bytes.NewReader(b.VersioningConfigXML))
|
||||
|
||||
@@ -39,3 +39,28 @@ func TestBucketMetadataCorsRoundTrip(t *testing.T) {
|
||||
t.Fatalf("CorsConfigUpdatedAt not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// A persisted retired extension must not prevent the whole bucket's metadata
|
||||
// from loading, including unrelated versioning and ordinary lifecycle rules.
|
||||
func TestBucketMetadataRetiredAccessTiering(t *testing.T) {
|
||||
meta := newBucketMetadata("retired-access")
|
||||
meta.LifecycleConfigXML = []byte(`<LifecycleConfiguration><AccessTierQuota>500GiB</AccessTierQuota><Rule><ID>access</ID><Status>Enabled</Status><Filter><Prefix>logs/</Prefix></Filter><AccessTransition><Window>10m</Window><PromoteAfterAccesses>10</PromoteAfterAccesses></AccessTransition></Rule><Rule><ID>ordinary</ID><Status>Enabled</Status><Filter><Prefix>expired/</Prefix></Filter><Expiration><Days>30</Days></Expiration></Rule></LifecycleConfiguration>`)
|
||||
meta.VersioningConfigXML = []byte(`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>`)
|
||||
data, err := meta.MarshalMsg(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := newBucketMetadata(meta.Name)
|
||||
if _, err := got.UnmarshalMsg(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := got.parseAllConfigs(t.Context(), nil); err != nil {
|
||||
t.Fatalf("bucket metadata failed to load: %v", err)
|
||||
}
|
||||
if got.lifecycleConfig == nil || got.lifecycleConfig.HasActiveRules("logs/") || !got.lifecycleConfig.HasActiveRules("expired/") {
|
||||
t.Fatal("unexpected lifecycle behavior")
|
||||
}
|
||||
if got.versioningConfig == nil || !got.versioningConfig.Enabled() {
|
||||
t.Fatal("unrelated versioning lost")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
@@ -100,13 +99,13 @@ func (api objectAPIHandlers) PutBucketPolicyHandler(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
|
||||
configData, err := json.Marshal(bucketPolicy)
|
||||
configData, err := canonicalBucketPolicy(bucketPolicy)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
updatedAt, err := globalBucketMetadataSys.Update(ctx, bucket, bucketPolicyConfig, configData)
|
||||
result, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketPolicyConfig, configData, false, false, nil)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
@@ -116,8 +115,8 @@ func (api objectAPIHandlers) PutBucketPolicyHandler(w http.ResponseWriter, r *ht
|
||||
replLogIf(ctx, globalSiteReplicationSys.BucketMetaHook(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypePolicy,
|
||||
Bucket: bucket,
|
||||
Policy: bucketPolicyBytes,
|
||||
UpdatedAt: updatedAt,
|
||||
Policy: result.meta.PolicyConfigJSON,
|
||||
UpdatedAt: result.updatedAt,
|
||||
}))
|
||||
|
||||
// Success.
|
||||
@@ -200,7 +199,7 @@ func (api objectAPIHandlers) GetBucketPolicyHandler(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
|
||||
configData, err := json.Marshal(config)
|
||||
configData, err := canonicalBucketPolicy(config)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
|
||||
@@ -97,7 +97,7 @@ func (api objectAPIHandlers) PutBucketVersioningHandler(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
|
||||
updatedAt, err := globalBucketMetadataSys.Update(ctx, bucket, bucketVersioningConfig, configData)
|
||||
result, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketVersioningConfig, configData, false, false, nil)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
@@ -107,12 +107,12 @@ func (api objectAPIHandlers) PutBucketVersioningHandler(w http.ResponseWriter, r
|
||||
//
|
||||
// We encode the xml bytes as base64 to ensure there are no encoding
|
||||
// errors.
|
||||
cfgStr := base64.StdEncoding.EncodeToString(configData)
|
||||
cfgStr := base64.StdEncoding.EncodeToString(result.meta.VersioningConfigXML)
|
||||
replLogIf(ctx, globalSiteReplicationSys.BucketMetaHook(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypeVersionConfig,
|
||||
Bucket: bucket,
|
||||
Versioning: &cfgStr,
|
||||
UpdatedAt: updatedAt,
|
||||
UpdatedAt: result.updatedAt,
|
||||
}))
|
||||
|
||||
writeSuccessResponseHeadersOnly(w)
|
||||
|
||||
@@ -900,6 +900,7 @@ func serverHandleEnvVars() {
|
||||
}
|
||||
|
||||
globalEnableSyncBoot = env.Get("MINIO_SYNC_BOOT", config.EnableOff) == config.EnableOn
|
||||
globalSiteReplicationMetadataTombstones = env.Get("MINIO_SITE_REPLICATION_METADATA_TOMBSTONES", config.EnableOff) == config.EnableOn
|
||||
}
|
||||
|
||||
func loadRootCredentials() auth.Credentials {
|
||||
|
||||
@@ -227,7 +227,7 @@ func initHelp() {
|
||||
},
|
||||
config.HelpKV{
|
||||
Key: config.ILMSubSys,
|
||||
Description: "manage ILM settings for expiration, transition, and access-tier workers",
|
||||
Description: "manage ILM settings for expiration and transition workers",
|
||||
Optional: true,
|
||||
},
|
||||
}
|
||||
@@ -704,9 +704,6 @@ func applyDynamicConfigForSubSys(ctx context.Context, objAPI ObjectLayer, s conf
|
||||
if globalExpiryState != nil {
|
||||
globalExpiryState.ResizeWorkers(ilmCfg.ExpirationWorkers)
|
||||
}
|
||||
if globalAccessTierState != nil {
|
||||
globalAccessTierState.UpdateWorkers(ilmCfg.AccessWorkers)
|
||||
}
|
||||
globalILMConfig.update(ilmCfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,7 +899,6 @@ type scannerItem struct {
|
||||
objectName string // Only the object name without prefixes.
|
||||
replication replicationConfig
|
||||
lifeCycle *lifecycle.Lifecycle
|
||||
poolIdx int
|
||||
Typ fs.FileMode
|
||||
heal struct {
|
||||
enabled bool
|
||||
@@ -910,7 +909,6 @@ type scannerItem struct {
|
||||
|
||||
type sizeSummary struct {
|
||||
totalSize int64
|
||||
hotTierSize int64
|
||||
versions uint64
|
||||
deleteMarkers uint64
|
||||
replicatedSize int64
|
||||
@@ -1161,14 +1159,6 @@ eventLoop:
|
||||
globalExpiryState.enqueueNoncurrentVersions(i.bucket, toDel, noncurrentEvents)
|
||||
}
|
||||
i.alertExcessiveVersions(remainingVersions, cumulativeSize)
|
||||
if globalILMConfig.accessTieringEnabled() {
|
||||
for idx, oi := range objInfos {
|
||||
if oi.IsLatest && events[idx].Action == lifecycle.NoneAction {
|
||||
applyAccessTransition(ctx, i, oi)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func evalActionFromLifecycle(ctx context.Context, lc lifecycle.Lifecycle, lr lock.Retention, rcfg *replication.Config, obj ObjectInfo) lifecycle.Event {
|
||||
@@ -1484,8 +1474,6 @@ const (
|
||||
ILMFreeVersionDelete = "ilm:free-version-delete"
|
||||
// ILMTransition - audit trail for ILM transitioning.
|
||||
ILMTransition = " ilm:transition"
|
||||
// ILMAccessTier - audit trail for moving objects between server pools.
|
||||
ILMAccessTier = "ilm:access-tier"
|
||||
)
|
||||
|
||||
func auditLogLifecycle(ctx context.Context, oi ObjectInfo, event string, tags map[string]string, traceFn func(event string, metadata map[string]string, err error)) {
|
||||
@@ -1497,8 +1485,6 @@ func auditLogLifecycle(ctx context.Context, oi ObjectInfo, event string, tags ma
|
||||
apiName = "ILMFreeVersionDelete"
|
||||
case ILMTransition:
|
||||
apiName = "ILMTransition"
|
||||
case ILMAccessTier:
|
||||
apiName = "ILMAccessTier"
|
||||
}
|
||||
auditLogInternal(ctx, AuditLogOptions{
|
||||
Event: event,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Read a real pre-removal scanner cache with nonzero hot-tier bytes. A v8
|
||||
// payload with a relabeled header would not exercise the ignored hts field.
|
||||
func TestDataUsageCacheReadV9(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/data-usage-v9/data-usage-v9.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(raw) == 0 || raw[0] != 9 {
|
||||
t.Fatal("fixture is not v9")
|
||||
}
|
||||
expected, err := os.ReadFile("testdata/data-usage-v9/data-usage-v9.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want, got dataUsageCache
|
||||
if err := json.Unmarshal(expected, &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := got.deserialize(bytes.NewReader(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Info.LastUpdate.Equal(want.Info.LastUpdate) {
|
||||
t.Fatal("cache timestamp changed")
|
||||
}
|
||||
// msgp restores local time while JSON preserves the UTC representation.
|
||||
want.Info.LastUpdate = got.Info.LastUpdate
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("v9 ordinary cache fields changed:\ngot: %#v\nwant: %#v", got, want)
|
||||
}
|
||||
flat := got.flatten(*got.root())
|
||||
if flat.Size != 74962 || flat.Objects != 3 || flat.Versions != 5 || flat.DeleteMarkers != 2 {
|
||||
t.Fatalf("statistics changed: %+v", flat)
|
||||
}
|
||||
if flat.AllTierStats == nil || flat.AllTierStats.Tiers["COLD"] != (tierStats{TotalSize: 65536, NumVersions: 1, NumObjects: 1}) {
|
||||
t.Fatalf("remote tier statistics changed: %+v", flat.AllTierStats)
|
||||
}
|
||||
buckets := []BucketInfo{{Name: "v9-bucket"}}
|
||||
if gotInfo, wantInfo := got.dui(dataUsageRoot, buckets), want.dui(dataUsageRoot, buckets); !reflect.DeepEqual(gotInfo, wantInfo) {
|
||||
t.Fatalf("bucket usage aggregation changed: %+v != %+v", gotInfo, wantInfo)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := got.serializeTo(&buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.Bytes()[0] != 8 {
|
||||
t.Fatalf("wrote cache version %d, want 8", buf.Bytes()[0])
|
||||
}
|
||||
var roundtrip dataUsageCache
|
||||
if err := roundtrip.deserialize(&buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, roundtrip) {
|
||||
t.Fatal("v8 round trip lost ordinary fields")
|
||||
}
|
||||
if err := roundtrip.deserialize(bytes.NewReader(raw[:len(raw)/2])); err == nil {
|
||||
t.Fatal("truncated v9 cache accepted")
|
||||
}
|
||||
raw[0] = 10
|
||||
if err := roundtrip.deserialize(bytes.NewReader(raw)); err == nil {
|
||||
t.Fatal("unknown cache version accepted")
|
||||
}
|
||||
}
|
||||
+7
-61
@@ -61,7 +61,6 @@ type dataUsageEntry struct {
|
||||
Children dataUsageHashMap `msg:"ch"`
|
||||
// These fields do no include any children.
|
||||
Size int64 `msg:"sz"`
|
||||
HotTierSize int64 `msg:"hts"`
|
||||
Objects uint64 `msg:"os"`
|
||||
Versions uint64 `msg:"vs"` // Versions that are not delete markers.
|
||||
DeleteMarkers uint64 `msg:"dms"`
|
||||
@@ -135,8 +134,8 @@ func (ts tierStats) add(u tierStats) tierStats {
|
||||
}
|
||||
}
|
||||
|
||||
//msgp:encode ignore dataUsageEntryV2 dataUsageEntryV3 dataUsageEntryV4 dataUsageEntryV5 dataUsageEntryV6 dataUsageEntryV7 dataUsageEntryV8
|
||||
//msgp:marshal ignore dataUsageEntryV2 dataUsageEntryV3 dataUsageEntryV4 dataUsageEntryV5 dataUsageEntryV6 dataUsageEntryV7 dataUsageEntryV8
|
||||
//msgp:encode ignore dataUsageEntryV2 dataUsageEntryV3 dataUsageEntryV4 dataUsageEntryV5 dataUsageEntryV6 dataUsageEntryV7
|
||||
//msgp:marshal ignore dataUsageEntryV2 dataUsageEntryV3 dataUsageEntryV4 dataUsageEntryV5 dataUsageEntryV6 dataUsageEntryV7
|
||||
|
||||
//msgp:tuple dataUsageEntryV2
|
||||
type dataUsageEntryV2 struct {
|
||||
@@ -200,30 +199,14 @@ type dataUsageEntryV7 struct {
|
||||
Compacted bool `msg:"c"`
|
||||
}
|
||||
|
||||
// dataUsageEntryV8 is the on-disk shape before access-tier accounting was
|
||||
// introduced. Keep it so caches written by the previous release decode
|
||||
// without being discarded.
|
||||
type dataUsageEntryV8 struct {
|
||||
Children dataUsageHashMap `msg:"ch"`
|
||||
// These fields do no include any children.
|
||||
Size int64 `msg:"sz"`
|
||||
Objects uint64 `msg:"os"`
|
||||
Versions uint64 `msg:"vs"`
|
||||
DeleteMarkers uint64 `msg:"dms"`
|
||||
ObjSizes sizeHistogram `msg:"szs"`
|
||||
ObjVersions versionsHistogram `msg:"vh"`
|
||||
AllTierStats *allTierStats `msg:"ats,omitempty"`
|
||||
Compacted bool `msg:"c"`
|
||||
}
|
||||
|
||||
// dataUsageCache contains a cache of data usage entries latest version.
|
||||
type dataUsageCache struct {
|
||||
Info dataUsageCacheInfo
|
||||
Cache map[string]dataUsageEntry
|
||||
}
|
||||
|
||||
//msgp:encode ignore dataUsageCacheV2 dataUsageCacheV3 dataUsageCacheV4 dataUsageCacheV5 dataUsageCacheV6 dataUsageCacheV7 dataUsageCacheV8
|
||||
//msgp:marshal ignore dataUsageCacheV2 dataUsageCacheV3 dataUsageCacheV4 dataUsageCacheV5 dataUsageCacheV6 dataUsageCacheV7 dataUsageCacheV8
|
||||
//msgp:encode ignore dataUsageCacheV2 dataUsageCacheV3 dataUsageCacheV4 dataUsageCacheV5 dataUsageCacheV6 dataUsageCacheV7
|
||||
//msgp:marshal ignore dataUsageCacheV2 dataUsageCacheV3 dataUsageCacheV4 dataUsageCacheV5 dataUsageCacheV6 dataUsageCacheV7
|
||||
|
||||
// dataUsageCacheV2 contains a cache of data usage entries version 2.
|
||||
type dataUsageCacheV2 struct {
|
||||
@@ -261,12 +244,6 @@ type dataUsageCacheV7 struct {
|
||||
Cache map[string]dataUsageEntryV7
|
||||
}
|
||||
|
||||
// dataUsageCacheV8 contains a cache of data usage entries version 8.
|
||||
type dataUsageCacheV8 struct {
|
||||
Info dataUsageCacheInfo
|
||||
Cache map[string]dataUsageEntryV8
|
||||
}
|
||||
|
||||
//msgp:ignore dataUsageEntryInfo
|
||||
type dataUsageEntryInfo struct {
|
||||
Name string
|
||||
@@ -295,7 +272,6 @@ type dataUsageCacheInfo struct {
|
||||
|
||||
func (e *dataUsageEntry) addSizes(summary sizeSummary) {
|
||||
e.Size += summary.totalSize
|
||||
e.HotTierSize += summary.hotTierSize
|
||||
e.Versions += summary.versions
|
||||
e.DeleteMarkers += summary.deleteMarkers
|
||||
e.ObjSizes.add(summary.totalSize)
|
||||
@@ -315,7 +291,6 @@ func (e *dataUsageEntry) merge(other dataUsageEntry) {
|
||||
e.Versions += other.Versions
|
||||
e.DeleteMarkers += other.DeleteMarkers
|
||||
e.Size += other.Size
|
||||
e.HotTierSize += other.HotTierSize
|
||||
|
||||
for i, v := range other.ObjSizes[:] {
|
||||
e.ObjSizes[i] += v
|
||||
@@ -456,7 +431,6 @@ func (d *dataUsageCache) dui(path string, buckets []BucketInfo) DataUsageInfo {
|
||||
flat := d.flatten(*e)
|
||||
dui := DataUsageInfo{
|
||||
LastUpdate: d.Info.LastUpdate,
|
||||
ScannerCycle: d.Info.NextCycle,
|
||||
ObjectsTotalCount: flat.Objects,
|
||||
VersionsTotalCount: flat.Versions,
|
||||
DeleteMarkersTotalCount: flat.DeleteMarkers,
|
||||
@@ -807,7 +781,6 @@ func (d *dataUsageCache) bucketsUsageInfo(buckets []BucketInfo) map[string]Bucke
|
||||
flat := d.flatten(*e)
|
||||
bui := BucketUsageInfo{
|
||||
Size: uint64(flat.Size),
|
||||
HotTierSize: uint64(max(flat.HotTierSize, 0)),
|
||||
VersionsCount: flat.Versions,
|
||||
ObjectsCount: flat.Objects,
|
||||
DeleteMarkersCount: flat.DeleteMarkers,
|
||||
@@ -1007,8 +980,8 @@ func (d *dataUsageCache) save(ctx context.Context, store objectIO, name string)
|
||||
// Bumping the cache version will drop data from previous versions
|
||||
// and write new data with the new version.
|
||||
const (
|
||||
dataUsageCacheVerCurrent = 9
|
||||
dataUsageCacheVerV8 = 8
|
||||
dataUsageCacheVerCurrent = 8
|
||||
dataUsageCacheVerV9 = 9 // Retired access-tier cache; only adds the ignored "hts" entry key.
|
||||
dataUsageCacheVerV7 = 7
|
||||
dataUsageCacheVerV6 = 6
|
||||
dataUsageCacheVerV5 = 5
|
||||
@@ -1210,34 +1183,7 @@ func (d *dataUsageCache) deserialize(r io.Reader) error {
|
||||
}
|
||||
|
||||
return nil
|
||||
case dataUsageCacheVerV8:
|
||||
// Zstd compressed.
|
||||
dec, err := zstd.NewReader(r, zstd.WithDecoderConcurrency(2))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dec.Close()
|
||||
dold := &dataUsageCacheV8{}
|
||||
if err = dold.DecodeMsg(msgp.NewReader(dec)); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Info = dold.Info
|
||||
d.Cache = make(map[string]dataUsageEntry, len(dold.Cache))
|
||||
for k, v := range dold.Cache {
|
||||
d.Cache[k] = dataUsageEntry{
|
||||
Children: v.Children,
|
||||
Size: v.Size,
|
||||
Objects: v.Objects,
|
||||
Versions: v.Versions,
|
||||
DeleteMarkers: v.DeleteMarkers,
|
||||
ObjSizes: v.ObjSizes,
|
||||
ObjVersions: v.ObjVersions,
|
||||
AllTierStats: v.AllTierStats,
|
||||
Compacted: v.Compacted,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case dataUsageCacheVerCurrent:
|
||||
case dataUsageCacheVerCurrent, dataUsageCacheVerV9:
|
||||
// Zstd compressed.
|
||||
dec, err := zstd.NewReader(r, zstd.WithDecoderConcurrency(2))
|
||||
if err != nil {
|
||||
|
||||
+9
-605
@@ -1591,145 +1591,6 @@ func (z *dataUsageCacheV7) Msgsize() (s int) {
|
||||
return
|
||||
}
|
||||
|
||||
// DecodeMsg implements msgp.Decodable
|
||||
func (z *dataUsageCacheV8) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, err = dc.ReadMapKeyPtr()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "Info":
|
||||
err = z.Info.DecodeMsg(dc)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Info")
|
||||
return
|
||||
}
|
||||
case "Cache":
|
||||
var zb0002 uint32
|
||||
zb0002, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Cache")
|
||||
return
|
||||
}
|
||||
if z.Cache == nil {
|
||||
z.Cache = make(map[string]dataUsageEntryV8, zb0002)
|
||||
} else if len(z.Cache) > 0 {
|
||||
clear(z.Cache)
|
||||
}
|
||||
for zb0002 > 0 {
|
||||
zb0002--
|
||||
var za0001 string
|
||||
za0001, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Cache")
|
||||
return
|
||||
}
|
||||
var za0002 dataUsageEntryV8
|
||||
err = za0002.DecodeMsg(dc)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Cache", za0001)
|
||||
return
|
||||
}
|
||||
z.Cache[za0001] = za0002
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// UnmarshalMsg implements msgp.Unmarshaler
|
||||
func (z *dataUsageCacheV8) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, bts, err = msgp.ReadMapKeyZC(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "Info":
|
||||
bts, err = z.Info.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Info")
|
||||
return
|
||||
}
|
||||
case "Cache":
|
||||
var zb0002 uint32
|
||||
zb0002, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Cache")
|
||||
return
|
||||
}
|
||||
if z.Cache == nil {
|
||||
z.Cache = make(map[string]dataUsageEntryV8, zb0002)
|
||||
} else if len(z.Cache) > 0 {
|
||||
clear(z.Cache)
|
||||
}
|
||||
for zb0002 > 0 {
|
||||
var za0002 dataUsageEntryV8
|
||||
zb0002--
|
||||
var za0001 string
|
||||
za0001, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Cache")
|
||||
return
|
||||
}
|
||||
bts, err = za0002.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Cache", za0001)
|
||||
return
|
||||
}
|
||||
z.Cache[za0001] = za0002
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
o = bts
|
||||
return
|
||||
}
|
||||
|
||||
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
|
||||
func (z *dataUsageCacheV8) Msgsize() (s int) {
|
||||
s = 1 + 5 + z.Info.Msgsize() + 6 + msgp.MapHeaderSize
|
||||
if z.Cache != nil {
|
||||
for za0001, za0002 := range z.Cache {
|
||||
_ = za0002
|
||||
s += msgp.StringPrefixSize + len(za0001) + za0002.Msgsize()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DecodeMsg implements msgp.Decodable
|
||||
func (z *dataUsageEntry) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
var field []byte
|
||||
@@ -1762,12 +1623,6 @@ func (z *dataUsageEntry) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
err = msgp.WrapError(err, "Size")
|
||||
return
|
||||
}
|
||||
case "hts":
|
||||
z.HotTierSize, err = dc.ReadInt64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "HotTierSize")
|
||||
return
|
||||
}
|
||||
case "os":
|
||||
z.Objects, err = dc.ReadUint64()
|
||||
if err != nil {
|
||||
@@ -1946,12 +1801,12 @@ func (z *dataUsageEntry) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
// EncodeMsg implements msgp.Encodable
|
||||
func (z *dataUsageEntry) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
// check for omitted fields
|
||||
zb0001Len := uint32(10)
|
||||
var zb0001Mask uint16 /* 10 bits */
|
||||
zb0001Len := uint32(9)
|
||||
var zb0001Mask uint16 /* 9 bits */
|
||||
_ = zb0001Mask
|
||||
if z.AllTierStats == nil {
|
||||
zb0001Len--
|
||||
zb0001Mask |= 0x100
|
||||
zb0001Mask |= 0x80
|
||||
}
|
||||
// variable map header, size zb0001Len
|
||||
err = en.Append(0x80 | uint8(zb0001Len))
|
||||
@@ -1981,16 +1836,6 @@ func (z *dataUsageEntry) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
err = msgp.WrapError(err, "Size")
|
||||
return
|
||||
}
|
||||
// write "hts"
|
||||
err = en.Append(0xa3, 0x68, 0x74, 0x73)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt64(z.HotTierSize)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "HotTierSize")
|
||||
return
|
||||
}
|
||||
// write "os"
|
||||
err = en.Append(0xa2, 0x6f, 0x73)
|
||||
if err != nil {
|
||||
@@ -2055,7 +1900,7 @@ func (z *dataUsageEntry) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (zb0001Mask & 0x100) == 0 { // if not omitted
|
||||
if (zb0001Mask & 0x80) == 0 { // if not omitted
|
||||
// write "ats"
|
||||
err = en.Append(0xa3, 0x61, 0x74, 0x73)
|
||||
if err != nil {
|
||||
@@ -2136,12 +1981,12 @@ func (z *dataUsageEntry) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
func (z *dataUsageEntry) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// check for omitted fields
|
||||
zb0001Len := uint32(10)
|
||||
var zb0001Mask uint16 /* 10 bits */
|
||||
zb0001Len := uint32(9)
|
||||
var zb0001Mask uint16 /* 9 bits */
|
||||
_ = zb0001Mask
|
||||
if z.AllTierStats == nil {
|
||||
zb0001Len--
|
||||
zb0001Mask |= 0x100
|
||||
zb0001Mask |= 0x80
|
||||
}
|
||||
// variable map header, size zb0001Len
|
||||
o = append(o, 0x80|uint8(zb0001Len))
|
||||
@@ -2158,9 +2003,6 @@ func (z *dataUsageEntry) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
// string "sz"
|
||||
o = append(o, 0xa2, 0x73, 0x7a)
|
||||
o = msgp.AppendInt64(o, z.Size)
|
||||
// string "hts"
|
||||
o = append(o, 0xa3, 0x68, 0x74, 0x73)
|
||||
o = msgp.AppendInt64(o, z.HotTierSize)
|
||||
// string "os"
|
||||
o = append(o, 0xa2, 0x6f, 0x73)
|
||||
o = msgp.AppendUint64(o, z.Objects)
|
||||
@@ -2182,7 +2024,7 @@ func (z *dataUsageEntry) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
for za0002 := range z.ObjVersions {
|
||||
o = msgp.AppendUint64(o, z.ObjVersions[za0002])
|
||||
}
|
||||
if (zb0001Mask & 0x100) == 0 { // if not omitted
|
||||
if (zb0001Mask & 0x80) == 0 { // if not omitted
|
||||
// string "ats"
|
||||
o = append(o, 0xa3, 0x61, 0x74, 0x73)
|
||||
if z.AllTierStats == nil {
|
||||
@@ -2246,12 +2088,6 @@ func (z *dataUsageEntry) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
err = msgp.WrapError(err, "Size")
|
||||
return
|
||||
}
|
||||
case "hts":
|
||||
z.HotTierSize, bts, err = msgp.ReadInt64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "HotTierSize")
|
||||
return
|
||||
}
|
||||
case "os":
|
||||
z.Objects, bts, err = msgp.ReadUint64Bytes(bts)
|
||||
if err != nil {
|
||||
@@ -2429,7 +2265,7 @@ func (z *dataUsageEntry) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
|
||||
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
|
||||
func (z *dataUsageEntry) Msgsize() (s int) {
|
||||
s = 1 + 3 + z.Children.Msgsize() + 3 + msgp.Int64Size + 4 + msgp.Int64Size + 3 + msgp.Uint64Size + 3 + msgp.Uint64Size + 4 + msgp.Uint64Size + 4 + msgp.ArrayHeaderSize + (dataUsageBucketLen * (msgp.Uint64Size)) + 3 + msgp.ArrayHeaderSize + (dataUsageVersionLen * (msgp.Uint64Size)) + 4
|
||||
s = 1 + 3 + z.Children.Msgsize() + 3 + msgp.Int64Size + 3 + msgp.Uint64Size + 3 + msgp.Uint64Size + 4 + msgp.Uint64Size + 4 + msgp.ArrayHeaderSize + (dataUsageBucketLen * (msgp.Uint64Size)) + 3 + msgp.ArrayHeaderSize + (dataUsageVersionLen * (msgp.Uint64Size)) + 4
|
||||
if z.AllTierStats == nil {
|
||||
s += msgp.NilSize
|
||||
} else {
|
||||
@@ -3422,438 +3258,6 @@ func (z *dataUsageEntryV7) Msgsize() (s int) {
|
||||
return
|
||||
}
|
||||
|
||||
// DecodeMsg implements msgp.Decodable
|
||||
func (z *dataUsageEntryV8) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
var zb0001Mask uint8 /* 1 bits */
|
||||
_ = zb0001Mask
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, err = dc.ReadMapKeyPtr()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "ch":
|
||||
err = z.Children.DecodeMsg(dc)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Children")
|
||||
return
|
||||
}
|
||||
case "sz":
|
||||
z.Size, err = dc.ReadInt64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Size")
|
||||
return
|
||||
}
|
||||
case "os":
|
||||
z.Objects, err = dc.ReadUint64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Objects")
|
||||
return
|
||||
}
|
||||
case "vs":
|
||||
z.Versions, err = dc.ReadUint64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Versions")
|
||||
return
|
||||
}
|
||||
case "dms":
|
||||
z.DeleteMarkers, err = dc.ReadUint64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "DeleteMarkers")
|
||||
return
|
||||
}
|
||||
case "szs":
|
||||
var zb0002 uint32
|
||||
zb0002, err = dc.ReadArrayHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ObjSizes")
|
||||
return
|
||||
}
|
||||
if zb0002 != uint32(dataUsageBucketLen) {
|
||||
err = msgp.ArrayError{Wanted: uint32(dataUsageBucketLen), Got: zb0002}
|
||||
return
|
||||
}
|
||||
for za0001 := range z.ObjSizes {
|
||||
z.ObjSizes[za0001], err = dc.ReadUint64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ObjSizes", za0001)
|
||||
return
|
||||
}
|
||||
}
|
||||
case "vh":
|
||||
var zb0003 uint32
|
||||
zb0003, err = dc.ReadArrayHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ObjVersions")
|
||||
return
|
||||
}
|
||||
if zb0003 != uint32(dataUsageVersionLen) {
|
||||
err = msgp.ArrayError{Wanted: uint32(dataUsageVersionLen), Got: zb0003}
|
||||
return
|
||||
}
|
||||
for za0002 := range z.ObjVersions {
|
||||
z.ObjVersions[za0002], err = dc.ReadUint64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ObjVersions", za0002)
|
||||
return
|
||||
}
|
||||
}
|
||||
case "ats":
|
||||
if dc.IsNil() {
|
||||
err = dc.ReadNil()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats")
|
||||
return
|
||||
}
|
||||
z.AllTierStats = nil
|
||||
} else {
|
||||
if z.AllTierStats == nil {
|
||||
z.AllTierStats = new(allTierStats)
|
||||
}
|
||||
var zb0004 uint32
|
||||
zb0004, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats")
|
||||
return
|
||||
}
|
||||
for zb0004 > 0 {
|
||||
zb0004--
|
||||
field, err = dc.ReadMapKeyPtr()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats")
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "ts":
|
||||
var zb0005 uint32
|
||||
zb0005, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers")
|
||||
return
|
||||
}
|
||||
if z.AllTierStats.Tiers == nil {
|
||||
z.AllTierStats.Tiers = make(map[string]tierStats, zb0005)
|
||||
} else if len(z.AllTierStats.Tiers) > 0 {
|
||||
clear(z.AllTierStats.Tiers)
|
||||
}
|
||||
for zb0005 > 0 {
|
||||
zb0005--
|
||||
var za0003 string
|
||||
za0003, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers")
|
||||
return
|
||||
}
|
||||
var za0004 tierStats
|
||||
var zb0006 uint32
|
||||
zb0006, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003)
|
||||
return
|
||||
}
|
||||
for zb0006 > 0 {
|
||||
zb0006--
|
||||
field, err = dc.ReadMapKeyPtr()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "ts":
|
||||
za0004.TotalSize, err = dc.ReadUint64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "TotalSize")
|
||||
return
|
||||
}
|
||||
case "nv":
|
||||
za0004.NumVersions, err = dc.ReadInt()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "NumVersions")
|
||||
return
|
||||
}
|
||||
case "no":
|
||||
za0004.NumObjects, err = dc.ReadInt()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "NumObjects")
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
z.AllTierStats.Tiers[za0003] = za0004
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
zb0001Mask |= 0x1
|
||||
case "c":
|
||||
z.Compacted, err = dc.ReadBool()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Compacted")
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear omitted fields.
|
||||
if (zb0001Mask & 0x1) == 0 {
|
||||
z.AllTierStats = nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// UnmarshalMsg implements msgp.Unmarshaler
|
||||
func (z *dataUsageEntryV8) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
var zb0001Mask uint8 /* 1 bits */
|
||||
_ = zb0001Mask
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, bts, err = msgp.ReadMapKeyZC(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "ch":
|
||||
bts, err = z.Children.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Children")
|
||||
return
|
||||
}
|
||||
case "sz":
|
||||
z.Size, bts, err = msgp.ReadInt64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Size")
|
||||
return
|
||||
}
|
||||
case "os":
|
||||
z.Objects, bts, err = msgp.ReadUint64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Objects")
|
||||
return
|
||||
}
|
||||
case "vs":
|
||||
z.Versions, bts, err = msgp.ReadUint64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Versions")
|
||||
return
|
||||
}
|
||||
case "dms":
|
||||
z.DeleteMarkers, bts, err = msgp.ReadUint64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "DeleteMarkers")
|
||||
return
|
||||
}
|
||||
case "szs":
|
||||
var zb0002 uint32
|
||||
zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ObjSizes")
|
||||
return
|
||||
}
|
||||
if zb0002 != uint32(dataUsageBucketLen) {
|
||||
err = msgp.ArrayError{Wanted: uint32(dataUsageBucketLen), Got: zb0002}
|
||||
return
|
||||
}
|
||||
for za0001 := range z.ObjSizes {
|
||||
z.ObjSizes[za0001], bts, err = msgp.ReadUint64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ObjSizes", za0001)
|
||||
return
|
||||
}
|
||||
}
|
||||
case "vh":
|
||||
var zb0003 uint32
|
||||
zb0003, bts, err = msgp.ReadArrayHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ObjVersions")
|
||||
return
|
||||
}
|
||||
if zb0003 != uint32(dataUsageVersionLen) {
|
||||
err = msgp.ArrayError{Wanted: uint32(dataUsageVersionLen), Got: zb0003}
|
||||
return
|
||||
}
|
||||
for za0002 := range z.ObjVersions {
|
||||
z.ObjVersions[za0002], bts, err = msgp.ReadUint64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ObjVersions", za0002)
|
||||
return
|
||||
}
|
||||
}
|
||||
case "ats":
|
||||
if msgp.IsNil(bts) {
|
||||
bts, err = msgp.ReadNilBytes(bts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
z.AllTierStats = nil
|
||||
} else {
|
||||
if z.AllTierStats == nil {
|
||||
z.AllTierStats = new(allTierStats)
|
||||
}
|
||||
var zb0004 uint32
|
||||
zb0004, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats")
|
||||
return
|
||||
}
|
||||
for zb0004 > 0 {
|
||||
zb0004--
|
||||
field, bts, err = msgp.ReadMapKeyZC(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats")
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "ts":
|
||||
var zb0005 uint32
|
||||
zb0005, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers")
|
||||
return
|
||||
}
|
||||
if z.AllTierStats.Tiers == nil {
|
||||
z.AllTierStats.Tiers = make(map[string]tierStats, zb0005)
|
||||
} else if len(z.AllTierStats.Tiers) > 0 {
|
||||
clear(z.AllTierStats.Tiers)
|
||||
}
|
||||
for zb0005 > 0 {
|
||||
var za0004 tierStats
|
||||
zb0005--
|
||||
var za0003 string
|
||||
za0003, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers")
|
||||
return
|
||||
}
|
||||
var zb0006 uint32
|
||||
zb0006, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003)
|
||||
return
|
||||
}
|
||||
for zb0006 > 0 {
|
||||
zb0006--
|
||||
field, bts, err = msgp.ReadMapKeyZC(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "ts":
|
||||
za0004.TotalSize, bts, err = msgp.ReadUint64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "TotalSize")
|
||||
return
|
||||
}
|
||||
case "nv":
|
||||
za0004.NumVersions, bts, err = msgp.ReadIntBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "NumVersions")
|
||||
return
|
||||
}
|
||||
case "no":
|
||||
za0004.NumObjects, bts, err = msgp.ReadIntBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003, "NumObjects")
|
||||
return
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats", "Tiers", za0003)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
z.AllTierStats.Tiers[za0003] = za0004
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AllTierStats")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
zb0001Mask |= 0x1
|
||||
case "c":
|
||||
z.Compacted, bts, err = msgp.ReadBoolBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Compacted")
|
||||
return
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear omitted fields.
|
||||
if (zb0001Mask & 0x1) == 0 {
|
||||
z.AllTierStats = nil
|
||||
}
|
||||
|
||||
o = bts
|
||||
return
|
||||
}
|
||||
|
||||
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
|
||||
func (z *dataUsageEntryV8) Msgsize() (s int) {
|
||||
s = 1 + 3 + z.Children.Msgsize() + 3 + msgp.Int64Size + 3 + msgp.Uint64Size + 3 + msgp.Uint64Size + 4 + msgp.Uint64Size + 4 + msgp.ArrayHeaderSize + (dataUsageBucketLen * (msgp.Uint64Size)) + 3 + msgp.ArrayHeaderSize + (dataUsageVersionLen * (msgp.Uint64Size)) + 4
|
||||
if z.AllTierStats == nil {
|
||||
s += msgp.NilSize
|
||||
} else {
|
||||
s += 1 + 3 + msgp.MapHeaderSize
|
||||
if z.AllTierStats.Tiers != nil {
|
||||
for za0003, za0004 := range z.AllTierStats.Tiers {
|
||||
_ = za0004
|
||||
s += msgp.StringPrefixSize + len(za0003) + 1 + 3 + msgp.Uint64Size + 3 + msgp.IntSize + 3 + msgp.IntSize
|
||||
}
|
||||
}
|
||||
}
|
||||
s += 2 + msgp.BoolSize
|
||||
return
|
||||
}
|
||||
|
||||
// DecodeMsg implements msgp.Decodable
|
||||
func (z *dataUsageHash) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
{
|
||||
|
||||
@@ -46,8 +46,7 @@ type BucketTargetUsageInfo struct {
|
||||
// - total objects in a bucket
|
||||
// - object size histogram per bucket
|
||||
type BucketUsageInfo struct {
|
||||
Size uint64 `json:"size"`
|
||||
HotTierSize uint64 `json:"hotTierSize,omitempty"`
|
||||
Size uint64 `json:"size"`
|
||||
// Following five fields suffixed with V1 are here for backward compatibility
|
||||
// Total Size for objects that have not yet been replicated
|
||||
ReplicationPendingSizeV1 uint64 `json:"objectsPendingReplicationTotalSize"`
|
||||
@@ -79,10 +78,6 @@ type DataUsageInfo struct {
|
||||
// LastUpdate is the timestamp of when the data usage info was last updated.
|
||||
// This does not indicate a full scan.
|
||||
LastUpdate time.Time `json:"lastUpdate"`
|
||||
// ScannerCycle changes only after a complete scanner pass. Background
|
||||
// consumers use it to distinguish a partial cache update from a baseline
|
||||
// that has visited every bucket and server pool.
|
||||
ScannerCycle uint32 `json:"scannerCycle,omitempty"`
|
||||
|
||||
// Objects total count across all buckets
|
||||
ObjectsTotalCount uint64 `json:"objectsCount"`
|
||||
|
||||
@@ -287,33 +287,51 @@ func (z *erasureServerPools) retireReplicaCopies(ctx context.Context, bucket, ob
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteObjectConditional evaluates the condition once against the logical
|
||||
// deleteObjectReconciled evaluates any condition once against the logical
|
||||
// version, then removes all its copies under the same lock as pooled writers.
|
||||
func (z *erasureServerPools) deleteObjectConditional(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
func (z *erasureServerPools) deleteObjectReconciled(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, opts)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
primary := copies[0]
|
||||
if opts.CheckPrecondFn(primary.ObjInfo) {
|
||||
if opts.CheckPrecondFn != nil && opts.CheckPrecondFn(primary.ObjInfo) {
|
||||
return ObjectInfo{}, PreConditionFailed{}
|
||||
}
|
||||
opts.CheckPrecondFn = nil
|
||||
opts.NoLock = true
|
||||
if opts.EvalRetentionBypassFn != nil || opts.EvalMetadataFn != nil {
|
||||
versions, err := z.metadataPoolInfos(ctx, bucket, object, opts)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
logical := primary.ObjInfo
|
||||
var gerr error
|
||||
switch {
|
||||
case logical.DeleteMarker:
|
||||
// Markers can be deleted by version ID. Match the set layer's
|
||||
// callback inputs instead of rejecting them as metadata updates.
|
||||
gerr = toObjectErr(errMethodNotAllowed, bucket, object)
|
||||
if opts.VersionID == "" || opts.DeleteMarker {
|
||||
gerr = toObjectErr(errFileNotFound, bucket, object)
|
||||
}
|
||||
case opts.VersionID != "":
|
||||
// An addressed version already resolved every copy above.
|
||||
logical = mergedPoolObjectInfo(copies)
|
||||
default:
|
||||
versions, err := z.metadataPoolInfos(ctx, bucket, object, opts)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
logical = mergedPoolObjectInfo(versions)
|
||||
}
|
||||
logical := mergedPoolObjectInfo(versions)
|
||||
// Keep the retention gate first. These callbacks independently evaluate
|
||||
// the logical version and run once before any deletion; the handler only
|
||||
// sweeps metadata's transition state after a successful delete.
|
||||
if opts.EvalRetentionBypassFn != nil {
|
||||
if err := opts.EvalRetentionBypassFn(logical, nil); err != nil {
|
||||
if err := opts.EvalRetentionBypassFn(logical, gerr); err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
opts.EvalRetentionBypassFn = nil
|
||||
}
|
||||
if opts.EvalMetadataFn != nil {
|
||||
decision, err := opts.EvalMetadataFn(&logical, nil)
|
||||
decision, err := opts.EvalMetadataFn(&logical, gerr)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
|
||||
@@ -24,12 +24,16 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
madmin "github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
@@ -62,6 +66,490 @@ func putConsistencyObject(t *testing.T, z *erasureServerPools, bucket, object st
|
||||
return oi
|
||||
}
|
||||
|
||||
func consistencyRequest(t *testing.T, router http.Handler, method, bucket, object, version string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
url := getGetObjectURL("", bucket, object)
|
||||
if version != "" {
|
||||
url += "?versionId=" + version
|
||||
}
|
||||
req, err := newTestSignedRequestV4(method, url, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Exercise the handler's real replication/retention callbacks, including the
|
||||
// MethodNotAllowed metadata returned for an explicitly addressed delete marker.
|
||||
func TestPoolsDeleteVersionAPI(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{VersioningEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, kind := range []string{"uuid", "null", "marker"} {
|
||||
for primary := range 2 {
|
||||
t.Run(fmt.Sprintf("%s/primary=%d", kind, primary), func(t *testing.T) {
|
||||
object := fmt.Sprintf("%s-%d", kind, primary)
|
||||
opts := ObjectOptions{Versioned: kind != "null", MTime: UTCNow().Add(-time.Hour)}
|
||||
var addressed ObjectInfo
|
||||
if kind == "marker" {
|
||||
opts.VersionID, opts.DeleteMarker = mustGetUUID(), true
|
||||
addressed, err = z.serverPools[primary].DeleteObject(ctx, bucket, object, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
addressed = putConsistencyObject(t, z, bucket, object, primary, "payload", opts)
|
||||
}
|
||||
version := addressed.VersionID
|
||||
if version == "" {
|
||||
version = nullVersionID
|
||||
}
|
||||
opts.VersionID = version
|
||||
opts.MTime = addressed.ModTime.Add(-time.Minute)
|
||||
if kind == "marker" {
|
||||
opts.DeleteMarker = true
|
||||
if _, err := z.serverPools[1-primary].DeleteObject(ctx, bucket, object, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
putConsistencyObject(t, z, bucket, object, 1-primary, "payload", opts)
|
||||
}
|
||||
latest := putConsistencyObject(t, z, bucket, object, 1-primary, "keep-latest", ObjectOptions{Versioned: true})
|
||||
rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, version)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if kind == "marker" && (strings.Join(rec.Header()[xhttp.AmzDeleteMarker], "") != "true" || strings.Join(rec.Header()[xhttp.AmzVersionID], "") != version) {
|
||||
t.Errorf("lost deleted marker response headers: %v", rec.Header())
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
if _, err := pool.GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: version}); !isErrVersionNotFound(err) {
|
||||
t.Errorf("pool %d retained addressed version: %v", i, err)
|
||||
}
|
||||
}
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
if rec := consistencyRequest(t, router, method, bucket, object, version); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s after DELETE: %d %s", method, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if got, err := z.GetObjectInfo(ctx, bucket, object, ObjectOptions{}); err != nil || got.VersionID != latest.VersionID {
|
||||
t.Errorf("DELETE changed another version: %+v, %v", got, err)
|
||||
}
|
||||
if rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, version); rec.Code != http.StatusNoContent {
|
||||
t.Errorf("idempotent retry: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if rec := consistencyRequest(t, router, http.MethodDelete, bucket, "absent-key", mustGetUUID()); rec.Code != http.StatusNoContent {
|
||||
t.Errorf("absent key: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type consistencyReadFaultDisk struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
}
|
||||
|
||||
func (d consistencyReadFaultDisk) ReadVersion(ctx context.Context, origvolume, volume, path, version string, opts ReadOptions) (FileInfo, error) {
|
||||
if volume == d.bucket && path == d.object {
|
||||
return FileInfo{}, errDiskNotFound
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, origvolume, volume, path, version, opts)
|
||||
}
|
||||
|
||||
func (d consistencyReadFaultDisk) ReadXL(ctx context.Context, volume, path string, readData bool) (RawFileInfo, error) {
|
||||
if volume == d.bucket && path == d.object {
|
||||
return RawFileInfo{}, errDiskNotFound
|
||||
}
|
||||
return d.StorageAPI.ReadXL(ctx, volume, path, readData)
|
||||
}
|
||||
|
||||
func TestPoolsDeleteVersionUnreadablePool(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{VersioningEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, holding := range []bool{false, true} {
|
||||
for failed := range 2 {
|
||||
t.Run(fmt.Sprintf("holding=%t/pool=%d", holding, failed), func(t *testing.T) {
|
||||
object := fmt.Sprintf("unreadable-%t-%d", holding, failed)
|
||||
oi := putConsistencyObject(t, z, bucket, object, 1-failed, "payload", ObjectOptions{Versioned: true})
|
||||
if holding {
|
||||
putConsistencyObject(t, z, bucket, object, failed, "payload", ObjectOptions{Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime})
|
||||
}
|
||||
set := z.serverPools[failed].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
faulty := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range faulty {
|
||||
faulty[i] = consistencyReadFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
if rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, oi.VersionID); rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("unreadable pool DELETE: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := z.serverPools[1-failed].GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: oi.VersionID}); err != nil {
|
||||
t.Errorf("DELETE lost the readable copy: %v", err)
|
||||
}
|
||||
set.getDisks = getDisks
|
||||
if rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, oi.VersionID); rec.Code != http.StatusNoContent {
|
||||
t.Errorf("recovered pool retry: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
if _, err := pool.GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: oi.VersionID}); !isErrVersionNotFound(err) {
|
||||
t.Errorf("pool %d retained version after retry: %v", i, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type consistencyDeleteCountDisk struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
metadataReads, deletes *atomic.Int32
|
||||
}
|
||||
|
||||
func (d consistencyDeleteCountDisk) ReadVersion(ctx context.Context, origvolume, volume, path, version string, opts ReadOptions) (FileInfo, error) {
|
||||
if volume == d.bucket && path == d.object {
|
||||
d.metadataReads.Add(1)
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, origvolume, volume, path, version, opts)
|
||||
}
|
||||
|
||||
func (d consistencyDeleteCountDisk) DeleteVersion(ctx context.Context, volume, path string, fi FileInfo, force bool, opts DeleteOptions) error {
|
||||
if volume == d.bucket && path == d.object {
|
||||
d.deletes.Add(1)
|
||||
}
|
||||
return d.StorageAPI.DeleteVersion(ctx, volume, path, fi, force, opts)
|
||||
}
|
||||
|
||||
func TestPoolsDeleteVersionSingleCopy(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
for primary := range 2 {
|
||||
t.Run(fmt.Sprintf("pool=%d", primary), func(t *testing.T) {
|
||||
object := fmt.Sprintf("single-copy-%d", primary)
|
||||
oi := putConsistencyObject(t, z, bucket, object, primary, "payload", ObjectOptions{Versioned: true})
|
||||
var metadataReads, deletes [2]atomic.Int32
|
||||
for i, pool := range z.serverPools {
|
||||
set := pool.getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
disks := append([]StorageAPI(nil), getDisks()...)
|
||||
for j := range disks {
|
||||
disks[j] = consistencyDeleteCountDisk{StorageAPI: disks[j], bucket: bucket, object: object, metadataReads: &metadataReads[i], deletes: &deletes[i]}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return disks }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
}
|
||||
metadata, retention := 0, 0
|
||||
_, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{
|
||||
Versioned: true, VersionID: oi.VersionID,
|
||||
EvalMetadataFn: func(current *ObjectInfo, err error) (ReplicateDecision, error) {
|
||||
metadata++
|
||||
if err != nil || current.VersionID != oi.VersionID {
|
||||
t.Errorf("metadata callback: %+v, %v", current, err)
|
||||
}
|
||||
// Count preflight reads before the physical delete reads its own metadata.
|
||||
for i := range metadataReads {
|
||||
if got := metadataReads[i].Load(); got != 16 {
|
||||
t.Errorf("pool %d metadata reads before callbacks = %d; want once per disk (16)", i, got)
|
||||
}
|
||||
}
|
||||
return ReplicateDecision{}, nil
|
||||
},
|
||||
EvalRetentionBypassFn: func(current ObjectInfo, err error) error {
|
||||
retention++
|
||||
return err
|
||||
},
|
||||
})
|
||||
if err != nil || metadata != 1 || retention != 1 {
|
||||
t.Fatalf("DELETE: %v, metadata=%d retention=%d", err, metadata, retention)
|
||||
}
|
||||
if deletes[primary].Load() != 16 || deletes[1-primary].Load() != 0 {
|
||||
t.Errorf("physical deletes per pool = %d, %d; want once per holding disk only", deletes[0].Load(), deletes[1].Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsDeleteVersionReplicationPurge(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
for _, marker := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("marker=%t", marker), func(t *testing.T) {
|
||||
object := fmt.Sprintf("local-replication-purge-%t", marker)
|
||||
version := mustGetUUID()
|
||||
for _, pool := range z.serverPools {
|
||||
opts := ObjectOptions{Versioned: true, VersionID: version, DeleteMarker: marker}
|
||||
var err error
|
||||
if marker {
|
||||
_, err = pool.DeleteObject(t.Context(), bucket, object, opts)
|
||||
} else {
|
||||
_, err = pool.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, strings.NewReader("payload"), 7, "", ""), opts)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
dsc := ReplicateDecision{}
|
||||
dsc.Set(newReplicateTargetDecision("arn1", true, false))
|
||||
opts := ObjectOptions{Versioned: true, VersionID: version}
|
||||
for attempt := range 2 {
|
||||
calls := 0
|
||||
opts.EvalMetadataFn = func(current *ObjectInfo, gerr error) (ReplicateDecision, error) {
|
||||
calls++
|
||||
wantMethodNotAllowed := marker || attempt > 0
|
||||
if (wantMethodNotAllowed && !isErrMethodNotAllowed(gerr)) || (!wantMethodNotAllowed && gerr != nil) {
|
||||
t.Errorf("pending purge callback lost set-layer read error: %v", gerr)
|
||||
}
|
||||
return dsc, nil
|
||||
}
|
||||
got, err := z.DeleteObject(t.Context(), bucket, object, opts)
|
||||
if err != nil || calls != 1 || got.VersionPurgeStatus != replication.VersionPurgePending || got.replicationDecision != dsc.String() {
|
||||
t.Fatalf("replication scheduling result: %+v, %v, calls=%d", got, err, calls)
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
got, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: version})
|
||||
if (err != nil && (!got.DeleteMarker || !isErrMethodNotAllowed(err))) || got.VersionPurgeStatus != replication.VersionPurgePending {
|
||||
t.Errorf("pool %d lost pending purge: %+v, %v", i, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The local worker completes the purge without ReplicationRequest.
|
||||
// Both pending copies must be removed, including a pending marker purge.
|
||||
opts.EvalMetadataFn = nil
|
||||
opts.DeleteReplication = ReplicationState{
|
||||
VersionPurgeStatusInternal: "arn1=COMPLETED;",
|
||||
PurgeTargets: map[string]VersionPurgeStatusType{"arn1": replication.VersionPurgeComplete},
|
||||
}
|
||||
if _, err := z.DeleteObject(t.Context(), bucket, object, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
if _, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: version}); !isErrVersionNotFound(err) {
|
||||
t.Errorf("pool %d retained completed replication purge: %v", i, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsDeleteVersionCleanupFailure(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
for primary := range 2 {
|
||||
t.Run(fmt.Sprintf("primary=%d", primary), func(t *testing.T) {
|
||||
object := fmt.Sprintf("cleanup-failure-%d", primary)
|
||||
oi := putConsistencyObject(t, z, bucket, object, primary, "payload", ObjectOptions{Versioned: true})
|
||||
putConsistencyObject(t, z, bucket, object, 1-primary, "payload", ObjectOptions{
|
||||
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime.Add(-time.Minute),
|
||||
})
|
||||
set := z.serverPools[1-primary].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
faulty := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range faulty {
|
||||
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
opts := ObjectOptions{Versioned: true, VersionID: oi.VersionID}
|
||||
if _, err := z.DeleteObject(t.Context(), bucket, object, opts); err == nil {
|
||||
t.Error("acknowledged DELETE despite failed secondary cleanup")
|
||||
}
|
||||
if got, err := z.serverPools[primary].GetObjectInfo(t.Context(), bucket, object, opts); err != nil || got.ETag != oi.ETag {
|
||||
t.Errorf("lost authoritative copy after secondary failure: %+v, %v", got, err)
|
||||
}
|
||||
set.getDisks = getDisks
|
||||
if _, err := z.DeleteObject(t.Context(), bucket, object, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
if _, err := pool.GetObjectInfo(t.Context(), bucket, object, opts); !isErrVersionNotFound(err) {
|
||||
t.Errorf("pool %d retained version after retry: %v", i, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsDeleteVersionCallbacks(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
for _, marker := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("marker=%t", marker), func(t *testing.T) {
|
||||
object := fmt.Sprintf("callbacks-%t", marker)
|
||||
old, recent := "2026-09-09T09:00:00Z", "2026-09-09T10:00:00Z"
|
||||
oi := putConsistencyObject(t, z, bucket, object, 0, "payload", ObjectOptions{
|
||||
Versioned: true, UserDefined: poolLockMetadata("GOVERNANCE", "OFF", recent, old),
|
||||
})
|
||||
putConsistencyObject(t, z, bucket, object, 1, "payload", ObjectOptions{
|
||||
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime,
|
||||
UserDefined: poolLockMetadata("", "ON", old, recent),
|
||||
})
|
||||
if marker {
|
||||
markerOpts := ObjectOptions{Versioned: true, VersionID: mustGetUUID(), DeleteMarker: true, MTime: UTCNow()}
|
||||
for _, pool := range z.serverPools {
|
||||
var err error
|
||||
oi, err = pool.DeleteObject(t.Context(), bucket, object, markerOpts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
check := func(current ObjectInfo, err error) {
|
||||
t.Helper()
|
||||
if current.VersionID != oi.VersionID || current.DeleteMarker != marker || (marker && !isErrMethodNotAllowed(err)) || (!marker && err != nil) {
|
||||
t.Errorf("wrong callback version or read error: %+v, %v", current, err)
|
||||
}
|
||||
if !marker {
|
||||
state := storedObjectLockState(current.UserDefined)
|
||||
if state.mode != "GOVERNANCE" || state.legalHold != "ON" {
|
||||
t.Errorf("callback did not reconcile independently ordered lock state: %+v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, reject := range []string{"retention", "metadata", "none"} {
|
||||
metadata, retention := 0, 0
|
||||
denied := errors.New("callback denied deletion")
|
||||
opts := ObjectOptions{
|
||||
Versioned: true, VersionID: oi.VersionID,
|
||||
EvalRetentionBypassFn: func(current ObjectInfo, err error) error {
|
||||
retention++
|
||||
check(current, err)
|
||||
if reject == "retention" {
|
||||
return denied
|
||||
}
|
||||
return nil
|
||||
},
|
||||
EvalMetadataFn: func(current *ObjectInfo, err error) (ReplicateDecision, error) {
|
||||
metadata++
|
||||
check(*current, err)
|
||||
if reject == "metadata" {
|
||||
return ReplicateDecision{}, denied
|
||||
}
|
||||
return ReplicateDecision{}, nil
|
||||
},
|
||||
}
|
||||
_, err := z.DeleteObject(t.Context(), bucket, object, opts)
|
||||
if reject == "none" {
|
||||
if err != nil || metadata != 1 || retention != 1 {
|
||||
t.Fatalf("DELETE: %v, metadata=%d retention=%d", err, metadata, retention)
|
||||
}
|
||||
} else if !errors.Is(err, denied) {
|
||||
t.Fatalf("%s rejection was lost: %v", reject, err)
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
current, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
|
||||
if reject == "none" {
|
||||
if !isErrVersionNotFound(err) {
|
||||
t.Errorf("pool %d retained deleted version: %v", i, err)
|
||||
}
|
||||
} else if current.VersionID != oi.VersionID || (err != nil && (!marker || !isErrMethodNotAllowed(err))) {
|
||||
t.Errorf("pool %d changed despite %s rejection: %+v, %v", i, reject, current, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsDeleteVersionSpecialCalls(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
t.Run("incoming-marker-replication", func(t *testing.T) {
|
||||
const object = "incoming-marker"
|
||||
opts := ObjectOptions{Versioned: true, VersionID: mustGetUUID(), DeleteMarker: true, ReplicationRequest: true}
|
||||
opts.SetReplicaStatus(replication.Replica)
|
||||
if _, err := z.DeleteObject(t.Context(), bucket, object, opts); err != nil {
|
||||
t.Fatalf("replication could not create an absent marker: %v", err)
|
||||
}
|
||||
if got, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: opts.VersionID}); !isErrMethodNotAllowed(err) || !got.DeleteMarker {
|
||||
t.Fatalf("replicated marker missing: %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
for _, rebalance := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("movement/rebalance=%t", rebalance), func(t *testing.T) {
|
||||
object := fmt.Sprintf("movement-%t", rebalance)
|
||||
putConsistencyObject(t, z, bucket, object, 1, "destination", ObjectOptions{Versioned: true})
|
||||
opts := ObjectOptions{Versioned: true, VersionID: mustGetUUID(), DeleteMarker: true, MTime: UTCNow()}
|
||||
if _, err := z.serverPools[0].DeleteObject(t.Context(), bucket, object, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rebalance {
|
||||
z.rebalMu.Lock()
|
||||
z.rebalMeta = &rebalanceMeta{PoolStats: []*rebalanceStats{{Participating: true, Info: rebalanceInfo{Status: rebalStarted}}, {}}}
|
||||
z.rebalMu.Unlock()
|
||||
defer func() { z.rebalMu.Lock(); z.rebalMeta = nil; z.rebalMu.Unlock() }()
|
||||
} else {
|
||||
z.poolMetaMutex.Lock()
|
||||
z.poolMeta.Pools[0].Decommission = &PoolDecommissionInfo{}
|
||||
z.poolMetaMutex.Unlock()
|
||||
defer func() { z.poolMetaMutex.Lock(); z.poolMeta.Pools[0].Decommission = nil; z.poolMetaMutex.Unlock() }()
|
||||
}
|
||||
// These are the marker-copy options used by rebalance/decommission;
|
||||
// Source cleanup belongs to the mover.
|
||||
opts.DataMovement, opts.SrcPoolIdx = true, 0
|
||||
opts.SkipRebalancing, opts.SkipDecommissioned = rebalance, !rebalance
|
||||
if _, err := z.DeleteObject(t.Context(), bucket, object, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
if got, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: opts.VersionID}); !isErrMethodNotAllowed(err) || !got.DeleteMarker {
|
||||
t.Errorf("movement lost marker in pool %d: %+v, %v", i, got, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, expiration := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("scanner/expiration=%t", expiration), func(t *testing.T) {
|
||||
object := fmt.Sprintf("scanner-%t", expiration)
|
||||
oi := putConsistencyObject(t, z, bucket, object, 0, "payload", ObjectOptions{Versioned: true})
|
||||
set := z.serverPools[1].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
faulty := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range faulty {
|
||||
faulty[i] = consistencyReadFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
opts := ObjectOptions{Versioned: true, VersionID: oi.VersionID, InclFreeVersions: !expiration, Expiration: ExpirationOptions{Expire: expiration}}
|
||||
_, err := z.DeleteObject(t.Context(), bucket, object, opts)
|
||||
if expiration {
|
||||
// No lifecycle rule authorizes expiration. Preserve its existing
|
||||
// version-not-found result, even with an unrelated unreadable pool.
|
||||
if !isErrVersionNotFound(err) {
|
||||
t.Errorf("expiration changed its scanner contract: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("free-version cleanup was forced through all-pool resolution: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsDeleteUnversionedFanout(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
const object = "unversioned-fanout"
|
||||
for i := range z.serverPools {
|
||||
putConsistencyObject(t, z, bucket, object, i, "payload", ObjectOptions{})
|
||||
}
|
||||
if _, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
if _, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) {
|
||||
t.Errorf("unversioned fanout left pool %d readable: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsConditionalDeleteVersionSelection(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
const object = "split-versions"
|
||||
@@ -108,7 +596,7 @@ func TestPoolsConditionalDeleteReportsOtherPoolFailure(t *testing.T) {
|
||||
getDisks := set.getDisks
|
||||
faulty := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range faulty {
|
||||
faulty[i] = accessMoveDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object}
|
||||
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
@@ -135,9 +623,8 @@ func testPoolsConditionalDeleteWriter(t *testing.T, multipart bool) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
reader := mustGetPutObjReader(t, bytes.NewBufferString("after"), 5, "", "")
|
||||
destination := 1
|
||||
write := func() error {
|
||||
_, err := z.PutObject(ctx, bucket, object, reader, ObjectOptions{DataMovement: true, SrcPoolIdx: 0, DstPoolIdx: &destination})
|
||||
_, err := z.PutObject(ctx, bucket, object, reader, ObjectOptions{})
|
||||
return err
|
||||
}
|
||||
if multipart {
|
||||
@@ -548,7 +1035,7 @@ func TestPoolsReplicaCleanupFailureCanRetry(t *testing.T) {
|
||||
getDisks := set.getDisks
|
||||
faulty := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range faulty {
|
||||
faulty[i] = accessMoveDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
|
||||
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
@@ -582,6 +1069,8 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
failPrimaryDelete bool
|
||||
differentRemote bool
|
||||
restored bool
|
||||
unconditional bool
|
||||
skipFreeVersion bool
|
||||
}{
|
||||
{name: "metadata-copy"},
|
||||
{name: "restored-metadata-copy", restored: true},
|
||||
@@ -589,6 +1078,9 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
{name: "failed-primary-delete", deleting: true, failPrimaryDelete: true},
|
||||
{name: "failed-primary-delete-distinct-reference", deleting: true, failPrimaryDelete: true, differentRemote: true},
|
||||
{name: "successful-delete", deleting: true},
|
||||
{name: "ordinary-failed-primary-delete", deleting: true, unconditional: true, failPrimaryDelete: true},
|
||||
{name: "ordinary-successful-delete", deleting: true, unconditional: true},
|
||||
{name: "ordinary-skip-free-version", deleting: true, unconditional: true, skipFreeVersion: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
@@ -622,16 +1114,20 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
getDisks := set.getDisks
|
||||
faulty := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range faulty {
|
||||
faulty[i] = accessMoveDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
|
||||
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
}
|
||||
if test.deleting {
|
||||
_, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{
|
||||
Versioned: true, VersionID: oi.VersionID,
|
||||
opts := ObjectOptions{
|
||||
Versioned: true, VersionID: oi.VersionID, SkipFreeVersion: test.skipFreeVersion,
|
||||
CheckPrecondFn: func(info ObjectInfo) bool { return info.ETag != current.ETag },
|
||||
})
|
||||
}
|
||||
if test.unconditional {
|
||||
opts.CheckPrecondFn = nil
|
||||
}
|
||||
_, err := z.DeleteObject(t.Context(), bucket, object, opts)
|
||||
if (err != nil) != test.failPrimaryDelete {
|
||||
t.Fatalf("unexpected authoritative delete result: %v", err)
|
||||
}
|
||||
@@ -656,6 +1152,7 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
t.Fatalf("lost retained authoritative copy: %v", err)
|
||||
}
|
||||
for pool, wantFree := range []bool{primaryDeleted, test.differentRemote} {
|
||||
wantFree = wantFree && !test.skipFreeVersion
|
||||
for _, disk := range z.serverPools[pool].getHashedSet(object).getDisks() {
|
||||
data, err := disk.ReadAll(t.Context(), bucket, pathJoin(object, xlStorageFormatFile))
|
||||
if errors.Is(err, errFileNotFound) && !wantFree {
|
||||
@@ -680,3 +1177,132 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fault injection shared by general multi-pool regressions.
|
||||
type consistencyDeleteFaultDisk struct {
|
||||
StorageAPI
|
||||
bucket, object, version string
|
||||
}
|
||||
|
||||
func (d consistencyDeleteFaultDisk) DeleteVersion(ctx context.Context, volume, path string, fi FileInfo, forceDelMarker bool, opts DeleteOptions) error {
|
||||
if volume == d.bucket && path == d.object && fi.VersionID == d.version {
|
||||
return errDiskFull
|
||||
}
|
||||
return d.StorageAPI.DeleteVersion(ctx, volume, path, fi, forceDelMarker, opts)
|
||||
}
|
||||
|
||||
// Exercise the surviving production rebalance copy, then interrupt the workflow
|
||||
// before its later source cleanup. Repeated versions are reachable without the
|
||||
// retired access-tier mover, and an API delete must remove both copies.
|
||||
func TestPoolsDeleteVersionAfterInterruptedRebalance(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{VersioningEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for source := range 2 {
|
||||
t.Run(fmt.Sprintf("source=%d", source), func(t *testing.T) {
|
||||
object := fmt.Sprintf("interrupted-rebalance-%d", source)
|
||||
original := putConsistencyObject(t, z, bucket, object, source, "original", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
|
||||
stats := []*rebalanceStats{{}, {}}
|
||||
stats[source] = &rebalanceStats{Participating: true, Info: rebalanceInfo{Status: rebalStarted}}
|
||||
z.rebalMu.Lock()
|
||||
z.rebalMeta = &rebalanceMeta{PoolStats: stats}
|
||||
z.rebalMu.Unlock()
|
||||
defer func() { z.rebalMu.Lock(); z.rebalMeta = nil; z.rebalMu.Unlock() }()
|
||||
gr, err := z.serverPools[source].GetObjectNInfo(ctx, bucket, object, nil, nil, ObjectOptions{VersionID: original.VersionID, NoLock: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := z.rebalanceObject(ctx, source, bucket, gr); err != nil {
|
||||
t.Fatalf("rebalance copy: %v", err)
|
||||
}
|
||||
// Simulate interruption after rebalanceObject, before the enclosing loop
|
||||
// removes the source version stack. Stop rebalance before the API request.
|
||||
z.rebalMu.Lock()
|
||||
z.rebalMeta = nil
|
||||
z.rebalMu.Unlock()
|
||||
for i, pool := range z.serverPools {
|
||||
got, err := pool.GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: original.VersionID})
|
||||
if err != nil || got.VersionID != original.VersionID || !got.ModTime.Equal(original.ModTime) {
|
||||
t.Fatalf("copy missing/changed in pool %d: %+v, %v", i, got, err)
|
||||
}
|
||||
}
|
||||
later, err := z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewBufferString("later"), 5, "", ""), ObjectOptions{Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, original.VersionID)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
if _, err := pool.GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: original.VersionID}); !isErrVersionNotFound(err) {
|
||||
t.Errorf("pool %d retained old version: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got, err := z.GetObjectInfo(ctx, bucket, object, ObjectOptions{}); err != nil || got.VersionID != later.VersionID {
|
||||
t.Fatalf("other version changed: %+v, %v", got, err)
|
||||
}
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
if rec := consistencyRequest(t, router, method, bucket, object, original.VersionID); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s returned %d", method, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsDeleteDirectoryMarker(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{VersioningEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for holding := range 2 {
|
||||
for _, unreadable := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("holding=%d/unreadable=%t", holding, unreadable), func(t *testing.T) {
|
||||
object := fmt.Sprintf("directory-%d-%t/", holding, unreadable)
|
||||
encoded := encodeDirObject(object)
|
||||
putConsistencyObject(t, z, bucket, encoded, holding, "", ObjectOptions{})
|
||||
set := z.serverPools[1-holding].getHashedSet(encoded)
|
||||
getDisks := set.getDisks
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
if unreadable {
|
||||
disks := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range disks {
|
||||
disks[i] = consistencyReadFaultDisk{StorageAPI: disks[i], bucket: bucket, object: encoded}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return disks }
|
||||
}
|
||||
// No explicit versionId: delOpts permanently deletes the null version.
|
||||
rec := consistencyRequest(t, router, http.MethodDelete, bucket, object, "")
|
||||
if unreadable {
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("unreadable pool: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := z.serverPools[holding].GetObjectInfo(ctx, bucket, encoded, ObjectOptions{VersionID: nullVersionID}); err != nil {
|
||||
t.Fatalf("readable directory marker lost: %v", err)
|
||||
}
|
||||
set.getDisks = getDisks
|
||||
rec = consistencyRequest(t, router, http.MethodDelete, bucket, object, "")
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
if _, err := pool.GetObjectInfo(ctx, bucket, encoded, ObjectOptions{VersionID: nullVersionID}); !isErrVersionNotFound(err) {
|
||||
t.Errorf("pool %d retained null version: %v", i, err)
|
||||
}
|
||||
}
|
||||
if rec := consistencyRequest(t, router, http.MethodHead, bucket, object, ""); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("directory still visible: %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-98
@@ -615,17 +615,7 @@ func (z *erasureServerPools) getPoolIdxExistingNoLock(ctx context.Context, bucke
|
||||
})
|
||||
}
|
||||
|
||||
func (z *erasureServerPools) getPoolIdxNoLock(ctx context.Context, bucket, object string, size int64, dstPoolIdx *int) (idx int, err error) {
|
||||
if dstPoolIdx != nil {
|
||||
if *dstPoolIdx < 0 || *dstPoolIdx >= len(z.serverPools) {
|
||||
return -1, errInvalidArgument
|
||||
}
|
||||
if z.IsSuspended(*dstPoolIdx) || z.IsPoolRebalancing(*dstPoolIdx) {
|
||||
return -1, toObjectErr(errDiskFull)
|
||||
}
|
||||
return *dstPoolIdx, nil
|
||||
}
|
||||
|
||||
func (z *erasureServerPools) getPoolIdxNoLock(ctx context.Context, bucket, object string, size int64) (idx int, err error) {
|
||||
idx, err = z.getPoolIdxExistingNoLock(ctx, bucket, object)
|
||||
if err != nil && !isErrObjectNotFound(err) {
|
||||
return idx, err
|
||||
@@ -644,23 +634,13 @@ func (z *erasureServerPools) getPoolIdxNoLock(ctx context.Context, bucket, objec
|
||||
// getPoolIdx returns the found previous object and its corresponding pool idx,
|
||||
// if none are found falls back to most available space pool, this function is
|
||||
// designed to be only used by PutObject, CopyObject (newObject creation) and NewMultipartUpload.
|
||||
func (z *erasureServerPools) getPoolIdx(ctx context.Context, bucket, object string, size int64, dstPoolIdx *int) (idx int, err error) {
|
||||
return z.getWritePoolIdx(ctx, bucket, object, size, dstPoolIdx, false)
|
||||
func (z *erasureServerPools) getPoolIdx(ctx context.Context, bucket, object string, size int64) (idx int, err error) {
|
||||
return z.getWritePoolIdx(ctx, bucket, object, size, false)
|
||||
}
|
||||
|
||||
// getWritePoolIdx keeps the write-allocation policy when the caller already
|
||||
// holds the pools-layer object lock and must not reacquire a set read lock.
|
||||
func (z *erasureServerPools) getWritePoolIdx(ctx context.Context, bucket, object string, size int64, dstPoolIdx *int, noLock bool) (idx int, err error) {
|
||||
if dstPoolIdx != nil {
|
||||
if *dstPoolIdx < 0 || *dstPoolIdx >= len(z.serverPools) {
|
||||
return -1, errInvalidArgument
|
||||
}
|
||||
if z.IsSuspended(*dstPoolIdx) || z.IsPoolRebalancing(*dstPoolIdx) {
|
||||
return -1, toObjectErr(errDiskFull)
|
||||
}
|
||||
return *dstPoolIdx, nil
|
||||
}
|
||||
|
||||
func (z *erasureServerPools) getWritePoolIdx(ctx context.Context, bucket, object string, size int64, noLock bool) (idx int, err error) {
|
||||
pinfo, _, err := z.getPoolInfoExistingWithOpts(ctx, bucket, object, ObjectOptions{
|
||||
NoLock: noLock,
|
||||
SkipDecommissioned: true,
|
||||
@@ -683,13 +663,6 @@ func (z *erasureServerPools) getWritePoolIdx(ctx context.Context, bucket, object
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func dataMovementDstPool(opts ObjectOptions) *int {
|
||||
if !opts.DataMovement {
|
||||
return nil
|
||||
}
|
||||
return opts.DstPoolIdx
|
||||
}
|
||||
|
||||
func (z *erasureServerPools) Shutdown(ctx context.Context) error {
|
||||
g := errgroup.WithNErrs(len(z.serverPools))
|
||||
|
||||
@@ -1158,16 +1131,10 @@ func (z *erasureServerPools) PutObject(ctx context.Context, bucket string, objec
|
||||
|
||||
object = encodeDirObject(object)
|
||||
if z.SinglePool() {
|
||||
idx, err := z.getPoolIdx(ctx, bucket, object, data.Size(), dataMovementDstPool(opts))
|
||||
_, err := z.getPoolIdx(ctx, bucket, object, data.Size())
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
if dataMovementDstPool(opts) != nil && idx == opts.SrcPoolIdx {
|
||||
return ObjectInfo{}, DataMovementOverwriteErr{
|
||||
Bucket: bucket, Object: object, VersionID: opts.VersionID,
|
||||
Err: errDataMovementSrcDstPoolSame,
|
||||
}
|
||||
}
|
||||
return z.serverPools[0].PutObject(ctx, bucket, object, data, opts)
|
||||
}
|
||||
|
||||
@@ -1182,7 +1149,7 @@ func (z *erasureServerPools) PutObject(ctx context.Context, bucket string, objec
|
||||
}
|
||||
opts.NoLock = true
|
||||
|
||||
idx, err := z.getWritePoolIdx(ctx, bucket, object, data.Size(), dataMovementDstPool(opts), true)
|
||||
idx, err := z.getWritePoolIdx(ctx, bucket, object, data.Size(), true)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
@@ -1238,30 +1205,11 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob
|
||||
return ObjectInfo{}, z.deletePrefix(ctx, bucket, object)
|
||||
}
|
||||
|
||||
// Access-tier moves must recreate delete markers on the explicitly
|
||||
// selected destination. The regular data-movement path discovers a pool
|
||||
// from existing object state, which is ambiguous while both source and
|
||||
// destination temporarily contain the version stack.
|
||||
if dstPoolIdx := dataMovementDstPool(opts); dstPoolIdx != nil {
|
||||
if *dstPoolIdx < 0 || *dstPoolIdx >= len(z.serverPools) {
|
||||
return ObjectInfo{}, errInvalidArgument
|
||||
}
|
||||
if *dstPoolIdx == opts.SrcPoolIdx {
|
||||
return ObjectInfo{}, DataMovementOverwriteErr{
|
||||
Bucket: bucket, Object: decodeDirObject(object), VersionID: opts.VersionID,
|
||||
Err: errDataMovementSrcDstPoolSame,
|
||||
}
|
||||
}
|
||||
if z.IsSuspended(*dstPoolIdx) || z.IsPoolRebalancing(*dstPoolIdx) {
|
||||
return ObjectInfo{}, toObjectErr(errDiskFull)
|
||||
}
|
||||
objInfo, err = z.serverPools[*dstPoolIdx].DeleteObject(ctx, bucket, object, opts)
|
||||
objInfo.Name = decodeDirObject(object)
|
||||
return objInfo, err
|
||||
}
|
||||
|
||||
if !z.SinglePool() && opts.CheckPrecondFn != nil {
|
||||
return z.deleteObjectConditional(ctx, bucket, object, opts)
|
||||
// Reconcile ordinary addressed-version deletes independently of pool movement.
|
||||
reconcileVersion := opts.VersionID != "" && !opts.DataMovement &&
|
||||
!opts.ReplicationRequest && !opts.Expiration.Expire && !opts.InclFreeVersions
|
||||
if !z.SinglePool() && (opts.CheckPrecondFn != nil || reconcileVersion) {
|
||||
return z.deleteObjectReconciled(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
gopts := opts
|
||||
@@ -1508,16 +1456,10 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
|
||||
return oi, err
|
||||
}
|
||||
|
||||
poolIdx, err := z.getPoolIdxNoLock(ctx, dstBucket, dstObject, srcInfo.Size, dataMovementDstPool(dstOpts))
|
||||
poolIdx, err := z.getPoolIdxNoLock(ctx, dstBucket, dstObject, srcInfo.Size)
|
||||
if err != nil {
|
||||
return objInfo, err
|
||||
}
|
||||
if dataMovementDstPool(dstOpts) != nil && poolIdx == dstOpts.SrcPoolIdx {
|
||||
return ObjectInfo{}, DataMovementOverwriteErr{
|
||||
Bucket: dstBucket, Object: dstObject, VersionID: dstOpts.VersionID,
|
||||
Err: errDataMovementSrcDstPoolSame,
|
||||
}
|
||||
}
|
||||
|
||||
if !z.SinglePool() && dstOpts.ReplicaLockReconcile {
|
||||
dstOpts.replicaObjectInfo = z.replicaObjectInfo
|
||||
@@ -1977,41 +1919,29 @@ func (z *erasureServerPools) NewMultipartUpload(ctx context.Context, bucket, obj
|
||||
}()
|
||||
|
||||
if z.SinglePool() {
|
||||
idx, err := z.getPoolIdx(ctx, bucket, object, -1, dataMovementDstPool(opts))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataMovementDstPool(opts) != nil && idx == opts.SrcPoolIdx {
|
||||
return nil, DataMovementOverwriteErr{
|
||||
Bucket: bucket, Object: object, VersionID: opts.VersionID,
|
||||
Err: errDataMovementSrcDstPoolSame,
|
||||
}
|
||||
}
|
||||
return z.serverPools[0].NewMultipartUpload(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
if dataMovementDstPool(opts) == nil {
|
||||
for idx, pool := range z.serverPools {
|
||||
if z.IsSuspended(idx) || z.IsPoolRebalancing(idx) {
|
||||
continue
|
||||
}
|
||||
for idx, pool := range z.serverPools {
|
||||
if z.IsSuspended(idx) || z.IsPoolRebalancing(idx) {
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := pool.ListMultipartUploads(ctx, bucket, object, "", "", "", maxUploadsList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If there is a multipart upload with the same bucket/object name,
|
||||
// create the new multipart in the same pool, this will avoid
|
||||
// creating two multiparts uploads in two different pools.
|
||||
if len(result.Uploads) != 0 {
|
||||
return z.serverPools[idx].NewMultipartUpload(ctx, bucket, object, opts)
|
||||
}
|
||||
result, err := pool.ListMultipartUploads(ctx, bucket, object, "", "", "", maxUploadsList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If there is a multipart upload with the same bucket/object name,
|
||||
// create the new multipart in the same pool, this will avoid
|
||||
// creating two multiparts uploads in two different pools
|
||||
if len(result.Uploads) != 0 {
|
||||
return z.serverPools[idx].NewMultipartUpload(ctx, bucket, object, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// any parallel writes on the object will block for this poolIdx
|
||||
// to return since this holds a read lock on the namespace.
|
||||
idx, err := z.getPoolIdx(ctx, bucket, object, -1, dataMovementDstPool(opts))
|
||||
idx, err := z.getPoolIdx(ctx, bucket, object, -1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2045,7 +1975,7 @@ func (z *erasureServerPools) PutObjectPart(ctx context.Context, bucket, object,
|
||||
}
|
||||
|
||||
if z.SinglePool() {
|
||||
_, err := z.getPoolIdx(ctx, bucket, object, data.Size(), dataMovementDstPool(opts))
|
||||
_, err := z.getPoolIdx(ctx, bucket, object, data.Size())
|
||||
if err != nil {
|
||||
return PartInfo{}, err
|
||||
}
|
||||
@@ -2276,6 +2206,11 @@ func (z *erasureServerPools) GetBucketInfo(ctx context.Context, bucket string, o
|
||||
if err != nil {
|
||||
return bucketInfo, toObjectErr(err, bucket)
|
||||
}
|
||||
// Physical existence/creation probes must not be overwritten by cached
|
||||
// metadata, which can legitimately lack Created on an unmigrated bucket.
|
||||
if opts.NoMetadata {
|
||||
return bucketInfo, nil
|
||||
}
|
||||
|
||||
meta, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err == nil {
|
||||
@@ -3214,7 +3149,7 @@ func (z *erasureServerPools) DecomTieredObject(ctx context.Context, bucket, obje
|
||||
defer ns.Unlock(lkctx)
|
||||
opts.NoLock = true
|
||||
}
|
||||
idx, err := z.getPoolIdxNoLock(ctx, bucket, object, fi.Size, dataMovementDstPool(opts))
|
||||
idx, err := z.getPoolIdxNoLock(ctx, bucket, object, fi.Size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
// Copyright 2026 PGSTY contributors.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/dsync"
|
||||
"github.com/minio/minio/internal/hash"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
func accessMovePools(t *testing.T) (*erasureServerPools, string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
dirs, err := getRandomDisks(32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoints := mustGetPoolEndpoints(0, dirs[:16]...)
|
||||
endpoints = append(endpoints, mustGetPoolEndpoints(1, dirs[16:]...)...)
|
||||
obj, _, err := initObjectLayer(ctx, endpoints)
|
||||
if err != nil {
|
||||
cancel()
|
||||
removeRoots(dirs)
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
previous := newObjectLayerFn()
|
||||
setObjectLayer(z)
|
||||
t.Cleanup(func() { cancel(); z.Shutdown(context.Background()); removeRoots(dirs); setObjectLayer(previous) })
|
||||
bucket := "access-move-test"
|
||||
if err := z.MakeBucket(ctx, bucket, MakeBucketOptions{VersioningEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return z, bucket
|
||||
}
|
||||
|
||||
func putAccessMoveVersion(t *testing.T, z *erasureServerPools, bucket, object string, pool int, body string, moved bool) ObjectInfo {
|
||||
t.Helper()
|
||||
metadata := map[string]string{"test-value": body}
|
||||
if moved {
|
||||
metadata[accessTierMetadataKey] = accessTierStamp(pool, time.Now().UnixNano())
|
||||
}
|
||||
cs := hash.NewChecksumFromData(hash.ChecksumCRC32C, []byte(body))
|
||||
oi, err := z.serverPools[pool].PutObject(t.Context(), bucket, object,
|
||||
mustGetPutObjReader(t, bytes.NewBufferString(body), int64(len(body)), "", ""),
|
||||
ObjectOptions{Versioned: true, UserDefined: metadata, WantChecksum: cs})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return oi
|
||||
}
|
||||
|
||||
func assertAccessMoveVersion(t *testing.T, z *erasureServerPools, bucket, object string, pool int, want ObjectInfo, body string) {
|
||||
t.Helper()
|
||||
gr, err := z.serverPools[pool].GetObjectNInfo(t.Context(), bucket, object, nil, http.Header{}, ObjectOptions{VersionID: want.VersionID})
|
||||
if err != nil {
|
||||
t.Errorf("pool %d version %s: %v", pool, want.VersionID, err)
|
||||
return
|
||||
}
|
||||
got, err := io.ReadAll(gr)
|
||||
gr.Close()
|
||||
if err != nil || string(got) != body {
|
||||
t.Errorf("pool %d payload = %q, err = %v, want %q", pool, got, err, body)
|
||||
}
|
||||
if gr.ObjInfo.ETag != want.ETag || !gr.ObjInfo.ModTime.Equal(want.ModTime) {
|
||||
t.Error("move changed ETag or modification time")
|
||||
}
|
||||
if len(want.Checksum) == 0 {
|
||||
t.Fatal("fixture has no checksum")
|
||||
}
|
||||
if !bytes.Equal(gr.ObjInfo.Checksum, want.Checksum) {
|
||||
t.Error("move changed or dropped the stored checksum")
|
||||
}
|
||||
if gr.ObjInfo.UserDefined["test-value"] != body {
|
||||
t.Error("move changed user metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMoveVersionStack(t *testing.T) {
|
||||
z, bucket := accessMovePools(t)
|
||||
const object = "versions"
|
||||
old := putAccessMoveVersion(t, z, bucket, object, 1, "old", false)
|
||||
dm, err := z.serverPools[1].DeleteObject(t.Context(), bucket, object, ObjectOptions{Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest := putAccessMoveVersion(t, z, bucket, object, 1, "new", false)
|
||||
for _, pair := range [][2]int{{1, 0}, {0, 1}} {
|
||||
if _, err := moveObjectPool(t.Context(), z, bucket, object, pair[0], pair[1], nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, pair[1], old, "old")
|
||||
assertAccessMoveVersion(t, z, bucket, object, pair[1], latest, "new")
|
||||
versions, err := accessObjectVersions(t.Context(), z, pair[1], bucket, object)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, version := range versions {
|
||||
if version.VersionID == dm.VersionID && version.Deleted {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found || len(versions) != 3 {
|
||||
t.Errorf("move lost a version or delete marker: %+v", versions)
|
||||
}
|
||||
if _, err := z.serverPools[pair[0]].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) {
|
||||
t.Errorf("source remains after completed move: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMovePreservesNestedObject(t *testing.T) {
|
||||
z, bucket := accessMovePools(t)
|
||||
parent := putAccessMoveVersion(t, z, bucket, "parent", 1, "parent", false)
|
||||
child := putAccessMoveVersion(t, z, bucket, "parent/child", 1, "child", false)
|
||||
if _, err := moveObjectPool(t.Context(), z, bucket, "parent", 1, 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, "parent", 0, parent, "parent")
|
||||
assertAccessMoveVersion(t, z, bucket, "parent/child", 1, child, "child")
|
||||
}
|
||||
|
||||
func TestAccessMoveNullVersion(t *testing.T) {
|
||||
z, bucket := accessMovePools(t)
|
||||
const object, body = "null-version", "unversioned"
|
||||
// A null version can remain in a bucket after versioning is enabled.
|
||||
oi, err := z.serverPools[1].PutObject(t.Context(), bucket, object,
|
||||
mustGetPutObjReader(t, bytes.NewBufferString(body), int64(len(body)), "", ""), ObjectOptions{
|
||||
UserDefined: map[string]string{"test-value": body},
|
||||
WantChecksum: hash.NewChecksumFromData(hash.ChecksumCRC32C, []byte(body)),
|
||||
})
|
||||
if err != nil || oi.VersionID != "" {
|
||||
t.Fatalf("null version fixture failed: %v %q", err, oi.VersionID)
|
||||
}
|
||||
if _, err := moveObjectPool(t.Context(), z, bucket, object, 1, 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, oi, body)
|
||||
if _, err := z.serverPools[1].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) {
|
||||
t.Fatalf("null version source was not removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Source removal can partially commit before a process or disk fails. The
|
||||
// destination can therefore hold the only remaining copy of an older version.
|
||||
func TestAccessMoveRetryPreservesDestinationVersions(t *testing.T) {
|
||||
z, bucket := accessMovePools(t)
|
||||
const object = "retry"
|
||||
onlyAtDestination := putAccessMoveVersion(t, z, bucket, object, 0, "unique-old", true)
|
||||
source := putAccessMoveVersion(t, z, bucket, object, 1, "source-new", false)
|
||||
if _, err := moveObjectPool(t.Context(), z, bucket, object, 1, 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, onlyAtDestination, "unique-old")
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, source, "source-new")
|
||||
}
|
||||
|
||||
type accessMoveFaultDisk struct {
|
||||
StorageAPI
|
||||
failVersion string
|
||||
}
|
||||
|
||||
func (d accessMoveFaultDisk) RenameData(ctx context.Context, srcVolume, srcPath string, fi FileInfo, dstVolume, dstPath string, opts RenameOptions) (RenameDataResp, error) {
|
||||
if fi.VersionID == d.failVersion {
|
||||
return RenameDataResp{}, errDiskFull
|
||||
}
|
||||
return d.StorageAPI.RenameData(ctx, srcVolume, srcPath, fi, dstVolume, dstPath, opts)
|
||||
}
|
||||
|
||||
func TestAccessMoveWriteFailureCanResume(t *testing.T) {
|
||||
z, bucket := accessMovePools(t)
|
||||
const object = "write-failure"
|
||||
old := putAccessMoveVersion(t, z, bucket, object, 1, "old", false)
|
||||
latest := putAccessMoveVersion(t, z, bucket, object, 1, "new", false)
|
||||
existing := putAccessMoveVersion(t, z, bucket, object, 0, "unique-destination", true)
|
||||
set := z.serverPools[0].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
disks := getDisks()
|
||||
faulty := make([]StorageAPI, len(disks))
|
||||
for i, disk := range disks {
|
||||
faulty[i] = accessMoveFaultDisk{StorageAPI: disk, failVersion: latest.VersionID}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
_, err := moveObjectPool(t.Context(), z, bucket, object, 1, 0, nil)
|
||||
set.getDisks = getDisks
|
||||
if err == nil {
|
||||
t.Fatal("injected destination write failure was ignored")
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 1, old, "old")
|
||||
assertAccessMoveVersion(t, z, bucket, object, 1, latest, "new")
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, existing, "unique-destination")
|
||||
if _, err := moveObjectPool(t.Context(), z, bucket, object, 1, 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, old, "old")
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, latest, "new")
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, existing, "unique-destination")
|
||||
}
|
||||
|
||||
func TestAccessMoveExcludesSourceWriter(t *testing.T) {
|
||||
z, bucket := accessMovePools(t)
|
||||
const object = "concurrent"
|
||||
old := putAccessMoveVersion(t, z, bucket, object, 1, "old", false)
|
||||
_, err := moveObjectPool(t.Context(), z, bucket, object, 1, 0, func(ObjectInfo, uint64) error {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := z.serverPools[1].PutObject(ctx, bucket, object,
|
||||
mustGetPutObjReader(t, bytes.NewBufferString("racing-write"), 12, "", ""), ObjectOptions{Versioned: true})
|
||||
if err == nil {
|
||||
t.Error("source writer committed while move was in progress")
|
||||
}
|
||||
if ctx.Err() == nil {
|
||||
return errors.New("writer did not wait for the move lock")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, old, "old")
|
||||
}
|
||||
|
||||
func TestAccessMoveResumesPartialCopy(t *testing.T) {
|
||||
z, bucket := accessMovePools(t)
|
||||
const object = "partial-copy"
|
||||
old := putAccessMoveVersion(t, z, bucket, object, 1, "old", false)
|
||||
latest := putAccessMoveVersion(t, z, bucket, object, 1, "new", false)
|
||||
gr, err := z.serverPools[1].GetObjectNInfo(t.Context(), bucket, object, nil, http.Header{}, ObjectOptions{VersionID: old.VersionID, NoDecryption: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Model a process stopping after the first copy commits, before cleanup.
|
||||
if err := moveAccessTierVersion(t.Context(), z, 1, 0, bucket, gr, time.Now().UnixNano()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := moveObjectPool(t.Context(), z, bucket, object, 1, 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, old, "old")
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, latest, "new")
|
||||
}
|
||||
|
||||
func TestAccessMoveRefusesConflictingVersion(t *testing.T) {
|
||||
z, bucket := accessMovePools(t)
|
||||
const object = "conflicting-version"
|
||||
source := putAccessMoveVersion(t, z, bucket, object, 1, "source", false)
|
||||
destination, err := z.serverPools[0].PutObject(t.Context(), bucket, object,
|
||||
mustGetPutObjReader(t, bytes.NewBufferString("target"), 6, "", ""), ObjectOptions{
|
||||
VersionID: source.VersionID, MTime: source.ModTime,
|
||||
UserDefined: map[string]string{"test-value": "target"},
|
||||
WantChecksum: hash.NewChecksumFromData(hash.ChecksumCRC32C, []byte("target")),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := moveObjectPool(t.Context(), z, bucket, object, 1, 0, nil); !errors.Is(err, errAccessTierNotEligible) {
|
||||
t.Fatalf("conflicting version should be left intact: %v", err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 1, source, "source")
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, destination, "target")
|
||||
}
|
||||
|
||||
type accessMoveDeleteFaultDisk struct {
|
||||
StorageAPI
|
||||
bucket, object, version string
|
||||
}
|
||||
|
||||
func (d accessMoveDeleteFaultDisk) DeleteVersion(ctx context.Context, volume, path string, fi FileInfo, forceDelMarker bool, opts DeleteOptions) error {
|
||||
if volume == d.bucket && path == d.object && fi.VersionID == d.version {
|
||||
return errDiskFull
|
||||
}
|
||||
return d.StorageAPI.DeleteVersion(ctx, volume, path, fi, forceDelMarker, opts)
|
||||
}
|
||||
|
||||
func TestAccessMoveSourceDeleteFailureCanResume(t *testing.T) {
|
||||
z, bucket := accessMovePools(t)
|
||||
const object = "source-delete-failure"
|
||||
old := putAccessMoveVersion(t, z, bucket, object, 1, "old", false)
|
||||
latest := putAccessMoveVersion(t, z, bucket, object, 1, "new", false)
|
||||
set := z.serverPools[1].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
faulty := append([]StorageAPI(nil), getDisks()...)
|
||||
// Commit removal of the old version, then reject the latest version on
|
||||
// every disk. This fixes the retry boundary without relying on how a
|
||||
// partially deleted erasure quorum is subsequently resolved or healed.
|
||||
for i := range faulty {
|
||||
faulty[i] = accessMoveDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: latest.VersionID}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
_, err := moveObjectPool(t.Context(), z, bucket, object, 1, 0, nil)
|
||||
set.getDisks = getDisks
|
||||
if err == nil {
|
||||
t.Fatal("injected partial source purge was ignored")
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, old, "old")
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, latest, "new")
|
||||
if _, err := z.serverPools[1].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: old.VersionID}); !isErrVersionNotFound(err) {
|
||||
t.Fatalf("old source version should already be removed: %v", err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 1, latest, "new")
|
||||
if _, err := moveObjectPool(t.Context(), z, bucket, object, 1, 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, old, "old")
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, latest, "new")
|
||||
}
|
||||
|
||||
// Exercise the public multipart API and compare whole-object and part reads
|
||||
// across both directions of a real pool move, including raw SSE-C ciphertext.
|
||||
func TestAccessMoveMultipartChecksums(t *testing.T) {
|
||||
z, _ := accessMovePools(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{VersioningEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previousTLS := globalIsTLS
|
||||
globalIsTLS = true
|
||||
defer func() { globalIsTLS = previousTLS }()
|
||||
partData, full := multipartChecksumTestData()
|
||||
for _, tc := range []struct {
|
||||
typ hash.ChecksumType
|
||||
ssec bool
|
||||
}{{hash.ChecksumCRC32, false}, {hash.ChecksumCRC32C, false}, {hash.ChecksumCRC64NVME, false}, {hash.ChecksumCRC32C, true}} {
|
||||
t.Run(fmt.Sprintf("%s/ssec=%t", tc.typ, tc.ssec), func(t *testing.T) {
|
||||
object := getRandomObjectName()
|
||||
headers := map[string]string{}
|
||||
if tc.ssec {
|
||||
key := bytes.Repeat([]byte{0x42}, 32)
|
||||
keyMD5 := md5.Sum(key)
|
||||
headers[xhttp.AmzServerSideEncryptionCustomerAlgorithm] = xhttp.AmzEncryptionAES
|
||||
headers[xhttp.AmzServerSideEncryptionCustomerKey] = base64.StdEncoding.EncodeToString(key)
|
||||
headers[xhttp.AmzServerSideEncryptionCustomerKeyMD5] = base64.StdEncoding.EncodeToString(keyMD5[:])
|
||||
}
|
||||
do := func(method, url string, body []byte, extra map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
h := maps.Clone(headers)
|
||||
maps.Copy(h, extra)
|
||||
req, err := newTestSignedRequestV4(method, url, int64(len(body)), bytes.NewReader(body), globalActiveCred.AccessKey, globalActiveCred.SecretKey, h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("%s %s: %d %s", method, url, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec
|
||||
}
|
||||
init := do(http.MethodPost, getNewMultipartURL("", bucket, object), nil, map[string]string{
|
||||
xhttp.AmzChecksumAlgo: tc.typ.String(), xhttp.AmzChecksumType: xhttp.AmzChecksumTypeFullObject,
|
||||
})
|
||||
var upload InitiateMultipartUploadResponse
|
||||
if err := xml.Unmarshal(init.Body.Bytes(), &upload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := make([]CompletePart, len(partData))
|
||||
for i, data := range partData {
|
||||
rec := do(http.MethodPut, getPutObjectPartURL("", bucket, object, upload.UploadID, fmt.Sprint(i+1)), data,
|
||||
map[string]string{tc.typ.Key(): mustChecksum(t, tc.typ, data)})
|
||||
parts[i] = CompletePart{PartNumber: i + 1, ETag: canonicalizeETag(rec.Header()[xhttp.ETag][0])}
|
||||
}
|
||||
body, err := xml.Marshal(CompleteMultipartUpload{Parts: parts})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
do(http.MethodPost, getCompleteMultipartUploadURL("", bucket, object, upload.UploadID), body,
|
||||
map[string]string{tc.typ.Key(): mustChecksum(t, tc.typ, full), xhttp.AmzChecksumType: xhttp.AmzChecksumTypeFullObject})
|
||||
source, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
||||
if err != nil || len(source.Checksum) == 0 {
|
||||
t.Fatalf("source checksum missing: %v", err)
|
||||
}
|
||||
src := 0
|
||||
if _, err := z.serverPools[src].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); isErrObjectNotFound(err) {
|
||||
src = 1
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
dst := 1 - src
|
||||
if _, err := moveObjectPool(t.Context(), z, bucket, object, src, dst, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := z.serverPools[dst].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
||||
if err != nil || !bytes.Equal(got.Checksum, source.Checksum) || got.ETag != source.ETag || got.VersionID != source.VersionID || !got.ModTime.Equal(source.ModTime) {
|
||||
t.Fatalf("move changed version metadata: %v checksumEqual=%t ETag=%q/%q version=%q/%q modTimeEqual=%t", err, bytes.Equal(got.Checksum, source.Checksum), got.ETag, source.ETag, got.VersionID, source.VersionID, got.ModTime.Equal(source.ModTime))
|
||||
}
|
||||
rec := do(http.MethodGet, getGetObjectURL("", bucket, object), nil, map[string]string{xhttp.AmzChecksumMode: "ENABLED"})
|
||||
if !bytes.Equal(rec.Body.Bytes(), full) || rec.Header().Get(tc.typ.Key()) != mustChecksum(t, tc.typ, full) {
|
||||
t.Fatal("GET payload or checksum changed after move")
|
||||
}
|
||||
for j, data := range partData {
|
||||
rec := do(http.MethodGet, getGetObjectURL("", bucket, object)+fmt.Sprintf("?partNumber=%d", j+1), nil, nil)
|
||||
if !bytes.Equal(rec.Body.Bytes(), data) {
|
||||
t.Fatalf("part %d changed after move", j+1)
|
||||
}
|
||||
}
|
||||
src = dst
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type accessMoveNamedLocker struct {
|
||||
*localLocker
|
||||
address string
|
||||
}
|
||||
|
||||
func (l accessMoveNamedLocker) String() string { return l.address }
|
||||
|
||||
func TestAccessMoveSharedDistributedLockers(t *testing.T) {
|
||||
// Three overlapping sets of real dsync lock servers. Taking independent
|
||||
// quorum locks for the same object would contend with our own earlier lock.
|
||||
peers := make([]dsync.NetLocker, 5)
|
||||
for i := range peers {
|
||||
peers[i] = accessMoveNamedLocker{localLocker: newLocker(), address: fmt.Sprint(i)}
|
||||
}
|
||||
z := &erasureServerPools{}
|
||||
for i := 0; i < 3; i++ {
|
||||
setPeers := peers[i : i+3]
|
||||
set := &erasureObjects{nsMutex: &nsLockMap{isDistErasure: true}, getLockers: func() ([]dsync.NetLocker, string) {
|
||||
return setPeers, "access-move-fixture"
|
||||
}}
|
||||
z.serverPools = append(z.serverPools, &erasureSets{sets: []*erasureObjects{set}, distributionAlgo: formatErasureVersionV3DistributionAlgoV3})
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, unlock, err := lockAccessTierObject(ctx, z, "bucket", "object", 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release := sync.OnceFunc(unlock)
|
||||
defer release()
|
||||
for i, pool := range z.serverPools {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
lock := pool.NewNSLock("bucket", "object")
|
||||
lc, err := lock.GetLock(ctx, globalOperationTimeout)
|
||||
cancel()
|
||||
if err == nil {
|
||||
lock.Unlock(lc)
|
||||
t.Fatalf("pool %d writer acquired its quorum during the move", i)
|
||||
}
|
||||
}
|
||||
release()
|
||||
// Every acquired peer lock is released, so ordinary writes can resume.
|
||||
for _, peer := range peers {
|
||||
// Distributed Unlock sends releases asynchronously.
|
||||
lock := (&nsLockMap{isDistErasure: true}).NewNSLock(func() ([]dsync.NetLocker, string) {
|
||||
return []dsync.NetLocker{peer}, "access-move-fixture"
|
||||
}, "bucket", "object")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
|
||||
lc, err := lock.GetLock(ctx, globalOperationTimeout)
|
||||
cancel()
|
||||
if err != nil {
|
||||
t.Fatalf("peer %s leaked a lock: %v", peer.String(), err)
|
||||
}
|
||||
lock.Unlock(lc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMoveConfiguredPromotionAndDemotion(t *testing.T) {
|
||||
z, bucket := accessMovePools(t)
|
||||
const object = "configured-move"
|
||||
version := putAccessMoveVersion(t, z, bucket, object, 1, "configured", false)
|
||||
oldCfg, oldTracker := globalILMConfig.accessCfg(), globalAccessTracker
|
||||
defer func() { globalILMConfig.update(oldCfg); globalAccessTracker = oldTracker }()
|
||||
cfg := oldCfg
|
||||
cfg.AccessTiering, cfg.AccessPools = true, []int{0, 1}
|
||||
cfg.AccessMinResidency, cfg.AccessPromoteWatermark = 0, 99
|
||||
globalILMConfig.update(cfg)
|
||||
globalAccessTracker = newAccessTracker()
|
||||
lc := accessLifecycleForTest(t)
|
||||
data, err := xml.Marshal(lc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketLifecycleConfig, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := newAccessTierState(t.Context())
|
||||
state.z, state.objAPI = z, z
|
||||
task := accessTierTask{ctx: t.Context(), bucket: bucket, object: object, src: 1, dst: 0, direction: accessTierPromote, bytes: uint64(version.Size)}
|
||||
// A queued move is rechecked when configuration is disabled dynamically.
|
||||
cfg.AccessTiering = false
|
||||
globalILMConfig.update(cfg)
|
||||
if err := state.processTask(task); !errors.Is(err, errAccessTierNotEligible) {
|
||||
t.Fatalf("disabled mover = %v", err)
|
||||
}
|
||||
cfg.AccessTiering = true
|
||||
globalILMConfig.update(cfg)
|
||||
globalAccessTracker.merged.Store(&mergedAccess{binWidth: 60, entries: map[string]accessEntry{
|
||||
accessKey(bucket, object): {Bins: []uint32{100}, LastAt: time.Now().Unix()},
|
||||
}})
|
||||
if reason, ok := state.reservePromotion(task, cfg, 0); !ok {
|
||||
t.Fatalf("promotion reservation failed: %s", reason)
|
||||
}
|
||||
if err := state.processTask(task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 0, version, "configured")
|
||||
// Advance the counter view beyond the rule's idle period.
|
||||
globalAccessTracker.merged.Store(&mergedAccess{binWidth: 60, entries: map[string]accessEntry{
|
||||
accessKey(bucket, object): {Bins: []uint32{0}, LastAt: time.Now().Add(-2 * time.Hour).Unix()},
|
||||
}})
|
||||
task.src, task.dst, task.direction, task.bytes = 0, 1, accessTierDemote, 0
|
||||
if err := state.processTask(task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertAccessMoveVersion(t, z, bucket, object, 1, version, "configured")
|
||||
if state.promotions.Load() != 1 || state.demotions.Load() != 1 || state.bytesMoved.Load() != 2*uint64(version.Size) || len(state.pending) != 0 || len(state.reserved) != 0 {
|
||||
t.Fatal("promotion/demotion metrics or reservation cleanup are incorrect")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,263 +0,0 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/config/ilm"
|
||||
"github.com/tinylib/msgp/msgp"
|
||||
)
|
||||
|
||||
func accessLifecycleForTest(t *testing.T) *lifecycle.Lifecycle {
|
||||
t.Helper()
|
||||
xml := "<LifecycleConfiguration>" +
|
||||
"<Rule><ID>access</ID><Status>Enabled</Status>" +
|
||||
"<AccessTransition><Window>10m</Window><PromoteAfterAccesses>100</PromoteAfterAccesses>" +
|
||||
"<DemoteAfterAccesses>5</DemoteAfterAccesses><DemoteAfterIdle>1h</DemoteAfterIdle></AccessTransition>" +
|
||||
"</Rule></LifecycleConfiguration>"
|
||||
lc, err := lifecycle.ParseLifecycleConfig(strings.NewReader(xml))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return lc
|
||||
}
|
||||
|
||||
func TestAccessTierStampAndDemoteEligibility(t *testing.T) {
|
||||
oldTracker := globalAccessTracker
|
||||
globalAccessTracker = newAccessTracker()
|
||||
t.Cleanup(func() { globalAccessTracker = oldTracker })
|
||||
|
||||
now := time.Now()
|
||||
cfg := ilm.Config{
|
||||
AccessTiering: true, AccessPools: []int{0, 1},
|
||||
AccessBinWidth: time.Minute, AccessBins: 12,
|
||||
AccessMinResidency: 30 * time.Minute,
|
||||
}
|
||||
oi := ObjectInfo{
|
||||
Bucket: "bucket", Name: "object", Size: 10, IsLatest: true,
|
||||
UserDefined: map[string]string{
|
||||
accessTierMetadataKey: "0:" + strconv.FormatInt(now.Add(-2*time.Hour).UnixNano(), 10),
|
||||
},
|
||||
}
|
||||
if !accessDemoteEligible(accessLifecycleForTest(t), oi, 0, cfg, now) {
|
||||
t.Fatal("cold, resident object should be demotion eligible")
|
||||
}
|
||||
oi.UserDefined[accessTierMetadataKey] = "0:" + strconv.FormatInt(now.Add(-time.Minute).UnixNano(), 10)
|
||||
if accessDemoteEligible(accessLifecycleForTest(t), oi, 0, cfg, now) {
|
||||
t.Fatal("object inside minimum residency was eligible")
|
||||
}
|
||||
oi.UserDefined[accessTierMetadataKey] = "broken"
|
||||
if accessDemoteEligible(accessLifecycleForTest(t), oi, 0, cfg, now) {
|
||||
t.Fatal("object with malformed marker was eligible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTierReservationsEnforceCaps(t *testing.T) {
|
||||
state := newAccessTierState(t.Context())
|
||||
state.usageReady = true
|
||||
state.baseUsage["a"] = 80
|
||||
state.baseTotal = 180
|
||||
|
||||
cfg := ilm.Config{AccessMaxSize: 200}
|
||||
task := accessTierTask{ctx: t.Context(), bucket: "a", object: "one", bytes: 30}
|
||||
if reason, ok := state.reservePromotion(task, cfg, 0); ok || reason != "max-size" {
|
||||
t.Fatalf("max-size reserve = %q/%v", reason, ok)
|
||||
}
|
||||
|
||||
cfg.AccessMaxSize = 0
|
||||
if reason, ok := state.reservePromotion(task, cfg, 100); ok || reason != "quota" {
|
||||
t.Fatalf("quota reserve = %q/%v", reason, ok)
|
||||
}
|
||||
|
||||
task.bytes = 20
|
||||
if reason, ok := state.reservePromotion(task, cfg, 100); !ok || reason != "" {
|
||||
t.Fatalf("valid reserve = %q/%v", reason, ok)
|
||||
}
|
||||
if got := state.bucketUsageLocked("a"); got != 100 {
|
||||
t.Fatalf("reserved bucket usage = %d, want 100", got)
|
||||
}
|
||||
state.releasePending(task, true)
|
||||
}
|
||||
|
||||
func TestDataMovementDestinationIsGated(t *testing.T) {
|
||||
dst := 0
|
||||
if got := dataMovementDstPool(ObjectOptions{DstPoolIdx: &dst}); got != nil {
|
||||
t.Fatal("destination honored without DataMovement")
|
||||
}
|
||||
if got := dataMovementDstPool(ObjectOptions{DataMovement: true, DstPoolIdx: &dst}); got == nil || *got != 0 {
|
||||
t.Fatalf("destination = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcedDestinationPoolSelection(t *testing.T) {
|
||||
z := &erasureServerPools{serverPools: make([]*erasureSets, 2)}
|
||||
dst := 1
|
||||
if got, err := z.getPoolIdx(context.Background(), "bucket", "object", 1, &dst); err != nil || got != dst {
|
||||
t.Fatalf("destination = %d, err = %v", got, err)
|
||||
}
|
||||
bad := 2
|
||||
if _, err := z.getPoolIdx(context.Background(), "bucket", "object", 1, &bad); !errors.Is(err, errInvalidArgument) {
|
||||
t.Fatalf("out-of-range error = %v", err)
|
||||
}
|
||||
z.poolMeta.Pools = make([]PoolStatus, 2)
|
||||
z.poolMeta.Pools[1].Decommission = &PoolDecommissionInfo{}
|
||||
if _, err := z.getPoolIdx(context.Background(), "bucket", "object", 1, &dst); err == nil {
|
||||
t.Fatal("suspended destination was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotTierAccounting(t *testing.T) {
|
||||
entry := dataUsageEntry{}
|
||||
entry.addSizes(sizeSummary{totalSize: 100, hotTierSize: 60, versions: 1})
|
||||
entry.merge(dataUsageEntry{Size: 50, HotTierSize: 25})
|
||||
if entry.Size != 150 || entry.HotTierSize != 85 {
|
||||
t.Fatalf("usage = size:%d hot:%d", entry.Size, entry.HotTierSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScannerCyclesApart(t *testing.T) {
|
||||
tests := []struct {
|
||||
current, previous uint32
|
||||
want uint32
|
||||
}{
|
||||
{current: 12, previous: 10, want: 2},
|
||||
{current: 0, previous: ^uint32(0), want: 1},
|
||||
// A cycle counter reset is not evidence that a complete pass covered
|
||||
// recent moves, so it must not release conservative deltas.
|
||||
{current: 1, previous: 100, want: 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := scannerCyclesApart(tt.current, tt.previous); got != tt.want {
|
||||
t.Fatalf("scannerCyclesApart(%d, %d) = %d, want %d", tt.current, tt.previous, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataUsageCacheV8Migration(t *testing.T) {
|
||||
var encoded bytes.Buffer
|
||||
if err := encoded.WriteByte(dataUsageCacheVerV8); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw, err := zstd.NewWriter(&encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mw := msgp.NewWriter(zw)
|
||||
if err = mw.WriteMapHeader(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mw.WriteString("Info"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := dataUsageCacheInfo{Name: dataUsageRoot, NextCycle: 17, LastUpdate: time.Now().UTC()}
|
||||
if err = info.EncodeMsg(mw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mw.WriteString("Cache"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mw.WriteMapHeader(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mw.WriteString("entry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A v8 entry has no hts field. Encoding only populated fields also
|
||||
// verifies that its map decoder retains normal msgpack compatibility.
|
||||
if err = mw.WriteMapHeader(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mw.WriteString("sz"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mw.WriteInt64(123); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mw.WriteString("os"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mw.WriteUint64(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mw.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var migrated dataUsageCache
|
||||
if err = migrated.deserialize(bytes.NewReader(encoded.Bytes())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := migrated.Cache["entry"]
|
||||
if entry.Size != 123 || entry.Objects != 2 || entry.HotTierSize != 0 {
|
||||
t.Fatalf("migrated entry = size:%d objects:%d hot:%d", entry.Size, entry.Objects, entry.HotTierSize)
|
||||
}
|
||||
if migrated.Info.NextCycle != 17 || !migrated.Info.LastUpdate.Equal(info.LastUpdate) {
|
||||
t.Fatalf("migrated cache info = %+v", migrated.Info)
|
||||
}
|
||||
}
|
||||
|
||||
// The stamp written on every moved version and the stamp the rollback path
|
||||
// matches against must agree, otherwise rollback silently skips its own work.
|
||||
func TestAccessTierStampRoundTrip(t *testing.T) {
|
||||
movedAt := time.Now().UnixNano()
|
||||
stamp := accessTierStamp(3, movedAt)
|
||||
|
||||
pool, at, ok := parseAccessTierStamp(map[string]string{accessTierMetadataKey: stamp})
|
||||
if !ok {
|
||||
t.Fatalf("stamp %q did not parse", stamp)
|
||||
}
|
||||
if pool != 3 {
|
||||
t.Fatalf("pool = %d, want 3", pool)
|
||||
}
|
||||
if at.UnixNano() != movedAt {
|
||||
t.Fatalf("movedAt = %d, want %d", at.UnixNano(), movedAt)
|
||||
}
|
||||
// A different move of the same object must not match, so a concurrent
|
||||
// client overwrite is never mistaken for our own copy.
|
||||
if stamp == accessTierStamp(3, movedAt+1) {
|
||||
t.Fatal("stamps from distinct moves collided")
|
||||
}
|
||||
if stamp == accessTierStamp(4, movedAt) {
|
||||
t.Fatal("stamps from distinct pools collided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessHitsMightPromote(t *testing.T) {
|
||||
lc := accessLifecycleForTest(t)
|
||||
hot := accessEntry{Bins: []uint32{100}, HeadAt: 0}
|
||||
cold := accessEntry{Bins: []uint32{1}, HeadAt: 0}
|
||||
if !accessHitsMightPromote(lc, "object", hot, 60) {
|
||||
t.Fatal("object above promote threshold was rejected")
|
||||
}
|
||||
if accessHitsMightPromote(lc, "object", cold, 60) {
|
||||
t.Fatal("object below promote threshold was accepted")
|
||||
}
|
||||
|
||||
xml := "<LifecycleConfiguration><Rule><ID>logs</ID><Status>Enabled</Status>" +
|
||||
"<Filter><Prefix>logs/</Prefix></Filter>" +
|
||||
"<AccessTransition><Window>10m</Window><PromoteAfterAccesses>100</PromoteAfterAccesses>" +
|
||||
"<DemoteAfterAccesses>5</DemoteAfterAccesses><DemoteAfterIdle>1h</DemoteAfterIdle></AccessTransition>" +
|
||||
"</Rule></LifecycleConfiguration>"
|
||||
prefixed, err := lifecycle.ParseLifecycleConfig(strings.NewReader(xml))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accessHitsMightPromote(prefixed, "data/object", hot, 60) {
|
||||
t.Fatal("object outside the rule prefix was accepted")
|
||||
}
|
||||
if !accessHitsMightPromote(prefixed, "logs/object", hot, 60) {
|
||||
t.Fatal("object inside the rule prefix was rejected")
|
||||
}
|
||||
}
|
||||
@@ -1,598 +0,0 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/config/ilm"
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
//go:generate msgp -file=$GOFILE -unexported
|
||||
//msgp:ignore accessTracker mergedAccess
|
||||
|
||||
const (
|
||||
// accessTrackerPrefix is where each node publishes its own counters.
|
||||
// One object per node, merged by every node on a timer.
|
||||
accessTrackerPrefix = minioConfigPrefix + "/ilm/access"
|
||||
|
||||
// accessQueueSize bounds the GET -> tracker handoff. Overflow drops
|
||||
// samples rather than slowing down reads.
|
||||
accessQueueSize = 100000
|
||||
|
||||
// accessMaxDemoteCandidates bounds what the scanner may hand over in a
|
||||
// single flush interval.
|
||||
accessMaxDemoteCandidates = 100000
|
||||
|
||||
// accessShardStaleFactor multiplies the flush interval to decide when a
|
||||
// peer's counters are too old to trust, e.g. after a node is removed.
|
||||
accessShardStaleFactor = 5
|
||||
)
|
||||
|
||||
func accessShardFresh(now int64, shard accessShard, stale int64) bool {
|
||||
if shard.UpdatedAt <= 0 {
|
||||
return false
|
||||
}
|
||||
if stale <= 0 {
|
||||
return true
|
||||
}
|
||||
age := now - shard.UpdatedAt
|
||||
return age >= -stale && age <= stale
|
||||
}
|
||||
|
||||
// accessEntry is a rolling hit counter for one object. Bins[0] is the current
|
||||
// bin and each subsequent bin is one bin-width older, so a rule asking for
|
||||
// "100 hits in 10 minutes" sums the newest ceil(10m/binWidth) bins.
|
||||
//
|
||||
// A fixed window is used rather than an exponentially decayed score because
|
||||
// the rule is stated to operators in exactly those terms.
|
||||
type accessEntry struct {
|
||||
Bins []uint32 `msg:"b"`
|
||||
HeadAt int64 `msg:"h"` // unix seconds at the start of Bins[0]
|
||||
LastAt int64 `msg:"l"` // unix seconds of the most recent hit
|
||||
}
|
||||
|
||||
// demoteCandidate is an object the scanner found sitting on a fast pool with
|
||||
// no recent reads. Candidates ride the node's own counter shard so they reach
|
||||
// the leader without a new peer RPC.
|
||||
type demoteCandidate struct {
|
||||
Bucket string `msg:"b"`
|
||||
Object string `msg:"o"`
|
||||
Pool int `msg:"p"`
|
||||
}
|
||||
|
||||
// accessShard is what one node publishes. BinWidth is carried so a peer that
|
||||
// has not yet picked up a configuration change is ignored rather than merged
|
||||
// with mismatched bins.
|
||||
type accessShard struct {
|
||||
UpdatedAt int64 `msg:"u"`
|
||||
BinWidth int64 `msg:"bw"`
|
||||
Entries map[string]accessEntry `msg:"e"`
|
||||
Demote []demoteCandidate `msg:"d"`
|
||||
}
|
||||
|
||||
// binStart truncates a unix timestamp to the start of its bin.
|
||||
func binStart(now, binWidth int64) int64 {
|
||||
if binWidth <= 0 {
|
||||
return now
|
||||
}
|
||||
return now - now%binWidth
|
||||
}
|
||||
|
||||
// rollTo advances the counter to now, zeroing the bins that elapsed since the
|
||||
// last update and resizing if the configured bin count changed.
|
||||
func (e *accessEntry) rollTo(now, binWidth int64, nbins int) {
|
||||
if nbins <= 0 || binWidth <= 0 {
|
||||
return
|
||||
}
|
||||
if len(e.Bins) != nbins {
|
||||
resized := make([]uint32, nbins)
|
||||
copy(resized, e.Bins)
|
||||
e.Bins = resized
|
||||
}
|
||||
head := binStart(now, binWidth)
|
||||
if e.HeadAt == 0 {
|
||||
e.HeadAt = head
|
||||
return
|
||||
}
|
||||
steps := (head - e.HeadAt) / binWidth
|
||||
if steps <= 0 {
|
||||
return
|
||||
}
|
||||
if steps >= int64(nbins) {
|
||||
clear(e.Bins)
|
||||
} else {
|
||||
copy(e.Bins[steps:], e.Bins[:nbins-int(steps)])
|
||||
clear(e.Bins[:steps])
|
||||
}
|
||||
e.HeadAt = head
|
||||
}
|
||||
|
||||
// hits returns the number of accesses recorded over the newest bins covering
|
||||
// window. A window longer than the configured history is clamped to it.
|
||||
//
|
||||
// The newest bin is partial, so the covered span is between window-binWidth
|
||||
// and window. Operators tune resolution with ilm access_bin_width.
|
||||
func (e accessEntry) hits(window time.Duration, binWidth int64) uint64 {
|
||||
if binWidth <= 0 || len(e.Bins) == 0 {
|
||||
return 0
|
||||
}
|
||||
n := int((int64(window/time.Second) + binWidth - 1) / binWidth)
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > len(e.Bins) {
|
||||
n = len(e.Bins)
|
||||
}
|
||||
var total uint64
|
||||
for _, v := range e.Bins[:n] {
|
||||
total += uint64(v)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// total is the whole retained history, used to decide what to evict.
|
||||
func (e accessEntry) total() uint64 {
|
||||
var t uint64
|
||||
for _, v := range e.Bins {
|
||||
t += uint64(v)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// mergeFrom adds another node's counters for the same object. Both sides must
|
||||
// already be rolled to the same head.
|
||||
func (e *accessEntry) mergeFrom(o accessEntry) {
|
||||
for i := range e.Bins {
|
||||
if i < len(o.Bins) {
|
||||
total := uint64(e.Bins[i]) + uint64(o.Bins[i])
|
||||
if total > uint64(^uint32(0)) {
|
||||
total = uint64(^uint32(0))
|
||||
}
|
||||
e.Bins[i] = uint32(total)
|
||||
}
|
||||
}
|
||||
if o.LastAt > e.LastAt {
|
||||
e.LastAt = o.LastAt
|
||||
}
|
||||
}
|
||||
|
||||
// mergedAccess is an immutable cluster-wide snapshot. Readers take it from an
|
||||
// atomic pointer, so the hot scanner and sweep paths never take a lock.
|
||||
type mergedAccess struct {
|
||||
entries map[string]accessEntry
|
||||
binWidth int64
|
||||
at int64
|
||||
}
|
||||
|
||||
func (m *mergedAccess) hits(key string, window time.Duration) uint64 {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
e, ok := m.entries[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return e.hits(window, m.binWidth)
|
||||
}
|
||||
|
||||
func (m *mergedAccess) lastAccess(key string) int64 {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
return m.entries[key].LastAt
|
||||
}
|
||||
|
||||
// accessTracker records how often each object is read.
|
||||
//
|
||||
// Ownership is deliberately narrow: the live counter map is touched only by
|
||||
// run()'s goroutine, so it needs no lock. Everything read from elsewhere goes
|
||||
// through the immutable merged snapshot.
|
||||
type accessTracker struct {
|
||||
ch chan string
|
||||
enabled atomic.Bool
|
||||
merged atomic.Pointer[mergedAccess]
|
||||
|
||||
// Demote candidates arrive from scanner goroutines, so this one does
|
||||
// need a lock. It is small: only objects we previously promoted.
|
||||
demoteMu sync.Mutex
|
||||
demote map[string]demoteCandidate
|
||||
|
||||
dropped atomic.Uint64
|
||||
}
|
||||
|
||||
var globalAccessTracker = newAccessTracker()
|
||||
|
||||
func newAccessTracker() *accessTracker {
|
||||
return &accessTracker{
|
||||
ch: make(chan string, accessQueueSize),
|
||||
demote: make(map[string]demoteCandidate),
|
||||
}
|
||||
}
|
||||
|
||||
// accessKey is the tracker's map key. Bucket names cannot contain '/', so the
|
||||
// join is unambiguous.
|
||||
func accessKey(bucket, object string) string {
|
||||
return bucket + "/" + object
|
||||
}
|
||||
|
||||
func splitAccessKey(key string) (bucket, object string, ok bool) {
|
||||
bucket, object, ok = strings.Cut(key, "/")
|
||||
if !ok || bucket == "" || object == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return bucket, object, true
|
||||
}
|
||||
|
||||
// note records one read. It is called from the GET path and must never block
|
||||
// or allocate meaningfully: on a full queue the sample is dropped.
|
||||
func (t *accessTracker) note(bucket, object string) {
|
||||
if t == nil || !t.enabled.Load() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case t.ch <- accessKey(bucket, object):
|
||||
default:
|
||||
t.dropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// noteDemoteCandidate is called by the scanner for an object it found on a
|
||||
// fast pool that has gone quiet. The leader picks these up on the next merge.
|
||||
func (t *accessTracker) noteDemoteCandidate(bucket, object string, pool int) {
|
||||
if t == nil || !t.enabled.Load() {
|
||||
return
|
||||
}
|
||||
t.demoteMu.Lock()
|
||||
defer t.demoteMu.Unlock()
|
||||
if len(t.demote) >= accessMaxDemoteCandidates {
|
||||
return
|
||||
}
|
||||
t.demote[accessKey(bucket, object)] = demoteCandidate{Bucket: bucket, Object: object, Pool: pool}
|
||||
}
|
||||
|
||||
// hits reports cluster-wide accesses to an object over window.
|
||||
func (t *accessTracker) hits(bucket, object string, window time.Duration) uint64 {
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
return t.merged.Load().hits(accessKey(bucket, object), window)
|
||||
}
|
||||
|
||||
// lastAccess reports the cluster-wide time an object was last read. A zero
|
||||
// time means "no read on record", which for demotion purposes is idle.
|
||||
func (t *accessTracker) lastAccess(bucket, object string) time.Time {
|
||||
if t == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
sec := t.merged.Load().lastAccess(accessKey(bucket, object))
|
||||
if sec == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(sec, 0)
|
||||
}
|
||||
|
||||
// snapshot returns the current merged view, or nil if none has been published.
|
||||
func (t *accessTracker) snapshot() *mergedAccess {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.merged.Load()
|
||||
}
|
||||
|
||||
// takeDemoteCandidates drains and returns the pending candidates.
|
||||
func (t *accessTracker) takeDemoteCandidates() []demoteCandidate {
|
||||
t.demoteMu.Lock()
|
||||
defer t.demoteMu.Unlock()
|
||||
if len(t.demote) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]demoteCandidate, 0, len(t.demote))
|
||||
for _, c := range t.demote {
|
||||
out = append(out, c)
|
||||
}
|
||||
t.demote = make(map[string]demoteCandidate)
|
||||
return out
|
||||
}
|
||||
|
||||
// restoreDemoteCandidates puts candidates back when the publisher is still
|
||||
// busy. Dropping access samples is acceptable; dropping the only scanner
|
||||
// discovery of an idle promoted object would delay demotion by a full scan.
|
||||
func (t *accessTracker) restoreDemoteCandidates(candidates []demoteCandidate) {
|
||||
if len(candidates) == 0 {
|
||||
return
|
||||
}
|
||||
t.demoteMu.Lock()
|
||||
defer t.demoteMu.Unlock()
|
||||
for _, c := range candidates {
|
||||
if len(t.demote) >= accessMaxDemoteCandidates {
|
||||
return
|
||||
}
|
||||
t.demote[accessKey(c.Bucket, c.Object)] = c
|
||||
}
|
||||
}
|
||||
|
||||
// shardName is this node's counter object. The node name is hashed so that
|
||||
// host:port never has to be escaped into an object key.
|
||||
func (t *accessTracker) shardName() string {
|
||||
return fmt.Sprintf("%s/%016x.bin", accessTrackerPrefix, xxh3.HashString(globalLocalNodeName))
|
||||
}
|
||||
|
||||
// run owns the live counter map. It drains reads, and on every flush interval
|
||||
// hands a marshaled shard to a background publisher.
|
||||
//
|
||||
// Everything here is best effort: this is accounting for a background data
|
||||
// movement decision, not a durability path.
|
||||
func (t *accessTracker) run(ctx context.Context, objAPI ObjectLayer) {
|
||||
cfg := globalILMConfig.accessCfg()
|
||||
t.enabled.Store(cfg.AccessTiering)
|
||||
|
||||
live := make(map[string]accessEntry)
|
||||
if cfg.AccessTiering {
|
||||
if buf, err := readConfig(ctx, objAPI, t.shardName()); err == nil {
|
||||
var previous accessShard
|
||||
if _, err = previous.UnmarshalMsg(buf); err == nil && previous.BinWidth == int64(cfg.AccessBinWidth/time.Second) {
|
||||
now := time.Now().Unix()
|
||||
for key, entry := range previous.Entries {
|
||||
entry.rollTo(now, previous.BinWidth, cfg.AccessBins)
|
||||
if entry.total() != 0 {
|
||||
live[key] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ticker := time.NewTicker(cfg.AccessFlush)
|
||||
defer ticker.Stop()
|
||||
|
||||
// One publisher goroutine keeps object-layer I/O off the drain loop.
|
||||
type pub struct {
|
||||
shard accessShard
|
||||
cfg ilm.Config
|
||||
}
|
||||
pubCh := make(chan pub, 1)
|
||||
go func() {
|
||||
for p := range pubCh {
|
||||
t.publish(ctx, objAPI, p.shard, p.cfg)
|
||||
}
|
||||
}()
|
||||
defer close(pubCh)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case key := <-t.ch:
|
||||
now := time.Now().Unix()
|
||||
e := live[key]
|
||||
e.rollTo(now, int64(cfg.AccessBinWidth/time.Second), cfg.AccessBins)
|
||||
if len(e.Bins) > 0 {
|
||||
if e.Bins[0] != ^uint32(0) {
|
||||
e.Bins[0]++
|
||||
}
|
||||
}
|
||||
e.LastAt = now
|
||||
live[key] = e
|
||||
|
||||
case <-ticker.C:
|
||||
newCfg := globalILMConfig.accessCfg()
|
||||
t.enabled.Store(newCfg.AccessTiering)
|
||||
if newCfg.AccessFlush != cfg.AccessFlush && newCfg.AccessFlush > 0 {
|
||||
ticker.Reset(newCfg.AccessFlush)
|
||||
}
|
||||
cfg = newCfg
|
||||
if !cfg.AccessTiering {
|
||||
// Feature turned off: release the counters rather than
|
||||
// holding a stale working set for the process lifetime.
|
||||
clear(live)
|
||||
t.merged.Store(nil)
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
binWidth := int64(cfg.AccessBinWidth / time.Second)
|
||||
t.evict(live, now, binWidth, cfg)
|
||||
|
||||
shard := accessShard{
|
||||
UpdatedAt: now,
|
||||
BinWidth: binWidth,
|
||||
Entries: make(map[string]accessEntry, len(live)),
|
||||
Demote: t.takeDemoteCandidates(),
|
||||
}
|
||||
for k, e := range live {
|
||||
e.Bins = slices.Clone(e.Bins)
|
||||
shard.Entries[k] = e
|
||||
}
|
||||
select {
|
||||
case pubCh <- pub{shard: shard, cfg: cfg}:
|
||||
default:
|
||||
// Previous publish still running; skip this round
|
||||
// rather than queueing work we cannot keep up with.
|
||||
t.restoreDemoteCandidates(shard.Demote)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evict rolls every counter forward and drops the ones with no hits left in
|
||||
// the retained history, then enforces the tracked-object cap.
|
||||
//
|
||||
// ponytail: amortized O(n) sweep on the flush tick; a heap would only pay off
|
||||
// past ~10M tracked keys.
|
||||
func (t *accessTracker) evict(live map[string]accessEntry, now, binWidth int64, cfg ilm.Config) {
|
||||
for k, e := range live {
|
||||
e.rollTo(now, binWidth, cfg.AccessBins)
|
||||
if e.total() == 0 {
|
||||
delete(live, k)
|
||||
continue
|
||||
}
|
||||
live[k] = e
|
||||
}
|
||||
if len(live) <= cfg.AccessMaxTracked {
|
||||
return
|
||||
}
|
||||
// Over the cap: keep the hottest and drop the rest. Objects that fall
|
||||
// out are cold by construction, which is exactly what the demotion path
|
||||
// already assumes about anything missing from the map.
|
||||
type kt struct {
|
||||
key string
|
||||
total uint64
|
||||
}
|
||||
all := make([]kt, 0, len(live))
|
||||
for k, e := range live {
|
||||
all = append(all, kt{k, e.total()})
|
||||
}
|
||||
slices.SortFunc(all, func(a, b kt) int { return cmp.Compare(b.total, a.total) })
|
||||
for _, x := range all[cfg.AccessMaxTracked:] {
|
||||
delete(live, x.key)
|
||||
}
|
||||
}
|
||||
|
||||
// publish writes this node's shard and rebuilds the merged snapshot from every
|
||||
// node's shard. Both halves are best effort.
|
||||
func (t *accessTracker) publish(ctx context.Context, objAPI ObjectLayer, shard accessShard, cfg ilm.Config) {
|
||||
buf, err := shard.MarshalMsg(nil)
|
||||
if err != nil {
|
||||
ilmLogIf(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := saveConfig(ctx, objAPI, t.shardName(), buf); err != nil {
|
||||
ilmLogIf(ctx, err)
|
||||
// Still rebuild the snapshot below: our own counters are already
|
||||
// in hand and a stale peer view beats no view.
|
||||
}
|
||||
t.merged.Store(t.mergeShards(ctx, objAPI, shard, cfg))
|
||||
}
|
||||
|
||||
// mergeShards sums every live node's counters, including our own in-memory
|
||||
// shard so this node's most recent reads are never a flush behind.
|
||||
func (t *accessTracker) mergeShards(ctx context.Context, objAPI ObjectLayer, own accessShard, cfg ilm.Config) *mergedAccess {
|
||||
now := time.Now().Unix()
|
||||
binWidth := int64(cfg.AccessBinWidth / time.Second)
|
||||
out := &mergedAccess{
|
||||
entries: make(map[string]accessEntry, len(own.Entries)),
|
||||
binWidth: binWidth,
|
||||
at: now,
|
||||
}
|
||||
|
||||
add := func(s accessShard) {
|
||||
if s.BinWidth != binWidth {
|
||||
// A peer has not yet picked up a bin-width change; merging
|
||||
// its bins would silently mis-scale the counts.
|
||||
return
|
||||
}
|
||||
for k, e := range s.Entries {
|
||||
e.rollTo(now, binWidth, cfg.AccessBins)
|
||||
cur, ok := out.entries[k]
|
||||
if !ok {
|
||||
cur = accessEntry{Bins: make([]uint32, cfg.AccessBins)}
|
||||
}
|
||||
cur.mergeFrom(e)
|
||||
out.entries[k] = cur
|
||||
}
|
||||
}
|
||||
|
||||
add(own)
|
||||
|
||||
ownName := t.shardName()
|
||||
stale := int64(cfg.AccessFlush/time.Second) * accessShardStaleFactor
|
||||
for _, name := range t.listShards(ctx, objAPI) {
|
||||
if name == ownName {
|
||||
continue
|
||||
}
|
||||
buf, err := readConfig(ctx, objAPI, name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var s accessShard
|
||||
if _, err := s.UnmarshalMsg(buf); err != nil {
|
||||
continue
|
||||
}
|
||||
if !accessShardFresh(now, s, stale) {
|
||||
continue // node is gone or wedged
|
||||
}
|
||||
add(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *accessTracker) listShards(ctx context.Context, objAPI ObjectLayer) []string {
|
||||
res, err := objAPI.ListObjects(ctx, minioMetaBucket, accessTrackerPrefix+"/", "", "", maxObjectList)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(res.Objects))
|
||||
for _, o := range res.Objects {
|
||||
if strings.HasSuffix(o.Name, ".bin") {
|
||||
names = append(names, o.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// collectDemoteCandidates returns every node's pending demote candidates. Only
|
||||
// the leader calls this, right before running a demotion pass.
|
||||
//
|
||||
// Our own shard is read back rather than skipped: run() drains the local map
|
||||
// into the published shard, so anything found since the last leader pass lives
|
||||
// there, not in memory. The local map is still drained here to pick up
|
||||
// candidates recorded since that publish.
|
||||
func (t *accessTracker) collectDemoteCandidates(ctx context.Context, objAPI ObjectLayer, cfg ilm.Config) []demoteCandidate {
|
||||
seen := make(map[string]struct{})
|
||||
var out []demoteCandidate
|
||||
|
||||
for _, c := range t.takeDemoteCandidates() {
|
||||
key := accessKey(c.Bucket, c.Object)
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
stale := int64(cfg.AccessFlush/time.Second) * accessShardStaleFactor
|
||||
for _, name := range t.listShards(ctx, objAPI) {
|
||||
buf, err := readConfig(ctx, objAPI, name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var s accessShard
|
||||
if _, err := s.UnmarshalMsg(buf); err != nil {
|
||||
continue
|
||||
}
|
||||
if !accessShardFresh(now, s, stale) {
|
||||
continue
|
||||
}
|
||||
for _, c := range s.Demote {
|
||||
key := accessKey(c.Bucket, c.Object)
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,742 +0,0 @@
|
||||
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/tinylib/msgp/msgp"
|
||||
)
|
||||
|
||||
// DecodeMsg implements msgp.Decodable
|
||||
func (z *accessEntry) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, err = dc.ReadMapKeyPtr()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "b":
|
||||
var zb0002 uint32
|
||||
zb0002, err = dc.ReadArrayHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Bins")
|
||||
return
|
||||
}
|
||||
if cap(z.Bins) >= int(zb0002) {
|
||||
z.Bins = (z.Bins)[:zb0002]
|
||||
} else {
|
||||
z.Bins = make([]uint32, zb0002)
|
||||
}
|
||||
for za0001 := range z.Bins {
|
||||
z.Bins[za0001], err = dc.ReadUint32()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Bins", za0001)
|
||||
return
|
||||
}
|
||||
}
|
||||
case "h":
|
||||
z.HeadAt, err = dc.ReadInt64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "HeadAt")
|
||||
return
|
||||
}
|
||||
case "l":
|
||||
z.LastAt, err = dc.ReadInt64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "LastAt")
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// EncodeMsg implements msgp.Encodable
|
||||
func (z *accessEntry) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
// map header, size 3
|
||||
// write "b"
|
||||
err = en.Append(0x83, 0xa1, 0x62)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteArrayHeader(uint32(len(z.Bins)))
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Bins")
|
||||
return
|
||||
}
|
||||
for za0001 := range z.Bins {
|
||||
err = en.WriteUint32(z.Bins[za0001])
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Bins", za0001)
|
||||
return
|
||||
}
|
||||
}
|
||||
// write "h"
|
||||
err = en.Append(0xa1, 0x68)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt64(z.HeadAt)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "HeadAt")
|
||||
return
|
||||
}
|
||||
// write "l"
|
||||
err = en.Append(0xa1, 0x6c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt64(z.LastAt)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "LastAt")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// MarshalMsg implements msgp.Marshaler
|
||||
func (z *accessEntry) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// map header, size 3
|
||||
// string "b"
|
||||
o = append(o, 0x83, 0xa1, 0x62)
|
||||
o = msgp.AppendArrayHeader(o, uint32(len(z.Bins)))
|
||||
for za0001 := range z.Bins {
|
||||
o = msgp.AppendUint32(o, z.Bins[za0001])
|
||||
}
|
||||
// string "h"
|
||||
o = append(o, 0xa1, 0x68)
|
||||
o = msgp.AppendInt64(o, z.HeadAt)
|
||||
// string "l"
|
||||
o = append(o, 0xa1, 0x6c)
|
||||
o = msgp.AppendInt64(o, z.LastAt)
|
||||
return
|
||||
}
|
||||
|
||||
// UnmarshalMsg implements msgp.Unmarshaler
|
||||
func (z *accessEntry) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, bts, err = msgp.ReadMapKeyZC(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "b":
|
||||
var zb0002 uint32
|
||||
zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Bins")
|
||||
return
|
||||
}
|
||||
if cap(z.Bins) >= int(zb0002) {
|
||||
z.Bins = (z.Bins)[:zb0002]
|
||||
} else {
|
||||
z.Bins = make([]uint32, zb0002)
|
||||
}
|
||||
for za0001 := range z.Bins {
|
||||
z.Bins[za0001], bts, err = msgp.ReadUint32Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Bins", za0001)
|
||||
return
|
||||
}
|
||||
}
|
||||
case "h":
|
||||
z.HeadAt, bts, err = msgp.ReadInt64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "HeadAt")
|
||||
return
|
||||
}
|
||||
case "l":
|
||||
z.LastAt, bts, err = msgp.ReadInt64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "LastAt")
|
||||
return
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
o = bts
|
||||
return
|
||||
}
|
||||
|
||||
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
|
||||
func (z *accessEntry) Msgsize() (s int) {
|
||||
s = 1 + 2 + msgp.ArrayHeaderSize + (len(z.Bins) * (msgp.Uint32Size)) + 2 + msgp.Int64Size + 2 + msgp.Int64Size
|
||||
return
|
||||
}
|
||||
|
||||
// DecodeMsg implements msgp.Decodable
|
||||
func (z *accessShard) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, err = dc.ReadMapKeyPtr()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "u":
|
||||
z.UpdatedAt, err = dc.ReadInt64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "UpdatedAt")
|
||||
return
|
||||
}
|
||||
case "bw":
|
||||
z.BinWidth, err = dc.ReadInt64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "BinWidth")
|
||||
return
|
||||
}
|
||||
case "e":
|
||||
var zb0002 uint32
|
||||
zb0002, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Entries")
|
||||
return
|
||||
}
|
||||
if z.Entries == nil {
|
||||
z.Entries = make(map[string]accessEntry, zb0002)
|
||||
} else if len(z.Entries) > 0 {
|
||||
clear(z.Entries)
|
||||
}
|
||||
for zb0002 > 0 {
|
||||
zb0002--
|
||||
var za0001 string
|
||||
za0001, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Entries")
|
||||
return
|
||||
}
|
||||
var za0002 accessEntry
|
||||
err = za0002.DecodeMsg(dc)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Entries", za0001)
|
||||
return
|
||||
}
|
||||
z.Entries[za0001] = za0002
|
||||
}
|
||||
case "d":
|
||||
var zb0003 uint32
|
||||
zb0003, err = dc.ReadArrayHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote")
|
||||
return
|
||||
}
|
||||
if cap(z.Demote) >= int(zb0003) {
|
||||
z.Demote = (z.Demote)[:zb0003]
|
||||
} else {
|
||||
z.Demote = make([]demoteCandidate, zb0003)
|
||||
}
|
||||
for za0003 := range z.Demote {
|
||||
var zb0004 uint32
|
||||
zb0004, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003)
|
||||
return
|
||||
}
|
||||
for zb0004 > 0 {
|
||||
zb0004--
|
||||
field, err = dc.ReadMapKeyPtr()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "b":
|
||||
z.Demote[za0003].Bucket, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003, "Bucket")
|
||||
return
|
||||
}
|
||||
case "o":
|
||||
z.Demote[za0003].Object, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003, "Object")
|
||||
return
|
||||
}
|
||||
case "p":
|
||||
z.Demote[za0003].Pool, err = dc.ReadInt()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003, "Pool")
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// EncodeMsg implements msgp.Encodable
|
||||
func (z *accessShard) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
// map header, size 4
|
||||
// write "u"
|
||||
err = en.Append(0x84, 0xa1, 0x75)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt64(z.UpdatedAt)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "UpdatedAt")
|
||||
return
|
||||
}
|
||||
// write "bw"
|
||||
err = en.Append(0xa2, 0x62, 0x77)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt64(z.BinWidth)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "BinWidth")
|
||||
return
|
||||
}
|
||||
// write "e"
|
||||
err = en.Append(0xa1, 0x65)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteMapHeader(uint32(len(z.Entries)))
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Entries")
|
||||
return
|
||||
}
|
||||
for za0001, za0002 := range z.Entries {
|
||||
err = en.WriteString(za0001)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Entries")
|
||||
return
|
||||
}
|
||||
err = za0002.EncodeMsg(en)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Entries", za0001)
|
||||
return
|
||||
}
|
||||
}
|
||||
// write "d"
|
||||
err = en.Append(0xa1, 0x64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteArrayHeader(uint32(len(z.Demote)))
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote")
|
||||
return
|
||||
}
|
||||
for za0003 := range z.Demote {
|
||||
// map header, size 3
|
||||
// write "b"
|
||||
err = en.Append(0x83, 0xa1, 0x62)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteString(z.Demote[za0003].Bucket)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003, "Bucket")
|
||||
return
|
||||
}
|
||||
// write "o"
|
||||
err = en.Append(0xa1, 0x6f)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteString(z.Demote[za0003].Object)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003, "Object")
|
||||
return
|
||||
}
|
||||
// write "p"
|
||||
err = en.Append(0xa1, 0x70)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt(z.Demote[za0003].Pool)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003, "Pool")
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// MarshalMsg implements msgp.Marshaler
|
||||
func (z *accessShard) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// map header, size 4
|
||||
// string "u"
|
||||
o = append(o, 0x84, 0xa1, 0x75)
|
||||
o = msgp.AppendInt64(o, z.UpdatedAt)
|
||||
// string "bw"
|
||||
o = append(o, 0xa2, 0x62, 0x77)
|
||||
o = msgp.AppendInt64(o, z.BinWidth)
|
||||
// string "e"
|
||||
o = append(o, 0xa1, 0x65)
|
||||
o = msgp.AppendMapHeader(o, uint32(len(z.Entries)))
|
||||
for za0001, za0002 := range z.Entries {
|
||||
o = msgp.AppendString(o, za0001)
|
||||
o, err = za0002.MarshalMsg(o)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Entries", za0001)
|
||||
return
|
||||
}
|
||||
}
|
||||
// string "d"
|
||||
o = append(o, 0xa1, 0x64)
|
||||
o = msgp.AppendArrayHeader(o, uint32(len(z.Demote)))
|
||||
for za0003 := range z.Demote {
|
||||
// map header, size 3
|
||||
// string "b"
|
||||
o = append(o, 0x83, 0xa1, 0x62)
|
||||
o = msgp.AppendString(o, z.Demote[za0003].Bucket)
|
||||
// string "o"
|
||||
o = append(o, 0xa1, 0x6f)
|
||||
o = msgp.AppendString(o, z.Demote[za0003].Object)
|
||||
// string "p"
|
||||
o = append(o, 0xa1, 0x70)
|
||||
o = msgp.AppendInt(o, z.Demote[za0003].Pool)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// UnmarshalMsg implements msgp.Unmarshaler
|
||||
func (z *accessShard) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, bts, err = msgp.ReadMapKeyZC(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "u":
|
||||
z.UpdatedAt, bts, err = msgp.ReadInt64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "UpdatedAt")
|
||||
return
|
||||
}
|
||||
case "bw":
|
||||
z.BinWidth, bts, err = msgp.ReadInt64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "BinWidth")
|
||||
return
|
||||
}
|
||||
case "e":
|
||||
var zb0002 uint32
|
||||
zb0002, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Entries")
|
||||
return
|
||||
}
|
||||
if z.Entries == nil {
|
||||
z.Entries = make(map[string]accessEntry, zb0002)
|
||||
} else if len(z.Entries) > 0 {
|
||||
clear(z.Entries)
|
||||
}
|
||||
for zb0002 > 0 {
|
||||
var za0002 accessEntry
|
||||
zb0002--
|
||||
var za0001 string
|
||||
za0001, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Entries")
|
||||
return
|
||||
}
|
||||
bts, err = za0002.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Entries", za0001)
|
||||
return
|
||||
}
|
||||
z.Entries[za0001] = za0002
|
||||
}
|
||||
case "d":
|
||||
var zb0003 uint32
|
||||
zb0003, bts, err = msgp.ReadArrayHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote")
|
||||
return
|
||||
}
|
||||
if cap(z.Demote) >= int(zb0003) {
|
||||
z.Demote = (z.Demote)[:zb0003]
|
||||
} else {
|
||||
z.Demote = make([]demoteCandidate, zb0003)
|
||||
}
|
||||
for za0003 := range z.Demote {
|
||||
var zb0004 uint32
|
||||
zb0004, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003)
|
||||
return
|
||||
}
|
||||
for zb0004 > 0 {
|
||||
zb0004--
|
||||
field, bts, err = msgp.ReadMapKeyZC(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "b":
|
||||
z.Demote[za0003].Bucket, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003, "Bucket")
|
||||
return
|
||||
}
|
||||
case "o":
|
||||
z.Demote[za0003].Object, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003, "Object")
|
||||
return
|
||||
}
|
||||
case "p":
|
||||
z.Demote[za0003].Pool, bts, err = msgp.ReadIntBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003, "Pool")
|
||||
return
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Demote", za0003)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
o = bts
|
||||
return
|
||||
}
|
||||
|
||||
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
|
||||
func (z *accessShard) Msgsize() (s int) {
|
||||
s = 1 + 2 + msgp.Int64Size + 3 + msgp.Int64Size + 2 + msgp.MapHeaderSize
|
||||
if z.Entries != nil {
|
||||
for za0001, za0002 := range z.Entries {
|
||||
_ = za0002
|
||||
s += msgp.StringPrefixSize + len(za0001) + za0002.Msgsize()
|
||||
}
|
||||
}
|
||||
s += 2 + msgp.ArrayHeaderSize
|
||||
for za0003 := range z.Demote {
|
||||
s += 1 + 2 + msgp.StringPrefixSize + len(z.Demote[za0003].Bucket) + 2 + msgp.StringPrefixSize + len(z.Demote[za0003].Object) + 2 + msgp.IntSize
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DecodeMsg implements msgp.Decodable
|
||||
func (z *demoteCandidate) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, err = dc.ReadMapKeyPtr()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "b":
|
||||
z.Bucket, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Bucket")
|
||||
return
|
||||
}
|
||||
case "o":
|
||||
z.Object, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Object")
|
||||
return
|
||||
}
|
||||
case "p":
|
||||
z.Pool, err = dc.ReadInt()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Pool")
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// EncodeMsg implements msgp.Encodable
|
||||
func (z demoteCandidate) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
// map header, size 3
|
||||
// write "b"
|
||||
err = en.Append(0x83, 0xa1, 0x62)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteString(z.Bucket)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Bucket")
|
||||
return
|
||||
}
|
||||
// write "o"
|
||||
err = en.Append(0xa1, 0x6f)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteString(z.Object)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Object")
|
||||
return
|
||||
}
|
||||
// write "p"
|
||||
err = en.Append(0xa1, 0x70)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt(z.Pool)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Pool")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// MarshalMsg implements msgp.Marshaler
|
||||
func (z demoteCandidate) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// map header, size 3
|
||||
// string "b"
|
||||
o = append(o, 0x83, 0xa1, 0x62)
|
||||
o = msgp.AppendString(o, z.Bucket)
|
||||
// string "o"
|
||||
o = append(o, 0xa1, 0x6f)
|
||||
o = msgp.AppendString(o, z.Object)
|
||||
// string "p"
|
||||
o = append(o, 0xa1, 0x70)
|
||||
o = msgp.AppendInt(o, z.Pool)
|
||||
return
|
||||
}
|
||||
|
||||
// UnmarshalMsg implements msgp.Unmarshaler
|
||||
func (z *demoteCandidate) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, bts, err = msgp.ReadMapKeyZC(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "b":
|
||||
z.Bucket, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Bucket")
|
||||
return
|
||||
}
|
||||
case "o":
|
||||
z.Object, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Object")
|
||||
return
|
||||
}
|
||||
case "p":
|
||||
z.Pool, bts, err = msgp.ReadIntBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Pool")
|
||||
return
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
o = bts
|
||||
return
|
||||
}
|
||||
|
||||
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
|
||||
func (z demoteCandidate) Msgsize() (s int) {
|
||||
s = 1 + 2 + msgp.StringPrefixSize + len(z.Bucket) + 2 + msgp.StringPrefixSize + len(z.Object) + 2 + msgp.IntSize
|
||||
return
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/tinylib/msgp/msgp"
|
||||
)
|
||||
|
||||
func TestMarshalUnmarshalaccessEntry(t *testing.T) {
|
||||
v := accessEntry{}
|
||||
bts, err := v.MarshalMsg(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
left, err := v.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) > 0 {
|
||||
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
|
||||
}
|
||||
|
||||
left, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) > 0 {
|
||||
t.Errorf("%d bytes left over after Skip(): %q", len(left), left)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshalMsgaccessEntry(b *testing.B) {
|
||||
v := accessEntry{}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.MarshalMsg(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAppendMsgaccessEntry(b *testing.B) {
|
||||
v := accessEntry{}
|
||||
bts := make([]byte, 0, v.Msgsize())
|
||||
bts, _ = v.MarshalMsg(bts[0:0])
|
||||
b.SetBytes(int64(len(bts)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bts, _ = v.MarshalMsg(bts[0:0])
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshalaccessEntry(b *testing.B) {
|
||||
v := accessEntry{}
|
||||
bts, _ := v.MarshalMsg(nil)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(bts)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := v.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeaccessEntry(t *testing.T) {
|
||||
v := accessEntry{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
|
||||
m := v.Msgsize()
|
||||
if buf.Len() > m {
|
||||
t.Log("WARNING: TestEncodeDecodeaccessEntry Msgsize() is inaccurate")
|
||||
}
|
||||
|
||||
vn := accessEntry{}
|
||||
err := msgp.Decode(&buf, &vn)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
msgp.Encode(&buf, &v)
|
||||
err = msgp.NewReader(&buf).Skip()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEncodeaccessEntry(b *testing.B) {
|
||||
v := accessEntry{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
b.SetBytes(int64(buf.Len()))
|
||||
en := msgp.NewWriter(msgp.Nowhere)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.EncodeMsg(en)
|
||||
}
|
||||
en.Flush()
|
||||
}
|
||||
|
||||
func BenchmarkDecodeaccessEntry(b *testing.B) {
|
||||
v := accessEntry{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
b.SetBytes(int64(buf.Len()))
|
||||
rd := msgp.NewEndlessReader(buf.Bytes(), b)
|
||||
dc := msgp.NewReader(rd)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
err := v.DecodeMsg(dc)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalUnmarshalaccessShard(t *testing.T) {
|
||||
v := accessShard{}
|
||||
bts, err := v.MarshalMsg(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
left, err := v.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) > 0 {
|
||||
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
|
||||
}
|
||||
|
||||
left, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) > 0 {
|
||||
t.Errorf("%d bytes left over after Skip(): %q", len(left), left)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshalMsgaccessShard(b *testing.B) {
|
||||
v := accessShard{}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.MarshalMsg(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAppendMsgaccessShard(b *testing.B) {
|
||||
v := accessShard{}
|
||||
bts := make([]byte, 0, v.Msgsize())
|
||||
bts, _ = v.MarshalMsg(bts[0:0])
|
||||
b.SetBytes(int64(len(bts)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bts, _ = v.MarshalMsg(bts[0:0])
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshalaccessShard(b *testing.B) {
|
||||
v := accessShard{}
|
||||
bts, _ := v.MarshalMsg(nil)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(bts)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := v.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeaccessShard(t *testing.T) {
|
||||
v := accessShard{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
|
||||
m := v.Msgsize()
|
||||
if buf.Len() > m {
|
||||
t.Log("WARNING: TestEncodeDecodeaccessShard Msgsize() is inaccurate")
|
||||
}
|
||||
|
||||
vn := accessShard{}
|
||||
err := msgp.Decode(&buf, &vn)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
msgp.Encode(&buf, &v)
|
||||
err = msgp.NewReader(&buf).Skip()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEncodeaccessShard(b *testing.B) {
|
||||
v := accessShard{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
b.SetBytes(int64(buf.Len()))
|
||||
en := msgp.NewWriter(msgp.Nowhere)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.EncodeMsg(en)
|
||||
}
|
||||
en.Flush()
|
||||
}
|
||||
|
||||
func BenchmarkDecodeaccessShard(b *testing.B) {
|
||||
v := accessShard{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
b.SetBytes(int64(buf.Len()))
|
||||
rd := msgp.NewEndlessReader(buf.Bytes(), b)
|
||||
dc := msgp.NewReader(rd)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
err := v.DecodeMsg(dc)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalUnmarshaldemoteCandidate(t *testing.T) {
|
||||
v := demoteCandidate{}
|
||||
bts, err := v.MarshalMsg(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
left, err := v.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) > 0 {
|
||||
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
|
||||
}
|
||||
|
||||
left, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) > 0 {
|
||||
t.Errorf("%d bytes left over after Skip(): %q", len(left), left)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshalMsgdemoteCandidate(b *testing.B) {
|
||||
v := demoteCandidate{}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.MarshalMsg(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAppendMsgdemoteCandidate(b *testing.B) {
|
||||
v := demoteCandidate{}
|
||||
bts := make([]byte, 0, v.Msgsize())
|
||||
bts, _ = v.MarshalMsg(bts[0:0])
|
||||
b.SetBytes(int64(len(bts)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bts, _ = v.MarshalMsg(bts[0:0])
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshaldemoteCandidate(b *testing.B) {
|
||||
v := demoteCandidate{}
|
||||
bts, _ := v.MarshalMsg(nil)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(bts)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := v.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodedemoteCandidate(t *testing.T) {
|
||||
v := demoteCandidate{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
|
||||
m := v.Msgsize()
|
||||
if buf.Len() > m {
|
||||
t.Log("WARNING: TestEncodeDecodedemoteCandidate Msgsize() is inaccurate")
|
||||
}
|
||||
|
||||
vn := demoteCandidate{}
|
||||
err := msgp.Decode(&buf, &vn)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
msgp.Encode(&buf, &v)
|
||||
err = msgp.NewReader(&buf).Skip()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEncodedemoteCandidate(b *testing.B) {
|
||||
v := demoteCandidate{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
b.SetBytes(int64(buf.Len()))
|
||||
en := msgp.NewWriter(msgp.Nowhere)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.EncodeMsg(en)
|
||||
}
|
||||
en.Flush()
|
||||
}
|
||||
|
||||
func BenchmarkDecodedemoteCandidate(b *testing.B) {
|
||||
v := demoteCandidate{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
b.SetBytes(int64(buf.Len()))
|
||||
rd := msgp.NewEndlessReader(buf.Bytes(), b)
|
||||
dc := msgp.NewReader(rd)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
err := v.DecodeMsg(dc)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/config/ilm"
|
||||
)
|
||||
|
||||
func TestAccessEntryRollAndHits(t *testing.T) {
|
||||
entry := accessEntry{Bins: []uint32{3, 2, 1}, HeadAt: 100, LastAt: 107}
|
||||
entry.rollTo(120, 10, 3)
|
||||
want := []uint32{0, 0, 3}
|
||||
for i := range want {
|
||||
if entry.Bins[i] != want[i] {
|
||||
t.Fatalf("bins = %v, want %v", entry.Bins, want)
|
||||
}
|
||||
}
|
||||
if entry.HeadAt != 120 || entry.LastAt != 107 {
|
||||
t.Fatalf("head/last = %d/%d", entry.HeadAt, entry.LastAt)
|
||||
}
|
||||
|
||||
entry = accessEntry{Bins: []uint32{10, 20, 30}, HeadAt: 120}
|
||||
if got := entry.hits(20*time.Second, 10); got != 30 {
|
||||
t.Fatalf("20s hits = %d, want 30", got)
|
||||
}
|
||||
if got := entry.hits(21*time.Second, 10); got != 60 {
|
||||
t.Fatalf("21s hits = %d, want 60", got)
|
||||
}
|
||||
entry.rollTo(200, 10, 3)
|
||||
if got := entry.total(); got != 0 {
|
||||
t.Fatalf("expired total = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessEntryMergeSaturates(t *testing.T) {
|
||||
entry := accessEntry{Bins: []uint32{math.MaxUint32 - 1}}
|
||||
entry.mergeFrom(accessEntry{Bins: []uint32{10}, LastAt: 50})
|
||||
if entry.Bins[0] != math.MaxUint32 || entry.LastAt != 50 {
|
||||
t.Fatalf("merged entry = %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTrackerEvictsColdest(t *testing.T) {
|
||||
tracker := newAccessTracker()
|
||||
live := map[string]accessEntry{
|
||||
"a": {Bins: []uint32{1}, HeadAt: 100},
|
||||
"b": {Bins: []uint32{5}, HeadAt: 100},
|
||||
"c": {Bins: []uint32{3}, HeadAt: 100},
|
||||
}
|
||||
tracker.evict(live, 100, 10, ilm.Config{AccessBins: 1, AccessMaxTracked: 2})
|
||||
if len(live) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(live))
|
||||
}
|
||||
if _, ok := live["a"]; ok {
|
||||
t.Fatal("coldest entry was retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessKeyRoundTrip(t *testing.T) {
|
||||
key := accessKey("bucket", "a/b/c")
|
||||
bucket, object, ok := splitAccessKey(key)
|
||||
if !ok || bucket != "bucket" || object != "a/b/c" {
|
||||
t.Fatalf("split %q = %q/%q/%v", key, bucket, object, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessShardFresh(t *testing.T) {
|
||||
const now = int64(1000)
|
||||
if !accessShardFresh(now, accessShard{UpdatedAt: 950}, 100) {
|
||||
t.Fatal("fresh shard rejected")
|
||||
}
|
||||
if accessShardFresh(now, accessShard{UpdatedAt: 899}, 100) {
|
||||
t.Fatal("stale shard accepted")
|
||||
}
|
||||
if accessShardFresh(now, accessShard{UpdatedAt: 1101}, 100) {
|
||||
t.Fatal("far-future shard accepted")
|
||||
}
|
||||
if accessShardFresh(now, accessShard{}, 100) {
|
||||
t.Fatal("zero timestamp accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTrackerRestoresDemoteCandidates(t *testing.T) {
|
||||
tracker := newAccessTracker()
|
||||
candidate := demoteCandidate{Bucket: "bucket", Object: "object", Pool: 1}
|
||||
tracker.restoreDemoteCandidates([]demoteCandidate{candidate})
|
||||
got := tracker.takeDemoteCandidates()
|
||||
if len(got) != 1 || got[0] != candidate {
|
||||
t.Fatalf("restored candidates = %+v, want %+v", got, candidate)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/config/ilm"
|
||||
)
|
||||
@@ -28,16 +27,6 @@ var globalILMConfig = ilmConfig{
|
||||
cfg: ilm.Config{
|
||||
ExpirationWorkers: 100,
|
||||
TransitionWorkers: 100,
|
||||
// Access tiering stays off until configured, but the counter
|
||||
// geometry must be sane from the start: the tracker divides by
|
||||
// AccessBinWidth before any config is loaded.
|
||||
AccessPromoteWatermark: 85,
|
||||
AccessBinWidth: time.Minute,
|
||||
AccessBins: 12,
|
||||
AccessFlush: time.Minute,
|
||||
AccessMinResidency: 24 * time.Hour,
|
||||
AccessWorkers: 10,
|
||||
AccessMaxTracked: 1000000,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -60,25 +49,6 @@ func (c *ilmConfig) getTransitionWorkers() int {
|
||||
return c.cfg.TransitionWorkers
|
||||
}
|
||||
|
||||
// accessCfg returns a copy of the access tiering settings. Callers take the
|
||||
// whole struct rather than one getter per field because the promotion and
|
||||
// demotion paths need a consistent view of several knobs at once.
|
||||
func (c *ilmConfig) accessCfg() ilm.Config {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
return c.cfg
|
||||
}
|
||||
|
||||
// accessTieringEnabled is the cheap gate used on the scanner path.
|
||||
func (c *ilmConfig) accessTieringEnabled() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
_, ok := c.cfg.HotPool()
|
||||
return ok
|
||||
}
|
||||
|
||||
func (c *ilmConfig) update(cfg ilm.Config) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -19,12 +19,11 @@ func _() {
|
||||
_ = x[lcEventSrc_s3PutObject-8]
|
||||
_ = x[lcEventSrc_s3CopyObject-9]
|
||||
_ = x[lcEventSrc_s3CompleteMultipartUpload-10]
|
||||
_ = x[lcEventSrc_AccessTier-11]
|
||||
}
|
||||
|
||||
const _lcEventSrc_name = "NoneHealScannerDecomRebals3HeadObjects3GetObjects3ListObjectss3PutObjects3CopyObjects3CompleteMultipartUploadAccessTier"
|
||||
const _lcEventSrc_name = "NoneHealScannerDecomRebals3HeadObjects3GetObjects3ListObjectss3PutObjects3CopyObjects3CompleteMultipartUpload"
|
||||
|
||||
var _lcEventSrc_index = [...]uint8{0, 4, 8, 15, 20, 25, 37, 48, 61, 72, 84, 109, 119}
|
||||
var _lcEventSrc_index = [...]uint8{0, 4, 8, 15, 20, 25, 37, 48, 61, 72, 84, 109}
|
||||
|
||||
func (i lcEventSrc) String() string {
|
||||
idx := int(i) - 0
|
||||
|
||||
@@ -26,17 +26,6 @@ const (
|
||||
transitionActiveTasks = "transition_active_tasks"
|
||||
transitionPendingTasks = "transition_pending_tasks"
|
||||
transitionMissedImmediateTasks = "transition_missed_immediate_tasks"
|
||||
accessTierActiveTasks = "access_tier_active_tasks"
|
||||
accessTierPendingTasks = "access_tier_pending_tasks"
|
||||
accessTierPromotionsTotal = "access_tier_promotions_total"
|
||||
accessTierDemotionsTotal = "access_tier_demotions_total"
|
||||
accessTierBytesMovedTotal = "access_tier_bytes_moved_total"
|
||||
accessTierFailuresTotal = "access_tier_failures_total"
|
||||
accessTierSkippedWatermark = "access_tier_skipped_watermark_total"
|
||||
accessTierSkippedMaxSize = "access_tier_skipped_max_size_total"
|
||||
accessTierSkippedQuota = "access_tier_skipped_bucket_quota_total"
|
||||
accessTierHotBytes = "access_tier_hot_bytes"
|
||||
accessTierSamplesDropped = "access_tier_samples_dropped_total"
|
||||
versionsScanned = "versions_scanned"
|
||||
)
|
||||
|
||||
@@ -45,17 +34,6 @@ var (
|
||||
ilmTransitionActiveTasksMD = NewGaugeMD(transitionActiveTasks, "Number of active ILM transition tasks")
|
||||
ilmTransitionPendingTasksMD = NewGaugeMD(transitionPendingTasks, "Number of pending ILM transition tasks in the queue")
|
||||
ilmTransitionMissedImmediateTasksMD = NewCounterMD(transitionMissedImmediateTasks, "Number of missed immediate ILM transition tasks")
|
||||
ilmAccessTierActiveTasksMD = NewGaugeMD(accessTierActiveTasks, "Number of active access-tier pool moves")
|
||||
ilmAccessTierPendingTasksMD = NewGaugeMD(accessTierPendingTasks, "Number of pending access-tier pool moves")
|
||||
ilmAccessTierPromotionsTotalMD = NewCounterMD(accessTierPromotionsTotal, "Total objects promoted by access-tier ILM")
|
||||
ilmAccessTierDemotionsTotalMD = NewCounterMD(accessTierDemotionsTotal, "Total objects demoted by access-tier ILM")
|
||||
ilmAccessTierBytesMovedTotalMD = NewCounterMD(accessTierBytesMovedTotal, "Total logical bytes moved by access-tier ILM")
|
||||
ilmAccessTierFailuresTotalMD = NewCounterMD(accessTierFailuresTotal, "Total failed access-tier ILM moves")
|
||||
ilmAccessTierSkippedWatermarkMD = NewCounterMD(accessTierSkippedWatermark, "Promotions skipped because the hot pool reached its watermark")
|
||||
ilmAccessTierSkippedMaxSizeMD = NewCounterMD(accessTierSkippedMaxSize, "Promotions skipped because the cluster hot-tier size cap was reached")
|
||||
ilmAccessTierSkippedQuotaMD = NewCounterMD(accessTierSkippedQuota, "Promotions skipped because the bucket hot-tier quota was reached")
|
||||
ilmAccessTierHotBytesMD = NewGaugeMD(accessTierHotBytes, "Logical bytes currently accounted to the hot tier", "bucket")
|
||||
ilmAccessTierSamplesDroppedMD = NewCounterMD(accessTierSamplesDropped, "GET samples dropped because the access tracker queue was full")
|
||||
ilmVersionsScannedMD = NewCounterMD(versionsScanned, "Total number of object versions checked for ILM actions since server start")
|
||||
)
|
||||
|
||||
@@ -69,21 +47,6 @@ func loadILMMetrics(_ context.Context, m MetricValues, _ *metricsCache) error {
|
||||
m.Set(transitionPendingTasks, float64(globalTransitionState.PendingTasks()))
|
||||
m.Set(transitionMissedImmediateTasks, float64(globalTransitionState.MissedImmediateTasks()))
|
||||
}
|
||||
if globalAccessTierState != nil {
|
||||
m.Set(accessTierActiveTasks, float64(globalAccessTierState.ActiveTasks()))
|
||||
m.Set(accessTierPendingTasks, float64(globalAccessTierState.PendingTasks()))
|
||||
m.Set(accessTierPromotionsTotal, float64(globalAccessTierState.promotions.Load()))
|
||||
m.Set(accessTierDemotionsTotal, float64(globalAccessTierState.demotions.Load()))
|
||||
m.Set(accessTierBytesMovedTotal, float64(globalAccessTierState.bytesMoved.Load()))
|
||||
m.Set(accessTierFailuresTotal, float64(globalAccessTierState.failures.Load()))
|
||||
m.Set(accessTierSkippedWatermark, float64(globalAccessTierState.skippedWatermark.Load()))
|
||||
m.Set(accessTierSkippedMaxSize, float64(globalAccessTierState.skippedMaxSize.Load()))
|
||||
m.Set(accessTierSkippedQuota, float64(globalAccessTierState.skippedQuota.Load()))
|
||||
for bucket, bytes := range globalAccessTierState.hotUsageSnapshot() {
|
||||
m.Set(accessTierHotBytes, float64(bytes), "bucket", bucket)
|
||||
}
|
||||
}
|
||||
m.Set(accessTierSamplesDropped, float64(globalAccessTracker.dropped.Load()))
|
||||
m.Set(versionsScanned, float64(globalScannerMetrics.lifetime(scannerMetricILM)))
|
||||
|
||||
return nil
|
||||
|
||||
@@ -392,17 +392,6 @@ func newMetricGroups(r *prometheus.Registry) *metricsV3Collection {
|
||||
ilmTransitionActiveTasksMD,
|
||||
ilmTransitionPendingTasksMD,
|
||||
ilmTransitionMissedImmediateTasksMD,
|
||||
ilmAccessTierActiveTasksMD,
|
||||
ilmAccessTierPendingTasksMD,
|
||||
ilmAccessTierPromotionsTotalMD,
|
||||
ilmAccessTierDemotionsTotalMD,
|
||||
ilmAccessTierBytesMovedTotalMD,
|
||||
ilmAccessTierFailuresTotalMD,
|
||||
ilmAccessTierSkippedWatermarkMD,
|
||||
ilmAccessTierSkippedMaxSizeMD,
|
||||
ilmAccessTierSkippedQuotaMD,
|
||||
ilmAccessTierHotBytesMD,
|
||||
ilmAccessTierSamplesDroppedMD,
|
||||
ilmVersionsScannedMD,
|
||||
},
|
||||
loadILMMetrics,
|
||||
|
||||
@@ -122,10 +122,6 @@ type ObjectOptions struct {
|
||||
SkipRebalancing bool
|
||||
|
||||
SrcPoolIdx int // set by PutObject/CompleteMultipart operations due to rebalance; used to prevent rebalance src, dst pools to be the same
|
||||
// DstPoolIdx forces a data-movement write onto a specific server pool.
|
||||
// It is ignored unless DataMovement is true; a pointer keeps pool zero
|
||||
// distinguishable from the unset value.
|
||||
DstPoolIdx *int
|
||||
|
||||
DataMovement bool // indicates an going decommisionning or rebalacing
|
||||
|
||||
|
||||
@@ -576,8 +576,6 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj
|
||||
return
|
||||
}
|
||||
|
||||
globalAccessTracker.note(bucket, object)
|
||||
|
||||
// Notify object accessed via a GET request.
|
||||
sendEvent(eventArgs{
|
||||
EventName: event.ObjectAccessedGet,
|
||||
|
||||
@@ -501,7 +501,6 @@ func initAllSubsystems(ctx context.Context) {
|
||||
globalTierConfigMgr = NewTierConfigMgr()
|
||||
|
||||
globalTransitionState = newTransitionState(GlobalContext)
|
||||
globalAccessTierState = newAccessTierState(GlobalContext)
|
||||
globalSiteResyncMetrics = newSiteResyncMetrics(GlobalContext)
|
||||
}
|
||||
|
||||
@@ -1070,10 +1069,6 @@ func serverMain(ctx *cli.Context) {
|
||||
bootstrapTrace("globalTransitionState.Init", func() {
|
||||
globalTransitionState.Init(newObject)
|
||||
})
|
||||
bootstrapTrace("globalAccessTierState.Init", func() {
|
||||
globalAccessTierState.Init(newObject)
|
||||
go globalAccessTracker.run(GlobalContext, newObject)
|
||||
})
|
||||
|
||||
go func() {
|
||||
// Initialize transition tier configuration manager
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
|
||||
func TestBucketMetadataTombstoneExportAndInitialSync(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
recordBucketConfigPeer(t, cred)
|
||||
old := globalSiteReplicationMetadataTombstones
|
||||
defer func() { globalSiteReplicationMetadataTombstones = old }()
|
||||
created := UTCNow().Add(-time.Hour)
|
||||
deletedAt := created.Add(time.Minute)
|
||||
for _, enabled := range []bool{false, true} {
|
||||
globalSiteReplicationMetadataTombstones = enabled
|
||||
meta := newBucketMetadata(bucket)
|
||||
meta.Created = created
|
||||
meta.defaultTimestamps()
|
||||
for _, file := range []string{bucketPolicyConfig, bucketTaggingConfig, bucketSSEConfig, bucketQuotaConfigFile} {
|
||||
_, at := replicatedBucketConfig(&meta, file)
|
||||
*at = deletedAt
|
||||
}
|
||||
if err := globalBucketMetadataSys.save(t.Context(), meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Force a disk reload instead of accepting the just-published cache.
|
||||
globalBucketMetadataSys.Remove(bucket)
|
||||
info, err := globalSiteReplicationSys.SiteReplicationMetaInfo(t.Context(), obj, madmin.SRStatusOptions{Buckets: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exported := info.Buckets[bucket]
|
||||
if !exported.PolicyUpdatedAt.Equal(deletedAt) {
|
||||
t.Fatal("Policy tombstone hidden by gate")
|
||||
}
|
||||
for _, at := range []time.Time{exported.TagConfigUpdatedAt, exported.SSEConfigUpdatedAt, exported.QuotaConfigUpdatedAt} {
|
||||
if enabled && !at.Equal(deletedAt) || !enabled && !at.IsZero() {
|
||||
t.Fatalf("gate=%v timestamp=%v", enabled, at)
|
||||
}
|
||||
}
|
||||
for file, data := range bucketConfigTestData(bucket) {
|
||||
event, send, err := initialBucketConfigReplicationEvent(meta, file)
|
||||
wantSend := enabled && !bucketConfigUpdateOnly(file)
|
||||
if err != nil || send != wantSend || send && !event.UpdatedAt.Equal(deletedAt) {
|
||||
t.Fatalf("%s initial gate=%v: %+v %v %v", file, enabled, event, send, err)
|
||||
}
|
||||
baseline := newBucketMetadata(bucket)
|
||||
baseline.Created = created
|
||||
baseline.defaultTimestamps()
|
||||
if _, send, err := initialBucketConfigReplicationEvent(baseline, file); err != nil || send {
|
||||
t.Fatalf("empty baseline sent: %s", file)
|
||||
}
|
||||
value, _ := replicatedBucketConfig(&baseline, file)
|
||||
*value = data
|
||||
event, send, err = initialBucketConfigReplicationEvent(baseline, file)
|
||||
if err != nil || !send || !event.UpdatedAt.Equal(created) {
|
||||
t.Fatalf("historical baseline-live omitted: %s %v", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
func TestPeerBucketMetadataLegacyAndGeneration(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
old := globalSiteReplicationMetadataTombstones
|
||||
defer func() { globalSiteReplicationMetadataTombstones = old }()
|
||||
created := UTCNow().Add(-time.Hour)
|
||||
for _, enabled := range []bool{false, true} {
|
||||
globalSiteReplicationMetadataTombstones = enabled
|
||||
for file, data := range bucketConfigTestData(bucket) {
|
||||
meta := newBucketMetadata(bucket)
|
||||
meta.Created = created
|
||||
meta.defaultTimestamps()
|
||||
if err := globalBucketMetadataSys.save(t.Context(), meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counter := &bucketConfigWriteCounter{ObjectLayer: obj}
|
||||
setObjectLayer(counter)
|
||||
event := newBucketConfigReplicationEvent(bucket, file, bucketConfigState{data: data, at: created.Add(-time.Second)})
|
||||
rec := applySRBucketMetaViaAdmin(t, cred, event)
|
||||
if rec.Code != http.StatusOK || counter.writes.Load() != 0 {
|
||||
t.Fatalf("pre-creation apply: %s %d writes=%d", file, rec.Code, counter.writes.Load())
|
||||
}
|
||||
// A live baseline may initialize. The same-time nil cannot delete it.
|
||||
event.UpdatedAt = created
|
||||
rec = applySRBucketMetaViaAdmin(t, cred, event)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("baseline apply: %s %s", file, rec.Body.String())
|
||||
}
|
||||
before := counter.writes.Load()
|
||||
rec = applySRBucketMetaViaAdmin(t, cred, madmin.SRBucketMeta{Type: event.Type, Bucket: bucket, UpdatedAt: created})
|
||||
if rec.Code != http.StatusOK || counter.writes.Load() != before {
|
||||
t.Fatalf("nil baseline cleared configuration: %s", file)
|
||||
}
|
||||
event.UpdatedAt = time.Time{}
|
||||
for range 2 {
|
||||
rec = applySRBucketMetaViaAdmin(t, cred, event)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("legacy-zero rejected: %s %s", file, rec.Body.String())
|
||||
}
|
||||
}
|
||||
meta, err := loadBucketMetadata(t.Context(), obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, at := replicatedBucketConfig(&meta, file)
|
||||
if len(*value) == 0 || !at.After(created) {
|
||||
t.Fatalf("legacy-zero not reclocked: %s %v", file, *at)
|
||||
}
|
||||
setObjectLayer(obj)
|
||||
}
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
type bucketMetadataCreatedObjectLayer struct {
|
||||
ObjectLayer
|
||||
missing bool
|
||||
}
|
||||
|
||||
func (o bucketMetadataCreatedObjectLayer) GetBucketInfo(ctx context.Context, bucket string, opts BucketOptions) (BucketInfo, error) {
|
||||
if opts.NoMetadata {
|
||||
if o.missing {
|
||||
return BucketInfo{}, BucketNotFound{Bucket: bucket}
|
||||
}
|
||||
// A physical bucket that reports no creation time either.
|
||||
return BucketInfo{Name: bucket}, nil
|
||||
}
|
||||
return o.ObjectLayer.GetBucketInfo(ctx, bucket, opts)
|
||||
}
|
||||
|
||||
func TestPeerBucketMetadataUnknownCreated(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
defer setObjectLayer(obj)
|
||||
data := bucketConfigTestData(bucket)[bucketTaggingConfig]
|
||||
for _, mode := range []string{"unknown", "missing"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
setObjectLayer(obj)
|
||||
if err := globalBucketMetadataSys.save(t.Context(), newBucketMetadata(bucket)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counter := &bucketConfigWriteCounter{ObjectLayer: bucketMetadataCreatedObjectLayer{ObjectLayer: obj, missing: mode == "missing"}}
|
||||
setObjectLayer(counter)
|
||||
stamp := UTCNow()
|
||||
_, err := globalBucketMetadataSys.updateAndParseMetadata(t.Context(), bucket, bucketTaggingConfig, data, false, false, &stamp)
|
||||
if err == nil || counter.writes.Load() != 0 {
|
||||
t.Fatalf("unknown generation was invented: %v writes=%d", err, counter.writes.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
// setPhysicalBucketCreated stamps the bucket directory on every local drive,
|
||||
// which is what StatVol reports as the physical creation time.
|
||||
func setPhysicalBucketCreated(t *testing.T, bucket string, at time.Time) {
|
||||
t.Helper()
|
||||
globalLocalDrivesMu.RLock()
|
||||
drives := cloneDrives(globalLocalDrivesMap)
|
||||
globalLocalDrivesMu.RUnlock()
|
||||
if len(drives) == 0 {
|
||||
t.Fatal("no local drives registered")
|
||||
}
|
||||
for _, drive := range drives {
|
||||
if err := os.Chtimes(pathJoin(drive.Endpoint().Path, bucket), at, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery has to run against the real ObjectLayer: a stub GetBucketInfo
|
||||
// returning the expected time would hide the cached zero creation time
|
||||
// overwriting it, which is what a bucket that never held a configuration has.
|
||||
func TestBucketMetadataPhysicalCreatedRecovery(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
||||
// One known time on every drive, so the recovered value can be neither
|
||||
// confused with UTCNow() nor dependent on which drive answers first.
|
||||
physical := UTCNow().Add(-3 * time.Hour).Truncate(time.Second)
|
||||
for _, missing := range []bool{false, true} {
|
||||
for _, file := range replicatedBucketConfigs {
|
||||
t.Run(backend+"/"+file+"/missing="+strconv.FormatBool(missing), func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
setPhysicalBucketCreated(t, bucket, physical)
|
||||
if err := globalBucketMetadataSys.save(ctx, newBucketMetadata(bucket)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if missing {
|
||||
if err := deleteConfig(ctx, obj, pathJoin(bucketMetaPrefix, bucket, bucketMetadataFile)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
data := bucketConfigTestData(bucket)[file]
|
||||
at, err := globalBucketMetadataSys.Update(ctx, bucket, file, data)
|
||||
if err != nil {
|
||||
t.Fatalf("bucket without a recorded creation time cannot update %s: %v", file, err)
|
||||
}
|
||||
got, err := readBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil || !got.Created.Equal(physical) || !at.After(physical) {
|
||||
t.Fatalf("physical creation not persisted: created=%v physical=%v updated=%v err=%v", got.Created, physical, at, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}})
|
||||
}
|
||||
|
||||
func TestBucketMetadataInitialSyncPhysicalCreated(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
serviceCred, _, err := globalIAMSys.NewServiceAccount(ctx, cred.AccessKey, nil, newServiceAccountOpts{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { globalIAMSys.DeleteServiceAccount(context.Background(), serviceCred.AccessKey, false) })
|
||||
physical := UTCNow().Add(-3 * time.Hour).Truncate(time.Second)
|
||||
setPhysicalBucketCreated(t, bucket, physical)
|
||||
meta := newBucketMetadata(bucket)
|
||||
meta.TaggingConfigXML = bucketConfigTestData(bucket)[bucketTaggingConfig]
|
||||
if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var createdAt string
|
||||
var events []madmin.SRBucketMeta
|
||||
peer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if r.URL.Query().Get("operation") == string(madmin.MakeWithVersioningBktOp) {
|
||||
createdAt = r.URL.Query().Get("createdAt")
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/bucket-meta") {
|
||||
var event madmin.SRBucketMeta
|
||||
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer peer.Close()
|
||||
// Exercise the complete outgoing sync sequence with real source
|
||||
// storage. This peer acknowledges RPCs; it is not a second ObjectLayer.
|
||||
c := &SiteReplicationSys{enabled: true, state: srState{
|
||||
ServiceAccountAccessKey: serviceCred.AccessKey,
|
||||
Peers: map[string]madmin.PeerInfo{"initial-peer": {DeploymentID: "initial-peer", Endpoint: peer.URL}},
|
||||
}}
|
||||
if err := c.syncToAllPeers(ctx, madmin.SRAddOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if createdAt != physical.Format(time.RFC3339Nano) {
|
||||
t.Fatalf("peer creation time %q, want physical time %s", createdAt, physical)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Bucket == bucket && event.Type == madmin.SRBucketMetaTypeTags {
|
||||
if event.Tags == nil || *event.Tags != base64.StdEncoding.EncodeToString(meta.TaggingConfigXML) || !event.UpdatedAt.Equal(physical) {
|
||||
t.Fatalf("historical tags lost baseline: %+v", event)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("initial sync silently skipped historical tags")
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
func TestPeerBucketMetadataPhysicalCreatedBoundary(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
physical := UTCNow().Truncate(time.Second)
|
||||
setPhysicalBucketCreated(t, bucket, physical)
|
||||
if err := globalBucketMetadataSys.save(ctx, newBucketMetadata(bucket)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counter := &bucketConfigWriteCounter{ObjectLayer: obj}
|
||||
setObjectLayer(counter)
|
||||
defer setObjectLayer(obj)
|
||||
data := bucketConfigTestData(bucket)[bucketTaggingConfig]
|
||||
at := physical.Add(-time.Hour)
|
||||
_, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketTaggingConfig, data, false, false, &at)
|
||||
if err != nil || counter.writes.Load() != 0 {
|
||||
t.Fatalf("event before recovered creation must be skipped: err=%v writes=%d", err, counter.writes.Load())
|
||||
}
|
||||
// Physical mtime is only an approximation. A local correction can
|
||||
// establish it; an earlier peer event cannot lower the bucket identity.
|
||||
at, err = globalBucketMetadataSys.Update(ctx, bucket, bucketTaggingConfig, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil || !got.Created.Equal(physical) || !at.After(physical) || !bytes.Equal(got.TaggingConfigXML, data) {
|
||||
t.Fatalf("local correction did not establish physical creation: %+v %v", got, err)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/logger/target/testlogger"
|
||||
)
|
||||
|
||||
func bucketConfigTestData(bucket string) map[string][]byte {
|
||||
return map[string][]byte{
|
||||
bucketPolicyConfig: []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket)),
|
||||
bucketTaggingConfig: []byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>value</Value></Tag></TagSet></Tagging>`),
|
||||
bucketSSEConfig: []byte(`<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`),
|
||||
bucketQuotaConfigFile: []byte(`{"quota":1024,"quotatype":"hard"}`),
|
||||
bucketVersioningConfig: enabledBucketVersioningConfig,
|
||||
objectLockConfig: enabledBucketObjectLockConfig,
|
||||
}
|
||||
}
|
||||
|
||||
type bucketConfigLogCapture struct {
|
||||
testing.TB
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
}
|
||||
|
||||
func (c *bucketConfigLogCapture) Logf(format string, args ...any) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.lines = append(c.lines, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func TestHealBucketConfigDiagnostics(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
recordBucketConfigPeer(t, cred)
|
||||
capture := &bucketConfigLogCapture{TB: t}
|
||||
defer testlogger.T.SetLogTB(capture)()
|
||||
disabled := logger.DisableLog
|
||||
logger.DisableLog = false
|
||||
defer func() { logger.DisableLog = disabled }()
|
||||
created := UTCNow().Add(-time.Hour)
|
||||
local := globalDeploymentID()
|
||||
bs := map[string]srBucketStatsSummary{
|
||||
local: bucketConfigTestInfo(bucket, bucketTaggingConfig, nil, created, created),
|
||||
"missing-bucket": {},
|
||||
"broken-rpc": bucketConfigTestInfo(bucket, bucketTaggingConfig, nil, created, created),
|
||||
}
|
||||
info := srStatusInfo{Sites: map[string]madmin.PeerInfo{local: {}, "missing-bucket": {}, "unreachable": {}, "broken-rpc": {}}, BucketStats: map[string]map[string]srBucketStatsSummary{bucket: bs}}
|
||||
if err := globalSiteReplicationSys.healBucketConfig(t.Context(), bucket, bucketTaggingConfig, info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(capture.lines) != 0 {
|
||||
t.Fatal("empty baselines produced diagnostics")
|
||||
}
|
||||
bs[local] = bucketConfigTestInfo(bucket, bucketTaggingConfig, bucketConfigTestData(bucket)[bucketTaggingConfig], created.Add(time.Minute), created)
|
||||
for range 2 {
|
||||
if err := globalSiteReplicationSys.healBucketConfig(t.Context(), bucket, bucketTaggingConfig, info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
capture.mu.Lock()
|
||||
defer capture.mu.Unlock()
|
||||
var unreachable, peerError int
|
||||
for _, line := range capture.lines {
|
||||
switch {
|
||||
case strings.Contains(line, "bucket metadata replication: unreachable"):
|
||||
unreachable++
|
||||
case strings.Contains(line, "bucket metadata replication: peer-error"):
|
||||
peerError++
|
||||
default:
|
||||
t.Fatalf("unexpected diagnostic: %s", line)
|
||||
}
|
||||
if !strings.HasPrefix(line, "WARNING:") {
|
||||
t.Fatalf("diagnostic is not a warning: %s", line)
|
||||
}
|
||||
}
|
||||
if unreachable != 1 || peerError != 1 {
|
||||
t.Fatalf("distinct reasons were lost or not deduplicated: unreachable=%d peer-error=%d", unreachable, peerError)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
func TestHealBucketConfigWithoutSourceDiagnostics(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
events := recordBucketConfigPeer(t, cred)
|
||||
disabled := logger.DisableLog
|
||||
logger.DisableLog = false
|
||||
defer func() { logger.DisableLog = disabled }()
|
||||
created := UTCNow().Add(-time.Hour)
|
||||
data := bucketConfigTestData(bucket)[bucketTaggingConfig]
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
at time.Time
|
||||
created time.Time
|
||||
wantLog bool
|
||||
}{
|
||||
{"unknown-created", data, created.Add(time.Minute), time.Time{}, true},
|
||||
{"malformed", []byte("<Tagging>"), created.Add(time.Minute), created, true},
|
||||
{"before-created", data, created.Add(-time.Minute), created, true},
|
||||
{"empty-baseline", nil, created, created, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
capture := &bucketConfigLogCapture{TB: t}
|
||||
defer testlogger.T.SetLogTB(capture)()
|
||||
// Each case has its own diagnostic key; no source can be selected
|
||||
// and neither local storage nor the recording peer may be written.
|
||||
name := bucket + "-" + tc.name
|
||||
local := globalDeploymentID()
|
||||
info := srStatusInfo{
|
||||
Sites: map[string]madmin.PeerInfo{local: {}, "metadata-peer": {}, "unreachable": {}},
|
||||
BucketStats: map[string]map[string]srBucketStatsSummary{name: {
|
||||
local: bucketConfigTestInfo(name, bucketTaggingConfig, tc.data, tc.at, tc.created),
|
||||
"metadata-peer": {},
|
||||
}},
|
||||
}
|
||||
for range 2 {
|
||||
if err := globalSiteReplicationSys.healBucketConfig(t.Context(), name, bucketTaggingConfig, info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
capture.mu.Lock()
|
||||
defer capture.mu.Unlock()
|
||||
want := 0
|
||||
if tc.wantLog {
|
||||
want = 1
|
||||
}
|
||||
if len(capture.lines) != want {
|
||||
t.Fatalf("got %d diagnostics, want %d: %v", len(capture.lines), want, capture.lines)
|
||||
}
|
||||
for _, line := range capture.lines {
|
||||
if !strings.HasPrefix(line, "WARNING:") || !strings.Contains(line, "bucket metadata replication: indeterminate") {
|
||||
t.Fatalf("unexpected diagnostic: %s", line)
|
||||
}
|
||||
}
|
||||
if len(events()) != 0 {
|
||||
t.Fatal("healing without a source sent a metadata RPC")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
// Construct independent wire fixtures, including timestamps hidden by legacy
|
||||
// exporters, without going through the production event or state helpers.
|
||||
func bucketConfigTestInfo(bucket, file string, data []byte, at, created time.Time) srBucketStatsSummary {
|
||||
info := madmin.SRBucketInfo{Bucket: bucket, CreatedAt: created}
|
||||
var payload *string
|
||||
if len(data) != 0 {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
payload = &encoded
|
||||
}
|
||||
switch file {
|
||||
case bucketPolicyConfig:
|
||||
info.Policy, info.PolicyUpdatedAt = data, at
|
||||
case bucketTaggingConfig:
|
||||
info.Tags, info.TagConfigUpdatedAt = payload, at
|
||||
case bucketSSEConfig:
|
||||
info.SSEConfig, info.SSEConfigUpdatedAt = payload, at
|
||||
case bucketQuotaConfigFile:
|
||||
info.QuotaConfig, info.QuotaConfigUpdatedAt = payload, at
|
||||
case bucketVersioningConfig:
|
||||
info.Versioning, info.VersioningConfigUpdatedAt = payload, at
|
||||
case objectLockConfig:
|
||||
info.ObjectLockConfig, info.ObjectLockConfigUpdatedAt = payload, at
|
||||
}
|
||||
return srBucketStatsSummary{meta: srBucketMetaInfo{SRBucketInfo: info}}
|
||||
}
|
||||
|
||||
func TestLatestBucketConfigCandidates(t *testing.T) {
|
||||
created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for file, data := range bucketConfigTestData("bucket") {
|
||||
t.Run(file, func(t *testing.T) {
|
||||
info := srStatusInfo{Sites: map[string]madmin.PeerInfo{"a": {}, "b": {}, "c": {}}, BucketStats: map[string]map[string]srBucketStatsSummary{"bucket": {}}}
|
||||
bs := info.BucketStats["bucket"]
|
||||
baseline := bucketConfigTestInfo("bucket", file, nil, created, created)
|
||||
bs["a"], bs["b"], bs["c"] = baseline, baseline, baseline
|
||||
if _, found := latestBucketConfig("bucket", file, info); found {
|
||||
t.Fatal("empty baseline chosen as source")
|
||||
}
|
||||
// Unknown and unavailable IDs cannot contribute, even with future clocks.
|
||||
bs[""], bs["unknown"] = bucketConfigTestInfo("bucket", file, data, created.Add(10*time.Hour), created), bucketConfigTestInfo("bucket", file, data, created.Add(20*time.Hour), created)
|
||||
if _, found := latestBucketConfig("bucket", file, info); found {
|
||||
t.Fatal("unknown source chosen")
|
||||
}
|
||||
liveBaseline := bucketConfigTestInfo("bucket", file, data, created, created)
|
||||
modified := bucketConfigTestInfo("bucket", file, data, created.Add(time.Hour), created)
|
||||
oldGeneration := bucketConfigTestInfo("bucket", file, data, created, created.Add(time.Second))
|
||||
states := []srBucketStatsSummary{liveBaseline, modified, oldGeneration}
|
||||
for _, order := range [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} {
|
||||
bs["a"], bs["b"], bs["c"] = states[order[0]], states[order[1]], states[order[2]]
|
||||
winner, found := latestBucketConfig("bucket", file, info)
|
||||
if !found || !winner.at.Equal(created.Add(time.Hour)) || !bytes.Equal(winner.data, data) {
|
||||
t.Fatalf("permutation %v chose %+v found=%v", order, winner, found)
|
||||
}
|
||||
}
|
||||
bs["a"], bs["b"], bs["c"] = baseline, liveBaseline, baseline
|
||||
if winner, found := latestBucketConfig("bucket", file, info); !found || !bytes.Equal(winner.data, data) || winner.real {
|
||||
t.Fatal("historical baseline-live cannot initialize")
|
||||
}
|
||||
if !bucketConfigUpdateOnly(file) {
|
||||
// A late-created empty baseline cannot beat a real earlier write.
|
||||
bs["a"], bs["b"], bs["c"] = modified, bucketConfigTestInfo("bucket", file, nil, created.Add(2*time.Hour), created.Add(2*time.Hour)), baseline
|
||||
if winner, found := latestBucketConfig("bucket", file, info); !found || len(winner.data) == 0 {
|
||||
t.Fatal("default became a deletion")
|
||||
}
|
||||
bs["c"] = bucketConfigTestInfo("bucket", file, nil, created.Add(time.Hour), created)
|
||||
if winner, found := latestBucketConfig("bucket", file, info); !found || len(winner.data) != 0 || !winner.real {
|
||||
t.Fatal("real tombstone lost equal-time tie")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealBucketConfigSourceAndQuiescence(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
events := recordBucketConfigPeer(t, cred)
|
||||
local := globalDeploymentID()
|
||||
created := UTCNow().Add(-time.Hour)
|
||||
oldAt, newAt := created.Add(time.Minute), created.Add(2*time.Minute)
|
||||
counter := &bucketConfigWriteCounter{ObjectLayer: obj}
|
||||
setObjectLayer(counter)
|
||||
defer setObjectLayer(obj)
|
||||
for file, data := range bucketConfigTestData(bucket) {
|
||||
t.Run(file, func(t *testing.T) {
|
||||
meta := newBucketMetadata(bucket)
|
||||
meta.Created = created
|
||||
meta.defaultTimestamps()
|
||||
value, at := replicatedBucketConfig(&meta, file)
|
||||
*value, *at = data, newAt
|
||||
if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bs := map[string]srBucketStatsSummary{
|
||||
local: bucketConfigTestInfo(bucket, file, data, newAt, created),
|
||||
"metadata-peer": bucketConfigTestInfo(bucket, file, data, oldAt, created),
|
||||
"": {}, "unknown": bucketConfigTestInfo(bucket, file, nil, newAt.Add(time.Hour), created),
|
||||
// Known but absent from c.state.Peers, forcing getAdminClient failure.
|
||||
"broken": bucketConfigTestInfo(bucket, file, nil, created, created),
|
||||
}
|
||||
info := srStatusInfo{Sites: map[string]madmin.PeerInfo{local: {}, "metadata-peer": {}, "broken": {}}, BucketStats: map[string]map[string]srBucketStatsSummary{bucket: bs}}
|
||||
before := len(events())
|
||||
writes := counter.writes.Load()
|
||||
if err := globalSiteReplicationSys.healBucketConfig(ctx, bucket, file, info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := events()[before:]
|
||||
if len(got) != 1 || !got[0].UpdatedAt.Equal(newAt) {
|
||||
t.Fatalf("same-payload timestamp heal did not reach healthy peer: %+v", got)
|
||||
}
|
||||
if file == bucketTaggingConfig && got[0].Tags == nil {
|
||||
t.Fatal("tag event missing payload")
|
||||
}
|
||||
if counter.writes.Load() != writes {
|
||||
t.Fatal("matching local state was rewritten")
|
||||
}
|
||||
bs["metadata-peer"], bs["broken"] = bs[local], bs[local]
|
||||
if err := globalSiteReplicationSys.healBucketConfig(ctx, bucket, file, info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events()) != before+1 || counter.writes.Load() != writes {
|
||||
t.Fatal("stable second heal wrote or broadcast")
|
||||
}
|
||||
if bucketConfigUpdateOnly(file) {
|
||||
return
|
||||
}
|
||||
bs["metadata-peer"] = bucketConfigTestInfo(bucket, file, nil, newAt.Add(time.Minute), created)
|
||||
delete(bs, "broken")
|
||||
if err := globalSiteReplicationSys.healBucketConfig(ctx, bucket, file, info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, at = replicatedBucketConfig(&after, file)
|
||||
if len(*value) != 0 || !at.Equal(newAt.Add(time.Minute)) {
|
||||
t.Fatalf("local heal lost deletion/source time: %s %v", *value, *at)
|
||||
}
|
||||
if file == bucketQuotaConfigFile {
|
||||
q, _, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket)
|
||||
if err != nil || q.Quota != 0 {
|
||||
t.Fatalf("quota cache not cleared: %+v %v", q, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
)
|
||||
|
||||
// Read once at startup. Enable only after every participating node is fixed.
|
||||
var globalSiteReplicationMetadataTombstones bool
|
||||
|
||||
func logBucketConfigReplication(ctx context.Context, bucket, file, reason string, at, created time.Time, detail string) {
|
||||
// LogOnceIf compares error text as well as its key. Keep both stable; changing
|
||||
// times and peer errors belong in ReqInfo, not in the error's message.
|
||||
req := &logger.ReqInfo{API: "SiteReplicationMetadata", BucketName: bucket}
|
||||
req.AppendTags("field", file)
|
||||
req.AppendTags("sourceTime", at.UTC().Format(time.RFC3339Nano))
|
||||
req.AppendTags("created", created.UTC().Format(time.RFC3339Nano))
|
||||
req.AppendTags("detail", detail)
|
||||
replLogOnceIf(logger.SetReqInfo(ctx, req), errors.New("bucket metadata replication: "+reason),
|
||||
"bucket-metadata/"+bucket+"/"+file+"/"+reason, logger.WarningKind)
|
||||
}
|
||||
|
||||
func initialBucketConfigReplicationEvent(meta BucketMetadata, file string) (madmin.SRBucketMeta, bool, error) {
|
||||
data, at := replicatedBucketConfig(&meta, file)
|
||||
state, err := newBucketConfigState(meta.Name, file, *data, *at, meta.Created, len(meta.ObjectLockConfigXML) != 0)
|
||||
if err != nil {
|
||||
return madmin.SRBucketMeta{}, false, err
|
||||
}
|
||||
if !state.candidate() || (len(state.data) == 0 && !globalSiteReplicationMetadataTombstones) {
|
||||
return madmin.SRBucketMeta{}, false, nil
|
||||
}
|
||||
return newBucketConfigReplicationEvent(meta.Name, file, state), true, nil
|
||||
}
|
||||
|
||||
func bucketConfigStateFromInfo(bucket, file string, meta madmin.SRBucketInfo) (bucketConfigState, error) {
|
||||
var payload *string
|
||||
var at time.Time
|
||||
switch file {
|
||||
case bucketPolicyConfig:
|
||||
return newBucketConfigState(bucket, file, meta.Policy, meta.PolicyUpdatedAt, meta.CreatedAt, false)
|
||||
case bucketTaggingConfig:
|
||||
payload, at = meta.Tags, meta.TagConfigUpdatedAt
|
||||
case bucketSSEConfig:
|
||||
payload, at = meta.SSEConfig, meta.SSEConfigUpdatedAt
|
||||
case bucketQuotaConfigFile:
|
||||
payload, at = meta.QuotaConfig, meta.QuotaConfigUpdatedAt
|
||||
case bucketVersioningConfig:
|
||||
payload, at = meta.Versioning, meta.VersioningConfigUpdatedAt
|
||||
case objectLockConfig:
|
||||
payload, at = meta.ObjectLockConfig, meta.ObjectLockConfigUpdatedAt
|
||||
}
|
||||
var data []byte
|
||||
if payload != nil {
|
||||
var err error
|
||||
data, err = base64.StdEncoding.DecodeString(*payload)
|
||||
if err != nil {
|
||||
return bucketConfigState{}, err
|
||||
}
|
||||
}
|
||||
lockEnabled := meta.ObjectLockConfig != nil && len(*meta.ObjectLockConfig) != 0
|
||||
return newBucketConfigState(bucket, file, data, at, meta.CreatedAt, lockEnabled)
|
||||
}
|
||||
|
||||
func newBucketConfigReplicationEvent(bucket, file string, state bucketConfigState) madmin.SRBucketMeta {
|
||||
event := madmin.SRBucketMeta{Bucket: bucket, UpdatedAt: state.at}
|
||||
var payload *string
|
||||
if len(state.data) != 0 {
|
||||
encoded := base64.StdEncoding.EncodeToString(state.data)
|
||||
payload = &encoded
|
||||
}
|
||||
switch file {
|
||||
case bucketPolicyConfig:
|
||||
event.Type, event.Policy = madmin.SRBucketMetaTypePolicy, state.data
|
||||
case bucketTaggingConfig:
|
||||
event.Type, event.Tags = madmin.SRBucketMetaTypeTags, payload
|
||||
case bucketSSEConfig:
|
||||
event.Type, event.SSEConfig = madmin.SRBucketMetaTypeSSEConfig, payload
|
||||
case bucketQuotaConfigFile:
|
||||
event.Type, event.Quota = madmin.SRBucketMetaTypeQuotaConfig, state.data
|
||||
case bucketVersioningConfig:
|
||||
event.Type, event.Versioning = madmin.SRBucketMetaTypeVersionConfig, payload
|
||||
case objectLockConfig:
|
||||
event.Type, event.ObjectLockConfig = madmin.SRBucketMetaTypeObjectLockConfig, payload
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func latestBucketConfig(bucket, file string, info srStatusInfo) (bucketConfigState, bool) {
|
||||
var latest bucketConfigState
|
||||
found := false
|
||||
for id, status := range info.BucketStats[bucket] {
|
||||
if _, known := info.Sites[id]; !known || id == "" {
|
||||
continue
|
||||
}
|
||||
state, err := bucketConfigStateFromInfo(bucket, file, status.meta.SRBucketInfo)
|
||||
if err != nil || !state.candidate() {
|
||||
continue
|
||||
}
|
||||
if !found || compareBucketConfigStates(state, latest) > 0 {
|
||||
latest, found = state, true
|
||||
}
|
||||
}
|
||||
return latest, found
|
||||
}
|
||||
|
||||
func (c *SiteReplicationSys) healBucketConfig(ctx context.Context, bucket, file string, info srStatusInfo) error {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
latest, found := latestBucketConfig(bucket, file, info)
|
||||
// Every reason keeps its own log key, so a site that did not report cannot
|
||||
// deduplicate away an unusable peer state or a real heal RPC failure for
|
||||
// the same bucket and field.
|
||||
if found {
|
||||
for id := range info.Sites {
|
||||
if _, present := info.BucketStats[bucket][id]; !present {
|
||||
logBucketConfigReplication(ctx, bucket, file, "unreachable", latest.at, time.Time{}, "peer "+id+" did not report")
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, status := range info.BucketStats[bucket] {
|
||||
if _, known := info.Sites[id]; !known || id == "" {
|
||||
continue
|
||||
}
|
||||
target := status.meta.SRBucketInfo
|
||||
current, currentErr := bucketConfigStateFromInfo(bucket, file, target)
|
||||
// A peer without the bucket reports neither a field nor a creation
|
||||
// time; bucket healing covers that normal transient. Report only a
|
||||
// state that exists and still cannot be ordered.
|
||||
if currentErr != nil || (!current.valid && (len(current.data) != 0 || !current.at.IsZero())) {
|
||||
logBucketConfigReplication(ctx, bucket, file, "indeterminate", current.at, target.CreatedAt, "unusable peer "+id)
|
||||
}
|
||||
// Invalid existing state still needs a diagnosis when no source can
|
||||
// be selected. Only propagation depends on having a valid source.
|
||||
if !found || target.CreatedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
if latest.at.Before(target.CreatedAt) {
|
||||
logBucketConfigReplication(ctx, bucket, file, "before-created", latest.at, target.CreatedAt, "peer "+id)
|
||||
continue
|
||||
}
|
||||
// Versioning can be normalized differently until Object Lock itself has
|
||||
// converged. Compare what this target would actually persist.
|
||||
incoming, err := newBucketConfigState(bucket, file, latest.data, latest.at, target.CreatedAt,
|
||||
target.ObjectLockConfig != nil && len(*target.ObjectLockConfig) != 0)
|
||||
if err != nil || !incoming.candidate() {
|
||||
continue
|
||||
}
|
||||
if currentErr == nil && compareBucketConfigStates(incoming, current) <= 0 {
|
||||
continue
|
||||
}
|
||||
if id == globalDeploymentID() {
|
||||
_, err = globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, file, latest.data, false, false, &latest.at)
|
||||
} else {
|
||||
var client *madmin.AdminClient
|
||||
client, err = c.getAdminClient(ctx, id)
|
||||
if err == nil {
|
||||
err = client.SRPeerReplicateBucketMeta(ctx, newBucketConfigReplicationEvent(bucket, file, incoming))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// A missing credential or unreachable peer must not abandon the other
|
||||
// targets simply because it happened to be visited first in this map.
|
||||
logBucketConfigReplication(ctx, bucket, file, "peer-error", latest.at, target.CreatedAt, "peer "+id+": "+err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
func TestPeerBucketMetadataSourceTimeAndDeletion(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testPeerBucketMetadataSourceTimeAndDeletion})
|
||||
}
|
||||
|
||||
func testPeerBucketMetadataSourceTimeAndDeletion(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
ctx := t.Context()
|
||||
created := UTCNow().Add(-time.Hour)
|
||||
putAt := created.Add(10 * time.Minute)
|
||||
delAt := putAt.Add(time.Minute)
|
||||
enc := func(s string) *string { v := base64.StdEncoding.EncodeToString([]byte(s)); return &v }
|
||||
base := newBucketMetadata(bucket)
|
||||
base.SetCreatedAt(created)
|
||||
base.defaultTimestamps()
|
||||
quotaJSON, err := json.Marshal(madmin.BucketQuota{Quota: 1024, Type: madmin.HardQuota})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name, file string
|
||||
put madmin.SRBucketMeta
|
||||
value func(BucketMetadata) []byte
|
||||
stamp func(BucketMetadata) time.Time
|
||||
deletable bool
|
||||
}{
|
||||
{"policy", bucketPolicyConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypePolicy, Policy: []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket))}, func(m BucketMetadata) []byte { return m.PolicyConfigJSON }, func(m BucketMetadata) time.Time { return m.PolicyConfigUpdatedAt }, true},
|
||||
{"tags", bucketTaggingConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeTags, Tags: enc(`<Tagging><TagSet><Tag><Key>key</Key><Value>old</Value></Tag></TagSet></Tagging>`)}, func(m BucketMetadata) []byte { return m.TaggingConfigXML }, func(m BucketMetadata) time.Time { return m.TaggingConfigUpdatedAt }, true},
|
||||
{"sse", bucketSSEConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeSSEConfig, SSEConfig: enc(`<ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`)}, func(m BucketMetadata) []byte { return m.EncryptionConfigXML }, func(m BucketMetadata) time.Time { return m.EncryptionConfigUpdatedAt }, true},
|
||||
{"quota", bucketQuotaConfigFile, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeQuotaConfig, Quota: quotaJSON}, func(m BucketMetadata) []byte { return m.QuotaConfigJSON }, func(m BucketMetadata) time.Time { return m.QuotaConfigUpdatedAt }, true},
|
||||
{"versioning", bucketVersioningConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeVersionConfig, Versioning: enc(`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>`)}, func(m BucketMetadata) []byte { return m.VersioningConfigXML }, func(m BucketMetadata) time.Time { return m.VersioningConfigUpdatedAt }, false},
|
||||
{"objectlock", objectLockConfig, newSRBucketObjectLockMeta(bucket, enc(`<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>30</Days></DefaultRetention></Rule></ObjectLockConfiguration>`), putAt), func(m BucketMetadata) []byte { return m.ObjectLockConfigXML }, func(m BucketMetadata) time.Time { return m.ObjectLockConfigUpdatedAt }, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(backend+"/"+tc.name, func(t *testing.T) {
|
||||
if err := globalBucketMetadataSys.save(ctx, base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := tc.put
|
||||
item.Bucket, item.UpdatedAt = bucket, putAt
|
||||
apply := func(item madmin.SRBucketMeta) {
|
||||
t.Helper()
|
||||
rec := applySRBucketMetaViaAdmin(t, cred, item)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("admin apply returned %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
read := func() BucketMetadata {
|
||||
t.Helper()
|
||||
m, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
apply(item)
|
||||
put := read()
|
||||
if len(tc.value(put)) == 0 {
|
||||
t.Fatal("PUT did not establish live config")
|
||||
}
|
||||
if !tc.stamp(put).Equal(putAt) {
|
||||
t.Errorf("SOURCE_TIME: persisted %s, want source %s", tc.stamp(put), putAt)
|
||||
}
|
||||
apply(madmin.SRBucketMeta{Type: item.Type, Bucket: bucket, UpdatedAt: delAt})
|
||||
after := read()
|
||||
if !tc.deletable {
|
||||
if !bytes.Equal(tc.value(put), tc.value(after)) || !tc.stamp(put).Equal(tc.stamp(after)) {
|
||||
t.Error("NIL_NOOP: update-only config changed")
|
||||
}
|
||||
t.Log("nil payload is correctly a no-op")
|
||||
return
|
||||
}
|
||||
if len(tc.value(after)) != 0 {
|
||||
t.Error("NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE")
|
||||
}
|
||||
if _, err := globalBucketMetadataSys.Delete(ctx, bucket, tc.file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tombstone := read()
|
||||
if len(tc.value(tombstone)) != 0 {
|
||||
t.Fatal("local DELETE failed to establish tombstone")
|
||||
}
|
||||
apply(item)
|
||||
after = read()
|
||||
if len(tc.value(after)) != 0 {
|
||||
t.Error("STALE_RESURRECTION: older source PUT resurrected a locally deleted config")
|
||||
} else {
|
||||
t.Log("older source PUT did not resurrect config")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerBucketMetadataBulkOrdering(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
meta, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newAt := meta.Created.Add(time.Hour)
|
||||
newXML := `<Tagging><TagSet><Tag><Key>key</Key><Value>new</Value></Tag></TagSet></Tagging>`
|
||||
meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = []byte(newXML), newAt
|
||||
if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldXML := base64.StdEncoding.EncodeToString([]byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>old</Value></Tag></TagSet></Tagging>`))
|
||||
item := madmin.SRBucketMeta{Bucket: bucket, Tags: &oldXML, UpdatedAt: newAt.Add(-time.Minute)}
|
||||
rec := applySRBucketMetaViaAdmin(t, cred, item)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("bulk apply returned %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
after, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(after.TaggingConfigXML, []byte(newXML)) || !after.TaggingConfigUpdatedAt.Equal(newAt) {
|
||||
t.Errorf("BULK_STALE_OVERWRITE: older bulk event overwrote newer config, value=%q time=%s", after.TaggingConfigXML, after.TaggingConfigUpdatedAt)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
func TestPeerBucketMetadataOrderingUnderLock(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
meta, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldAt, newAt := meta.Created.Add(time.Hour), meta.Created.Add(2*time.Hour)
|
||||
oldXML := base64.StdEncoding.EncodeToString([]byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>older</Value></Tag></TagSet></Tagging>`))
|
||||
newXML := []byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>newest</Value></Tag></TagSet></Tagging>`)
|
||||
lockCtx, unlock, err := lockBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
locked := true
|
||||
defer func() {
|
||||
if locked {
|
||||
unlock()
|
||||
}
|
||||
}()
|
||||
ready := make(chan struct{}, 1)
|
||||
hook := func(name string) {
|
||||
if name == bucket {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
lockBucketMetadataAcquireHook.Store(&hook)
|
||||
defer lockBucketMetadataAcquireHook.Store(nil)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- globalSiteReplicationSys.PeerBucketTaggingHandler(ctx, bucket, &oldXML, oldAt) }()
|
||||
select {
|
||||
case <-ready:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("older event never reached metadata lock")
|
||||
}
|
||||
// A newer writer commits while holding the existing metadata lock.
|
||||
// The old peer event has already checked the pre-commit cache.
|
||||
meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = newXML, newAt
|
||||
if err := globalBucketMetadataSys.saveMetadata(lockCtx, obj, &meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlock()
|
||||
locked = false
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("older event did not finish")
|
||||
}
|
||||
after, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after.TaggingConfigXML) != string(newXML) {
|
||||
t.Errorf("CHECK_OUTSIDE_LOCK: queued older event replaced newer committed tags with %q", after.TaggingConfigXML)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
// Count actual metadata persistence, including writes that would be invisible
|
||||
// in a value-only assertion after a duplicate or rejected event.
|
||||
type bucketConfigWriteCounter struct {
|
||||
ObjectLayer
|
||||
writes atomic.Int64
|
||||
}
|
||||
|
||||
func (o *bucketConfigWriteCounter) PutObject(ctx context.Context, bucket, object string, data *PutObjReader, opts ObjectOptions) (ObjectInfo, error) {
|
||||
if bucket == minioMetaBucket && path.Base(object) == bucketMetadataFile {
|
||||
o.writes.Add(1)
|
||||
}
|
||||
return o.ObjectLayer.PutObject(ctx, bucket, object, data, opts)
|
||||
}
|
||||
|
||||
func TestBucketConfigStateOrdering(t *testing.T) {
|
||||
created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
live := []byte(`<Tagging><TagSet><Tag><Key>k</Key><Value>a</Value></Tag></TagSet></Tagging>`)
|
||||
other := bytes.ReplaceAll(live, []byte("<Value>a"), []byte("<Value>b"))
|
||||
state := func(data []byte, at, birth time.Time) bucketConfigState {
|
||||
t.Helper()
|
||||
s, err := newBucketConfigState("bucket", bucketTaggingConfig, data, at, birth, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
baseline := state(nil, created, created)
|
||||
baselineLive := state(live, created, created)
|
||||
put := state(live, created.Add(time.Second), created)
|
||||
deleted := state(nil, put.at, created)
|
||||
lateBaseline := state(other, created.Add(time.Hour), created.Add(time.Hour))
|
||||
tests := []struct {
|
||||
name string
|
||||
lower, higher bucketConfigState
|
||||
}{
|
||||
{"baseline initialization", baseline, baselineLive},
|
||||
{"real before later baseline", lateBaseline, put},
|
||||
{"delete wins tie", put, deleted},
|
||||
{"live key tie", put, state(other, put.at, created)},
|
||||
{"new PUT after deletion", deleted, state(live, put.at.Add(time.Second), created)},
|
||||
{"baseline key ignores creation time", state(live, created.Add(time.Hour), created.Add(time.Hour)), state(other, created, created)},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if compareBucketConfigStates(tc.lower, tc.higher) >= 0 || compareBucketConfigStates(tc.higher, tc.lower) <= 0 {
|
||||
t.Fatal("ordering is not antisymmetric or chose the wrong winner")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, s := range []bucketConfigState{baseline, state(live, created.Add(-time.Second), created), state(live, created, time.Time{})} {
|
||||
if s.candidate() {
|
||||
t.Fatal("default, pre-creation, or unknown-generation state became a source")
|
||||
}
|
||||
}
|
||||
for _, file := range []string{bucketVersioningConfig, objectLockConfig} {
|
||||
s, err := newBucketConfigState("bucket", file, nil, put.at, created, false)
|
||||
if err != nil || s.candidate() {
|
||||
t.Fatalf("empty update-only candidate: %+v %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketPolicyReplicationKey(t *testing.T) {
|
||||
// Permute independent set arrays and parse separately, as two sites would.
|
||||
a := []byte(`{"Version":"2012-10-17","Statement":[{"Sid":"one","Effect":"Allow","Principal":{"AWS":["b","a"]},"Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::bucket/b*","arn:aws:s3:::bucket/a*"],"Condition":{"StringLike":{"s3:prefix":["b*","a*"]}}},{"Sid":"two","Effect":"Deny","Principal":"*","NotAction":["s3:GetObject","s3:PutObject"],"NotResource":["arn:aws:s3:::bucket/d*","arn:aws:s3:::bucket/c*"]}]}`)
|
||||
// Use a condition valid for object actions.
|
||||
a = bytes.ReplaceAll(a, []byte("s3:prefix"), []byte("aws:UserAgent"))
|
||||
b := []byte(`{"Statement":[{"NotResource":["arn:aws:s3:::bucket/c*","arn:aws:s3:::bucket/d*"],"NotAction":["s3:PutObject","s3:GetObject"],"Principal":"*","Effect":"Deny","Sid":"two"},{"Condition":{"StringLike":{"aws:UserAgent":["a*","b*"]}},"Resource":["arn:aws:s3:::bucket/a*","arn:aws:s3:::bucket/b*"],"Action":["s3:PutObject","s3:GetObject"],"Principal":{"AWS":["a","b"]},"Effect":"Allow","Sid":"one"}],"Version":"2012-10-17"}`)
|
||||
_, key, err := bucketConfigPayload("bucket", bucketPolicyConfig, a, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range 30 {
|
||||
_, next, err := bucketConfigPayload("bucket", bucketPolicyConfig, b, false)
|
||||
if err != nil || !bytes.Equal(key, next) {
|
||||
t.Fatalf("independent parse %d: %s != %s (%v)", i, key, next, err)
|
||||
}
|
||||
}
|
||||
_, sid, err := bucketConfigPayload("bucket", bucketPolicyConfig, bytes.ReplaceAll(a, []byte(`"one"`), []byte(`"other"`)), false)
|
||||
if err != nil || bytes.Equal(key, sid) {
|
||||
t.Fatalf("Sid difference lost: %v", err)
|
||||
}
|
||||
n, err := canonicalBucketPolicyJSON([]byte(`{"n":[9007199254740993,9007199254740992]}`))
|
||||
if err != nil || string(n) != `{"n":[9007199254740992,9007199254740993]}` {
|
||||
t.Fatalf("integer precision lost: %s %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketPolicyReplicationStatusLegacyOrder(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
// Statement order here is the reverse of the canonical encoder's,
|
||||
// which is what an upgraded peer stores: the permutation must not
|
||||
// be reported as a permanent mismatch.
|
||||
legacy := []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Sid":"allow","Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"},{"Sid":"deny","Effect":"Deny","Principal":"*","Action":"s3:DeleteObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket, bucket))
|
||||
source, err := policy.ParseBucketPolicyConfig(bytes.NewReader(legacy), bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := newBucketMetadata(bucket)
|
||||
meta.Created = UTCNow().Add(-time.Hour)
|
||||
meta.PolicyConfigJSON = legacy
|
||||
meta.PolicyConfigUpdatedAt = meta.Created.Add(time.Minute)
|
||||
event, send, err := initialBucketConfigReplicationEvent(meta, bucketPolicyConfig)
|
||||
if err != nil || !send {
|
||||
t.Fatalf("legacy initial event: %v send=%v", err, send)
|
||||
}
|
||||
target := newBucketMetadata(bucket)
|
||||
target.Created = meta.Created
|
||||
if err := globalBucketMetadataSys.save(ctx, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := applySRBucketMetaViaAdmin(t, cred, event); rec.Code != http.StatusOK {
|
||||
t.Fatalf("peer apply: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
received, _, err := globalBucketMetadataSys.GetPolicyConfig(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !isBktPolicyReplicated(2, []*policy.BucketPolicy{source, received}) {
|
||||
t.Fatal("equivalent legacy and received policy reported as permanently mismatched")
|
||||
}
|
||||
changed, err := policy.ParseBucketPolicyConfig(bytes.NewReader(legacy), bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed.Statements[0].SID = "different"
|
||||
if isBktPolicyReplicated(2, []*policy.BucketPolicy{source, changed}) {
|
||||
t.Fatal("distinct policy state reported as replicated")
|
||||
}
|
||||
if isBktPolicyReplicated(2, []*policy.BucketPolicy{source, nil}) || !isBktPolicyReplicated(2, []*policy.BucketPolicy{nil, nil}) {
|
||||
t.Fatal("per-site presence accounting changed")
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
func TestPeerBucketMetadataWireAtomicity(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
meta, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stamp := meta.Created.Add(time.Hour)
|
||||
policyData := []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket))
|
||||
meta.PolicyConfigJSON, meta.PolicyConfigUpdatedAt = policyData, stamp
|
||||
meta.QuotaConfigJSON, meta.QuotaConfigUpdatedAt = []byte(`{"quota":1024,"quotatype":"hard"}`), stamp
|
||||
meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = []byte(`<Tagging><TagSet><Tag><Key>k</Key><Value>a</Value></Tag></TagSet></Tagging>`), stamp
|
||||
if err = globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counter := &bucketConfigWriteCounter{ObjectLayer: obj}
|
||||
setObjectLayer(counter)
|
||||
defer setObjectLayer(obj)
|
||||
read := func() BucketMetadata {
|
||||
t.Helper()
|
||||
m, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
apply := func(fragment string, at time.Time, ok bool) {
|
||||
t.Helper()
|
||||
raw := fmt.Sprintf(`{"bucket":%q,"updatedAt":%q,%s}`, bucket, at.Format(time.RFC3339Nano), fragment)
|
||||
var item madmin.SRBucketMeta
|
||||
if err := json.Unmarshal([]byte(raw), &item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := applySRBucketMetaViaAdmin(t, cred, item)
|
||||
if (rec.Code == http.StatusOK) != ok {
|
||||
t.Fatalf("%s: %d %s", raw, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// Omitted/null *string and empty update-only payloads are all no-ops.
|
||||
apply(`"policy":null,"quota":null,"tags":null,"versioningConfig":"","objectLockConfig":""`, stamp.Add(time.Minute), true)
|
||||
current := read()
|
||||
if len(current.PolicyConfigJSON) != 0 || len(current.QuotaConfigJSON) == 0 {
|
||||
t.Fatal("explicit null semantics lost")
|
||||
}
|
||||
if q, _, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket); err != nil || q.Quota != 0 {
|
||||
t.Fatalf("zero quota cache: %+v %v", q, err)
|
||||
}
|
||||
before := counter.writes.Load()
|
||||
apply(`"tags":null,"versioningConfig":"","objectLockConfig":""`, stamp.Add(2*time.Minute), true)
|
||||
if counter.writes.Load() != before {
|
||||
t.Fatal("omitted fields or empty update-only wrote metadata")
|
||||
}
|
||||
// An invalid second field cannot persist the valid deletion in this bulk.
|
||||
apply(`"tags":"","quota":{"quota":1,"quotatype":"invalid"}`, stamp.Add(2*time.Minute), false)
|
||||
if counter.writes.Load() != before || len(read().TaggingConfigXML) == 0 {
|
||||
t.Fatal("invalid bulk partially committed")
|
||||
}
|
||||
apply(`"tags":""`, stamp.Add(2*time.Minute), true)
|
||||
before = counter.writes.Load()
|
||||
apply(`"tags":""`, stamp.Add(2*time.Minute), true)
|
||||
apply(`"tags":""`, stamp.Add(time.Minute), true)
|
||||
if counter.writes.Load() != before {
|
||||
t.Fatal("duplicate or old event wrote metadata")
|
||||
}
|
||||
localAt, err := globalBucketMetadataSys.Update(ctx, bucket, bucketQuotaConfigFile, []byte(`{}`))
|
||||
if err != nil || !localAt.After(current.QuotaConfigUpdatedAt) {
|
||||
t.Fatalf("local future monotonic time: %v %v", localAt, err)
|
||||
}
|
||||
deletedAt, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketQuotaConfigFile)
|
||||
if err != nil || !deletedAt.After(localAt) {
|
||||
t.Fatalf("adjacent local time: %v %v", deletedAt, err)
|
||||
}
|
||||
if q, _, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket); err != nil || q.Quota != 0 {
|
||||
t.Fatalf("deleted quota cache: %+v %v", q, err)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
func TestPeerBucketAdoptionRebasesOnlyDefaults(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
||||
for _, shift := range []time.Duration{-time.Hour, 0, time.Hour, 3 * time.Hour} {
|
||||
t.Run(fmt.Sprintf("%s/%s", backend, shift), func(t *testing.T) {
|
||||
created := UTCNow().Add(-3 * time.Hour)
|
||||
meta := newBucketMetadata(bucket)
|
||||
meta.Created = created
|
||||
meta.defaultTimestamps()
|
||||
meta.QuotaConfigUpdatedAt = time.Time{}
|
||||
meta.TaggingConfigUpdatedAt = created.Add(2 * time.Hour) // real deletion
|
||||
meta.EncryptionConfigXML = []byte(`<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`)
|
||||
meta.EncryptionConfigUpdatedAt = created.Add(2 * time.Hour)
|
||||
if err := globalBucketMetadataSys.save(t.Context(), meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := globalSiteReplicationSys.PeerBucketMakeWithVersioningHandler(t.Context(), bucket, MakeBucketOptions{CreatedAt: created.Add(shift)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readBucketMetadata(t.Context(), obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.PolicyConfigUpdatedAt.Equal(got.Created) || !got.QuotaConfigUpdatedAt.Equal(got.Created) {
|
||||
t.Fatal("default turned into tombstone or invalid source")
|
||||
}
|
||||
if !got.TaggingConfigUpdatedAt.Equal(meta.TaggingConfigUpdatedAt) || !got.EncryptionConfigUpdatedAt.Equal(meta.EncryptionConfigUpdatedAt) || !bytes.Equal(got.EncryptionConfigXML, meta.EncryptionConfigXML) {
|
||||
t.Fatal("actual state changed during adoption")
|
||||
}
|
||||
if shift > 2*time.Hour {
|
||||
// Preserve history, but do not promote state from an earlier
|
||||
// bucket generation to a new valid source by retimestamping it.
|
||||
for _, file := range []string{bucketTaggingConfig, bucketSSEConfig} {
|
||||
data, at := replicatedBucketConfig(&got, file)
|
||||
state, err := newBucketConfigState(bucket, file, *data, *at, got.Created, false)
|
||||
if err != nil || state.candidate() {
|
||||
t.Fatalf("pre-generation history became a source: %s %v", file, err)
|
||||
}
|
||||
// As a target, that invalid history must yield to a valid
|
||||
// state from the adopted generation, including a live tag
|
||||
// replacing the preserved earlier-generation deletion.
|
||||
incoming := bucketConfigTestData(bucket)[file]
|
||||
incomingAt := got.Created.Add(time.Minute)
|
||||
changed, err := applyBucketConfig(&got, file, incoming, incomingAt)
|
||||
if err != nil || !changed || !at.Equal(incomingAt) || !bytes.Equal(*data, incoming) {
|
||||
t.Fatalf("adopted generation did not replace invalid target: %s %v", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}})
|
||||
}
|
||||
|
||||
func recordBucketConfigPeer(t *testing.T, cred auth.Credentials) func() []madmin.SRBucketMeta {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var events []madmin.SRBucketMeta
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var event madmin.SRBucketMeta
|
||||
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
||||
t.Error(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
events = append(events, event)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(remote.Close)
|
||||
serviceCred, err := auth.CreateCredentials("metadata-source-time-svc", "metadata-source-time-service-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serviceCred.ParentUser = cred.AccessKey
|
||||
globalSiteReplicatorCred.Set(serviceCred.SecretKey)
|
||||
t.Cleanup(func() { globalSiteReplicatorCred.Set("") })
|
||||
if _, err = globalIAMSys.store.AddServiceAccount(t.Context(), serviceCred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { globalIAMSys.DeleteServiceAccount(context.Background(), serviceCred.AccessKey, false) })
|
||||
globalSiteReplicationSys.Lock()
|
||||
oldEnabled, oldState := globalSiteReplicationSys.enabled, globalSiteReplicationSys.state
|
||||
globalSiteReplicationSys.enabled = true
|
||||
globalSiteReplicationSys.state = srState{Name: "metadata-source-test", ServiceAccountAccessKey: serviceCred.AccessKey, Peers: map[string]madmin.PeerInfo{
|
||||
globalDeploymentID(): {Name: "local", DeploymentID: globalDeploymentID()},
|
||||
"metadata-peer": {Name: "remote", DeploymentID: "metadata-peer", Endpoint: remote.URL},
|
||||
}}
|
||||
globalSiteReplicationSys.Unlock()
|
||||
t.Cleanup(func() {
|
||||
globalSiteReplicationSys.Lock()
|
||||
globalSiteReplicationSys.enabled, globalSiteReplicationSys.state = oldEnabled, oldState
|
||||
globalSiteReplicationSys.Unlock()
|
||||
})
|
||||
return func() []madmin.SRBucketMeta {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]madmin.SRBucketMeta(nil), events...)
|
||||
}
|
||||
}
|
||||
|
||||
// The final metadata lock is taken after reading the ZIP. Commit another
|
||||
// writer immediately before that acquisition, using the existing lock wrapper.
|
||||
type importInterleavingObjectLayer struct {
|
||||
ObjectLayer
|
||||
once atomic.Bool
|
||||
write func()
|
||||
}
|
||||
|
||||
func (o *importInterleavingObjectLayer) NewNSLock(bucket string, objects ...string) RWLocker {
|
||||
lock := o.ObjectLayer.NewNSLock(bucket, objects...)
|
||||
if bucket != minioMetaBucket || len(objects) != 1 || path.Base(objects[0]) != "metadata.lock" {
|
||||
return lock
|
||||
}
|
||||
return metadataObservedRWLocker{RWLocker: lock, onLock: func(context.Context) {
|
||||
if o.once.CompareAndSwap(false, true) {
|
||||
o.write()
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
func TestLocalBucketMetadataCommittedEvents(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, endpoints: []string{"PutBucketPolicy", "GetBucketPolicy", "PutBucketVersioning"}, objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
events := recordBucketConfigPeer(t, cred)
|
||||
putPolicy := func(data []byte) {
|
||||
t.Helper()
|
||||
req, err := newTestSignedRequestV4(http.MethodPut, getPutPolicyURL("", bucket), int64(len(data)), bytes.NewReader(data), cred.AccessKey, cred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("empty policy PUT: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
putPolicy([]byte(`{"Version":"2012-10-17","Statement":[]}`))
|
||||
meta, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := events()
|
||||
if len(got) != 1 || got[0].Type != madmin.SRBucketMetaTypePolicy || len(got[0].Policy) != 0 || len(meta.PolicyConfigJSON) != 0 || !meta.PolicyConfigUpdatedAt.Equal(got[0].UpdatedAt) {
|
||||
t.Fatalf("empty policy event/disk mismatch: %+v", got)
|
||||
}
|
||||
req, err := newTestSignedRequestV4(http.MethodGet, getGetPolicyURL("", bucket), 0, nil, cred.AccessKey, cred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("empty policy GET: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Supported negative sets must survive the same serializer on PUT,
|
||||
// peer apply, GET and export instead of failing on empty Action.
|
||||
putPolicy([]byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","NotAction":["s3:DeleteObject"],"NotResource":["arn:aws:s3:::%s/private/*"]}]}`, bucket)))
|
||||
req, err = newTestSignedRequestV4(http.MethodGet, getGetPolicyURL("", bucket), 0, nil, cred.AccessKey, cred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(`"NotAction"`)) || !bytes.Contains(rec.Body.Bytes(), []byte(`"NotResource"`)) {
|
||||
t.Fatalf("negative policy set GET: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
corsAdminRequest(t, cred, http.MethodPut, "/set-bucket-quota?bucket="+bucket, []byte(`{}`))
|
||||
meta, err = loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = events()
|
||||
last := got[len(got)-1]
|
||||
if last.Type != madmin.SRBucketMetaTypeQuotaConfig || len(last.Quota) == 0 || !last.UpdatedAt.Equal(meta.QuotaConfigUpdatedAt) {
|
||||
t.Fatalf("zero quota event: %+v", last)
|
||||
}
|
||||
|
||||
injectedAt := UTCNow().Add(2 * time.Hour)
|
||||
interleaving := &importInterleavingObjectLayer{ObjectLayer: obj, write: func() {
|
||||
data := []byte(`{"quota":2048,"quotatype":"hard"}`)
|
||||
_, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketQuotaConfigFile, data, false, false, &injectedAt)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}}
|
||||
setObjectLayer(interleaving)
|
||||
defer setObjectLayer(obj)
|
||||
before := len(events())
|
||||
rec = corsAdminRequest(t, cred, http.MethodPut, "/import-bucket-metadata", corsZip(t, map[string][]byte{
|
||||
bucket + "/" + bucketPolicyConfig: []byte(`{"Version":"2012-10-17","Statement":[]}`),
|
||||
bucket + "/quota.json": []byte(`{}`),
|
||||
bucket + "/" + bucketTaggingConfig: []byte(`<Tagging><TagSet><Tag><Key>imported</Key><Value>yes</Value></Tag></TagSet></Tagging>`),
|
||||
bucket + "/" + objectLockConfig: enabledBucketObjectLockConfig,
|
||||
bucket + "/" + bucketVersioningConfig: []byte(`<VersioningConfiguration><Status>Enabled</Status><ExcludeFolders>true</ExcludeFolders></VersioningConfiguration>`),
|
||||
}))
|
||||
report := corsImportReport(t, rec).Buckets[bucket]
|
||||
if report.Err != "" || report.Policy.Err != "" || report.Quota.Err != "" || report.Tagging.Err != "" {
|
||||
t.Fatalf("import: %+v", report)
|
||||
}
|
||||
meta, err = loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !meta.QuotaConfigUpdatedAt.After(injectedAt) || !meta.TaggingConfigUpdatedAt.Equal(meta.QuotaConfigUpdatedAt) || !meta.PolicyConfigUpdatedAt.Equal(meta.QuotaConfigUpdatedAt) {
|
||||
t.Fatalf("import time %v must exceed intervening %v; policy=%v tags=%v", meta.QuotaConfigUpdatedAt, injectedAt, meta.PolicyConfigUpdatedAt, meta.TaggingConfigUpdatedAt)
|
||||
}
|
||||
if !bytes.Equal(meta.VersioningConfigXML, enabledBucketVersioningConfig) || !meta.VersioningConfigUpdatedAt.Equal(meta.QuotaConfigUpdatedAt) || !meta.ObjectLockConfigUpdatedAt.Equal(meta.QuotaConfigUpdatedAt) {
|
||||
t.Fatal("import normalization or common source time lost")
|
||||
}
|
||||
got = events()[before:]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("import events: %+v", got)
|
||||
}
|
||||
for _, event := range got {
|
||||
if !event.UpdatedAt.Equal(meta.QuotaConfigUpdatedAt) {
|
||||
t.Fatal("import hook used ZIP start time")
|
||||
}
|
||||
if event.Type == madmin.SRBucketMetaTypePolicy {
|
||||
if len(event.Policy) != 0 {
|
||||
t.Fatal("empty policy import is not deletion")
|
||||
}
|
||||
} else if event.Tags == nil || len(event.Quota) == 0 {
|
||||
t.Fatalf("bulk omitted imported state: %+v", event)
|
||||
}
|
||||
if event.Type == "" {
|
||||
if event.Versioning == nil || event.ObjectLockConfig == nil {
|
||||
t.Fatal("normalized import fields omitted")
|
||||
}
|
||||
payload, err := base64.StdEncoding.DecodeString(*event.Versioning)
|
||||
if err != nil || !bytes.Equal(payload, enabledBucketVersioningConfig) {
|
||||
t.Fatal("import sent pre-normalization versioning")
|
||||
}
|
||||
}
|
||||
}
|
||||
beforeMeta, beforeEvents := meta, len(events())
|
||||
rec = corsAdminRequest(t, cred, http.MethodPut, "/import-bucket-metadata", corsZip(t, map[string][]byte{
|
||||
bucket + "/" + bucketTaggingConfig: []byte(`<Tagging><TagSet><Tag><Key>only</Key><Value>tags</Value></Tag></TagSet></Tagging>`),
|
||||
}))
|
||||
if status := corsImportReport(t, rec).Buckets[bucket]; status.Err != "" || status.Tagging.Err != "" {
|
||||
t.Fatalf("tags-only import: %+v", status)
|
||||
}
|
||||
meta, err = loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(meta.PolicyConfigJSON, beforeMeta.PolicyConfigJSON) || !meta.PolicyConfigUpdatedAt.Equal(beforeMeta.PolicyConfigUpdatedAt) || !bytes.Equal(meta.QuotaConfigJSON, beforeMeta.QuotaConfigJSON) || !meta.QuotaConfigUpdatedAt.Equal(beforeMeta.QuotaConfigUpdatedAt) {
|
||||
t.Fatal("tags-only import changed Policy or Quota")
|
||||
}
|
||||
got = events()[beforeEvents:]
|
||||
if len(got) != 1 || got[0].Tags == nil || got[0].Policy != nil || got[0].Quota != nil || got[0].Versioning != nil || got[0].ObjectLockConfig != nil || !got[0].UpdatedAt.Equal(meta.TaggingConfigUpdatedAt) {
|
||||
t.Fatalf("tags-only import hook leaked unspecified fields: %+v", got)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
func TestPeerBucketMetadataNormalizesBeforeComparison(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
meta, err := loadBucketMetadata(t.Context(), obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versioning := base64.StdEncoding.EncodeToString([]byte(`<VersioningConfiguration><Status>Enabled</Status><ExcludedPrefixes><Prefix>skip/</Prefix></ExcludedPrefixes><ExcludeFolders>true</ExcludeFolders></VersioningConfiguration>`))
|
||||
lock := base64.StdEncoding.EncodeToString(enabledBucketObjectLockConfig)
|
||||
item := madmin.SRBucketMeta{Bucket: bucket, ObjectLockConfig: &lock, Versioning: &versioning, UpdatedAt: meta.Created.Add(time.Hour)}
|
||||
counter := &bucketConfigWriteCounter{ObjectLayer: obj}
|
||||
setObjectLayer(counter)
|
||||
defer setObjectLayer(obj)
|
||||
for range 2 {
|
||||
rec := applySRBucketMetaViaAdmin(t, cred, item)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("bulk: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if counter.writes.Load() != 1 {
|
||||
t.Fatalf("normalized duplicate persisted %d times", counter.writes.Load())
|
||||
}
|
||||
meta, err = loadBucketMetadata(t.Context(), obj, bucket)
|
||||
if err != nil || !bytes.Equal(meta.VersioningConfigXML, enabledBucketVersioningConfig) || !meta.VersioningConfigUpdatedAt.Equal(item.UpdatedAt) {
|
||||
t.Fatalf("normalization: %s %v", meta.VersioningConfigXML, err)
|
||||
}
|
||||
// Ordinary local updates use the identical effective document in their
|
||||
// commit result, even if Object Lock changed since the handler's precheck.
|
||||
data, _ := base64.StdEncoding.DecodeString(versioning)
|
||||
result, err := globalBucketMetadataSys.updateAndParseMetadata(t.Context(), bucket, bucketVersioningConfig, data, false, false, nil)
|
||||
if err != nil || !bytes.Equal(result.meta.VersioningConfigXML, enabledBucketVersioningConfig) {
|
||||
t.Fatalf("local commit snapshot: %s %v", result.meta.VersioningConfigXML, err)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
|
||||
func TestPeerBucketMetadataEqualTimeArrivalOrders(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
t.Run(backend, func(t *testing.T) {
|
||||
created := UTCNow().Add(-time.Hour)
|
||||
a := []byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>a</Value></Tag></TagSet></Tagging>`)
|
||||
b := bytes.ReplaceAll(a, []byte("<Value>a"), []byte("<Value>b"))
|
||||
for _, pair := range [][2][]byte{{a, b}, {b, a}, {a, nil}, {nil, a}} {
|
||||
meta := newBucketMetadata(bucket)
|
||||
meta.Created = created
|
||||
meta.defaultTimestamps()
|
||||
if err := globalBucketMetadataSys.save(t.Context(), meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, data := range pair {
|
||||
var payload *string
|
||||
if data != nil {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
payload = &encoded
|
||||
}
|
||||
rec := applySRBucketMetaViaAdmin(t, cred, madmin.SRBucketMeta{Bucket: bucket, Type: madmin.SRBucketMetaTypeTags, Tags: payload, UpdatedAt: created.Add(time.Minute)})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("equal time apply: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
got, err := readBucketMetadata(t.Context(), obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := b
|
||||
if pair[0] == nil || pair[1] == nil {
|
||||
want = nil
|
||||
}
|
||||
if !bytes.Equal(got.TaggingConfigXML, want) || !got.TaggingConfigUpdatedAt.Equal(created.Add(time.Minute)) {
|
||||
t.Fatalf("arrival order changed winner: %q at %v", got.TaggingConfigXML, got.TaggingConfigUpdatedAt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
+116
-638
@@ -819,6 +819,9 @@ func (c *SiteReplicationSys) MakeBucketHook(ctx context.Context, bucket string,
|
||||
optsMap["forceCreate"] = "true"
|
||||
}
|
||||
createdAt, _ := globalBucketMetadataSys.CreatedAt(bucket)
|
||||
if createdAt.IsZero() {
|
||||
createdAt = opts.CreatedAt
|
||||
}
|
||||
optsMap["createdAt"] = createdAt.UTC().Format(time.RFC3339Nano)
|
||||
opts.CreatedAt = createdAt
|
||||
|
||||
@@ -906,7 +909,7 @@ func enablePeerBucketVersioning(meta *BucketMetadata, lockEnabled bool) error {
|
||||
config, err := versioning.ParseConfig(bytes.NewReader(meta.VersioningConfigXML))
|
||||
if err != nil || (lockEnabled && (config.Suspended() || config.PrefixesExcluded())) {
|
||||
meta.VersioningConfigXML = enabledBucketVersioningConfig
|
||||
meta.VersioningConfigUpdatedAt = UTCNow()
|
||||
meta.VersioningConfigUpdatedAt = localBucketConfigUpdatedAt(*meta, bucketVersioningConfig, UTCNow())
|
||||
return nil
|
||||
}
|
||||
if config.Enabled() {
|
||||
@@ -917,7 +920,7 @@ func enablePeerBucketVersioning(meta *BucketMetadata, lockEnabled bool) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta.VersioningConfigUpdatedAt = UTCNow()
|
||||
meta.VersioningConfigUpdatedAt = localBucketConfigUpdatedAt(*meta, bucketVersioningConfig, UTCNow())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -947,7 +950,9 @@ func (c *SiteReplicationSys) PeerBucketMakeWithVersioningHandler(ctx context.Con
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldCreated := meta.Created
|
||||
meta.SetCreatedAt(opts.CreatedAt)
|
||||
rebaseBucketConfigDefaults(&meta, oldCreated)
|
||||
|
||||
if err = enablePeerBucketVersioning(&meta, opts.LockEnabled || len(meta.ObjectLockConfigXML) != 0); err != nil {
|
||||
return err
|
||||
@@ -958,7 +963,7 @@ func (c *SiteReplicationSys) PeerBucketMakeWithVersioningHandler(ctx context.Con
|
||||
meta.ObjectLockConfigUpdatedAt = meta.Created
|
||||
}
|
||||
}
|
||||
return globalBucketMetadataSys.saveMetadata(bgContext(ctx), objAPI, meta)
|
||||
return globalBucketMetadataSys.saveMetadata(bgContext(ctx), objAPI, &meta)
|
||||
}()
|
||||
if err != nil {
|
||||
return wrapSRErr(c.annotateErr(makeBucketWithVersion, err))
|
||||
@@ -1583,24 +1588,18 @@ func (c *SiteReplicationSys) BucketMetaHook(ctx context.Context, item madmin.SRB
|
||||
|
||||
// PeerBucketVersioningHandler - updates versioning config to local cluster.
|
||||
func (c *SiteReplicationSys) PeerBucketVersioningHandler(ctx context.Context, bucket string, versioning *string, updatedAt time.Time) error {
|
||||
var data []byte
|
||||
if versioning != nil {
|
||||
// skip overwrite if local update is newer than peer update.
|
||||
if !updatedAt.IsZero() {
|
||||
if _, updateTm, err := globalBucketMetadataSys.GetVersioningConfig(bucket); err == nil && updateTm.After(updatedAt) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
configData, err := base64.StdEncoding.DecodeString(*versioning)
|
||||
var err error
|
||||
data, err = base64.StdEncoding.DecodeString(*versioning)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
_, err = globalBucketMetadataSys.Update(ctx, bucket, bucketVersioningConfig, configData)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketVersioningConfig, data, false, false, &updatedAt)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1647,53 +1646,51 @@ func (c *SiteReplicationSys) PeerBucketMetadataUpdateHandler(ctx context.Context
|
||||
if item.Cors != nil {
|
||||
replLogOnceIf(ctx, fmt.Errorf("ignoring CORS event for bucket %s from %v before bucket creation at %v", item.Bucket, item.UpdatedAt, meta.Created), "cors-event-before-bucket-creation-"+item.Bucket)
|
||||
}
|
||||
return nil
|
||||
item.Cors = nil
|
||||
}
|
||||
|
||||
if item.Policy != nil {
|
||||
meta.PolicyConfigJSON = item.Policy
|
||||
meta.PolicyConfigUpdatedAt = item.UpdatedAt
|
||||
// Presence is separate from content: omitted RawMessage is not a delete;
|
||||
// explicit JSON null still goes through the existing policy/quota parser.
|
||||
updates := make(map[string][]byte, len(replicatedBucketConfigs))
|
||||
if len(item.Policy) != 0 {
|
||||
updates[bucketPolicyConfig] = item.Policy
|
||||
}
|
||||
|
||||
if item.Versioning != nil {
|
||||
configData, err := base64.StdEncoding.DecodeString(*item.Versioning)
|
||||
if len(item.Quota) != 0 {
|
||||
updates[bucketQuotaConfigFile] = item.Quota
|
||||
}
|
||||
for file, payload := range map[string]*string{
|
||||
objectLockConfig: item.ObjectLockConfig, bucketVersioningConfig: item.Versioning,
|
||||
bucketTaggingConfig: item.Tags, bucketSSEConfig: item.SSEConfig,
|
||||
} {
|
||||
if payload != nil {
|
||||
data, err := base64.StdEncoding.DecodeString(*payload)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
updates[file] = data
|
||||
}
|
||||
}
|
||||
if len(updates) != 0 {
|
||||
if err := ensureBucketMetadataCreated(ctx, objectAPI, &meta); err != nil {
|
||||
logBucketConfigReplication(ctx, item.Bucket, "bulk", "indeterminate", item.UpdatedAt, meta.Created, err.Error())
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
for _, file := range replicatedBucketConfigs {
|
||||
data, supplied := updates[file]
|
||||
if !supplied {
|
||||
continue
|
||||
}
|
||||
if item.UpdatedAt.Before(meta.Created) {
|
||||
logBucketConfigReplication(ctx, item.Bucket, file, "before-created", item.UpdatedAt, meta.Created, "bulk event")
|
||||
continue
|
||||
}
|
||||
applied, err := applyBucketConfig(&meta, file, data, item.UpdatedAt)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
meta.VersioningConfigXML = configData
|
||||
meta.VersioningConfigUpdatedAt = item.UpdatedAt
|
||||
}
|
||||
|
||||
if item.Tags != nil {
|
||||
configData, err := base64.StdEncoding.DecodeString(*item.Tags)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
meta.TaggingConfigXML = configData
|
||||
meta.TaggingConfigUpdatedAt = item.UpdatedAt
|
||||
}
|
||||
|
||||
if item.ObjectLockConfig != nil {
|
||||
configData, err := base64.StdEncoding.DecodeString(*item.ObjectLockConfig)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
meta.ObjectLockConfigXML = configData
|
||||
meta.ObjectLockConfigUpdatedAt = item.UpdatedAt
|
||||
}
|
||||
|
||||
if item.SSEConfig != nil {
|
||||
configData, err := base64.StdEncoding.DecodeString(*item.SSEConfig)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
meta.EncryptionConfigXML = configData
|
||||
meta.EncryptionConfigUpdatedAt = item.UpdatedAt
|
||||
}
|
||||
|
||||
if item.Quota != nil {
|
||||
meta.QuotaConfigJSON = item.Quota
|
||||
meta.QuotaConfigUpdatedAt = item.UpdatedAt
|
||||
changed = changed || applied
|
||||
}
|
||||
|
||||
if item.Cors != nil {
|
||||
@@ -1702,10 +1699,14 @@ func (c *SiteReplicationSys) PeerBucketMetadataUpdateHandler(ctx context.Context
|
||||
if compareCORSReplicationStates(localState, incoming) < 0 {
|
||||
meta.CorsConfigXML = bytes.Clone(corsConfigData)
|
||||
meta.CorsConfigUpdatedAt = item.UpdatedAt
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if err = globalBucketMetadataSys.saveMetadata(ctx, objectAPI, meta); err != nil {
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
if err = globalBucketMetadataSys.saveMetadata(ctx, objectAPI, &meta); err != nil {
|
||||
return err
|
||||
}
|
||||
unlock()
|
||||
@@ -1716,62 +1717,35 @@ func (c *SiteReplicationSys) PeerBucketMetadataUpdateHandler(ctx context.Context
|
||||
|
||||
// PeerBucketPolicyHandler - copies/deletes policy to local cluster.
|
||||
func (c *SiteReplicationSys) PeerBucketPolicyHandler(ctx context.Context, bucket string, policy *policy.BucketPolicy, updatedAt time.Time) error {
|
||||
// skip overwrite if local update is newer than peer update.
|
||||
if !updatedAt.IsZero() {
|
||||
if _, updateTm, err := globalBucketMetadataSys.GetPolicyConfig(bucket); err == nil && updateTm.After(updatedAt) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if policy != nil {
|
||||
configData, err := json.Marshal(policy)
|
||||
var err error
|
||||
data, err = canonicalBucketPolicy(policy)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
|
||||
_, err = globalBucketMetadataSys.Update(ctx, bucket, bucketPolicyConfig, configData)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete the bucket policy
|
||||
_, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketPolicyConfig)
|
||||
_, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketPolicyConfig, data, false, false, &updatedAt)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PeerBucketTaggingHandler - copies/deletes tags to local cluster.
|
||||
func (c *SiteReplicationSys) PeerBucketTaggingHandler(ctx context.Context, bucket string, tags *string, updatedAt time.Time) error {
|
||||
// skip overwrite if local update is newer than peer update.
|
||||
if !updatedAt.IsZero() {
|
||||
if _, updateTm, err := globalBucketMetadataSys.GetTaggingConfig(bucket); err == nil && updateTm.After(updatedAt) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if tags != nil {
|
||||
configData, err := base64.StdEncoding.DecodeString(*tags)
|
||||
var err error
|
||||
data, err = base64.StdEncoding.DecodeString(*tags)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
_, err = globalBucketMetadataSys.Update(ctx, bucket, bucketTaggingConfig, configData)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete the tags
|
||||
_, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketTaggingConfig)
|
||||
_, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketTaggingConfig, data, false, false, &updatedAt)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1797,51 +1771,32 @@ func (c *SiteReplicationSys) peerBucketObjectLockConfigItem(ctx context.Context,
|
||||
|
||||
// PeerBucketObjectLockConfigHandler - sets object lock on local bucket.
|
||||
func (c *SiteReplicationSys) PeerBucketObjectLockConfigHandler(ctx context.Context, bucket string, objectLockData *string, updatedAt time.Time) error {
|
||||
var data []byte
|
||||
if objectLockData != nil {
|
||||
// skip overwrite if local update is newer than peer update.
|
||||
if !updatedAt.IsZero() {
|
||||
if _, updateTm, err := globalBucketMetadataSys.GetObjectLockConfig(bucket); err == nil && updateTm.After(updatedAt) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
configData, err := base64.StdEncoding.DecodeString(*objectLockData)
|
||||
var err error
|
||||
data, err = base64.StdEncoding.DecodeString(*objectLockData)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
_, err = globalBucketMetadataSys.Update(ctx, bucket, objectLockConfig, configData)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, objectLockConfig, data, false, false, &updatedAt)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PeerBucketSSEConfigHandler - copies/deletes SSE config to local cluster.
|
||||
func (c *SiteReplicationSys) PeerBucketSSEConfigHandler(ctx context.Context, bucket string, sseConfig *string, updatedAt time.Time) error {
|
||||
// skip overwrite if local update is newer than peer update.
|
||||
if !updatedAt.IsZero() {
|
||||
if _, updateTm, err := globalBucketMetadataSys.GetSSEConfig(bucket); err == nil && updateTm.After(updatedAt) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if sseConfig != nil {
|
||||
configData, err := base64.StdEncoding.DecodeString(*sseConfig)
|
||||
var err error
|
||||
data, err = base64.StdEncoding.DecodeString(*sseConfig)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
_, err = globalBucketMetadataSys.Update(ctx, bucket, bucketSSEConfig, configData)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete sse config
|
||||
_, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketSSEConfig)
|
||||
_, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketSSEConfig, data, false, false, &updatedAt)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
@@ -2046,7 +2001,7 @@ func applyBucketCORSMetadata(ctx context.Context, objectAPI ObjectLayer, bucket
|
||||
|
||||
meta.CorsConfigXML = bytes.Clone(configData)
|
||||
meta.CorsConfigUpdatedAt = updatedAt
|
||||
if err = globalBucketMetadataSys.saveMetadata(ctx, objectAPI, meta); err != nil {
|
||||
if err = globalBucketMetadataSys.saveMetadata(ctx, objectAPI, &meta); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
unlock()
|
||||
@@ -2078,32 +2033,18 @@ func (c *SiteReplicationSys) PeerBucketCorsConfigHandler(ctx context.Context, bu
|
||||
|
||||
// PeerBucketQuotaConfigHandler - copies/deletes policy to local cluster.
|
||||
func (c *SiteReplicationSys) PeerBucketQuotaConfigHandler(ctx context.Context, bucket string, quota *madmin.BucketQuota, updatedAt time.Time) error {
|
||||
// skip overwrite if local update is newer than peer update.
|
||||
if !updatedAt.IsZero() {
|
||||
if _, updateTm, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket); err == nil && updateTm.After(updatedAt) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if quota != nil {
|
||||
quotaData, err := json.Marshal(quota)
|
||||
var err error
|
||||
data, err = json.Marshal(quota)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
|
||||
if _, err = globalBucketMetadataSys.Update(ctx, bucket, bucketQuotaConfigFile, quotaData); err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete the bucket policy
|
||||
_, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketQuotaConfigFile)
|
||||
_, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketQuotaConfigFile, data, false, false, &updatedAt)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2201,10 +2142,14 @@ func (c *SiteReplicationSys) syncToAllPeers(ctx context.Context, addOpts madmin.
|
||||
if err != nil && !errors.Is(err, errConfigNotFound) {
|
||||
return errSRBackendIssue(err)
|
||||
}
|
||||
if err := ensureBucketMetadataCreated(ctx, objAPI, &meta); err != nil {
|
||||
logBucketConfigReplication(ctx, bucket, "initial-sync", "indeterminate", time.Time{}, meta.Created, err.Error())
|
||||
return errSRBackendIssue(err)
|
||||
}
|
||||
|
||||
opts := MakeBucketOptions{
|
||||
LockEnabled: meta.ObjectLocking(),
|
||||
CreatedAt: bucketInfo.Created.UTC(),
|
||||
CreatedAt: meta.Created.UTC(),
|
||||
}
|
||||
|
||||
// Now call the MakeBucketHook on existing bucket - this will
|
||||
@@ -2213,57 +2158,17 @@ func (c *SiteReplicationSys) syncToAllPeers(ctx context.Context, addOpts madmin.
|
||||
return errSRBucketConfigError(err)
|
||||
}
|
||||
|
||||
// Replicate bucket policy if present.
|
||||
policyJSON, tm := meta.PolicyConfigJSON, meta.PolicyConfigUpdatedAt
|
||||
if len(policyJSON) > 0 {
|
||||
err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypePolicy,
|
||||
Bucket: bucket,
|
||||
Policy: policyJSON,
|
||||
UpdatedAt: tm,
|
||||
})
|
||||
// Versioning is bootstrapped by MakeBucketHook and then reconciled by
|
||||
// heal. Preserve the existing initial-sync fields during rolling upgrades.
|
||||
for _, file := range []string{bucketPolicyConfig, bucketTaggingConfig, objectLockConfig, bucketSSEConfig, bucketQuotaConfigFile} {
|
||||
event, send, err := initialBucketConfigReplicationEvent(meta, file)
|
||||
if err != nil {
|
||||
return errSRBucketMetaError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Replicate bucket tags if present.
|
||||
tagCfg, tm := meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt
|
||||
if len(tagCfg) > 0 {
|
||||
tagCfgStr := base64.StdEncoding.EncodeToString(tagCfg)
|
||||
err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypeTags,
|
||||
Bucket: bucket,
|
||||
Tags: &tagCfgStr,
|
||||
UpdatedAt: tm,
|
||||
})
|
||||
if err != nil {
|
||||
return errSRBucketMetaError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Replicate object-lock config if present.
|
||||
objLockCfgData, tm := meta.ObjectLockConfigXML, meta.ObjectLockConfigUpdatedAt
|
||||
if len(objLockCfgData) > 0 {
|
||||
objLockStr := base64.StdEncoding.EncodeToString(objLockCfgData)
|
||||
err = c.BucketMetaHook(ctx, newSRBucketObjectLockMeta(bucket, &objLockStr, tm))
|
||||
if err != nil {
|
||||
return errSRBucketMetaError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Replicate existing bucket bucket encryption settings
|
||||
sseConfigData, tm := meta.EncryptionConfigXML, meta.EncryptionConfigUpdatedAt
|
||||
if len(sseConfigData) > 0 {
|
||||
sseConfigStr := base64.StdEncoding.EncodeToString(sseConfigData)
|
||||
err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypeSSEConfig,
|
||||
Bucket: bucket,
|
||||
SSEConfig: &sseConfigStr,
|
||||
UpdatedAt: tm,
|
||||
})
|
||||
if err != nil {
|
||||
return errSRBucketMetaError(err)
|
||||
if send {
|
||||
if err := c.BucketMetaHook(ctx, event); err != nil {
|
||||
return errSRBucketMetaError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2275,20 +2180,6 @@ func (c *SiteReplicationSys) syncToAllPeers(ctx context.Context, addOpts madmin.
|
||||
}
|
||||
}
|
||||
|
||||
// Replicate existing bucket quotas settings
|
||||
quotaConfigJSON, tm := meta.QuotaConfigJSON, meta.QuotaConfigUpdatedAt
|
||||
if len(quotaConfigJSON) > 0 {
|
||||
err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypeQuotaConfig,
|
||||
Bucket: bucket,
|
||||
Quota: quotaConfigJSON,
|
||||
UpdatedAt: tm,
|
||||
})
|
||||
if err != nil {
|
||||
return errSRBucketMetaError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Replicate ILM expiry rules if needed
|
||||
if addOpts.ReplicateILMExpiry && (meta.lifecycleConfig != nil && meta.lifecycleConfig.HasExpiry()) {
|
||||
var expLclCfg lifecycle.Lifecycle
|
||||
@@ -3900,18 +3791,19 @@ func isBktPolicyReplicated(total int, policies []*policy.BucketPolicy) bool {
|
||||
return false
|
||||
}
|
||||
// check if policies match between sites
|
||||
var prev *policy.BucketPolicy
|
||||
for i, p := range policies {
|
||||
var prev []byte
|
||||
first := true
|
||||
for _, p := range policies {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
prev = p
|
||||
continue
|
||||
}
|
||||
if !prev.Equals(*p) {
|
||||
// Heal treats statement/set permutations as the same effective state.
|
||||
// Status must agree even when an upgraded peer retains legacy bytes.
|
||||
key, err := canonicalBucketPolicy(p)
|
||||
if err != nil || !first && !bytes.Equal(prev, key) {
|
||||
return false
|
||||
}
|
||||
prev, first = key, false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -4073,6 +3965,8 @@ func (c *SiteReplicationSys) SiteReplicationMetaInfo(ctx context.Context, objAPI
|
||||
tagCfgStr := base64.StdEncoding.EncodeToString(meta.TaggingConfigXML)
|
||||
bms.Tags = &tagCfgStr
|
||||
bms.TagConfigUpdatedAt = meta.TaggingConfigUpdatedAt
|
||||
} else if globalSiteReplicationMetadataTombstones && !meta.Created.IsZero() && meta.TaggingConfigUpdatedAt.After(meta.Created) {
|
||||
bms.TagConfigUpdatedAt = meta.TaggingConfigUpdatedAt
|
||||
}
|
||||
|
||||
if len(meta.VersioningConfigXML) > 0 {
|
||||
@@ -4091,12 +3985,16 @@ func (c *SiteReplicationSys) SiteReplicationMetaInfo(ctx context.Context, objAPI
|
||||
quotaConfigStr := base64.StdEncoding.EncodeToString(meta.QuotaConfigJSON)
|
||||
bms.QuotaConfig = "aConfigStr
|
||||
bms.QuotaConfigUpdatedAt = meta.QuotaConfigUpdatedAt
|
||||
} else if globalSiteReplicationMetadataTombstones && !meta.Created.IsZero() && meta.QuotaConfigUpdatedAt.After(meta.Created) {
|
||||
bms.QuotaConfigUpdatedAt = meta.QuotaConfigUpdatedAt
|
||||
}
|
||||
|
||||
if len(meta.EncryptionConfigXML) > 0 {
|
||||
sseConfigStr := base64.StdEncoding.EncodeToString(meta.EncryptionConfigXML)
|
||||
bms.SSEConfig = &sseConfigStr
|
||||
bms.SSEConfigUpdatedAt = meta.EncryptionConfigUpdatedAt
|
||||
} else if globalSiteReplicationMetadataTombstones && !meta.Created.IsZero() && meta.EncryptionConfigUpdatedAt.After(meta.Created) {
|
||||
bms.SSEConfigUpdatedAt = meta.EncryptionConfigUpdatedAt
|
||||
}
|
||||
|
||||
bms.CorsConfigUpdatedAt = meta.CorsConfigUpdatedAt
|
||||
@@ -4950,366 +4848,23 @@ func (c *SiteReplicationSys) healBucketILMExpiry(ctx context.Context, objAPI Obj
|
||||
}
|
||||
|
||||
func (c *SiteReplicationSys) healTagMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
|
||||
bs := info.BucketStats[bucket]
|
||||
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
latestID, latestPeerName string
|
||||
lastUpdate time.Time
|
||||
latestTaggingConfig *string
|
||||
)
|
||||
|
||||
for dID, ss := range bs {
|
||||
if lastUpdate.IsZero() {
|
||||
lastUpdate = ss.meta.TagConfigUpdatedAt
|
||||
latestID = dID
|
||||
latestTaggingConfig = ss.meta.Tags
|
||||
}
|
||||
// avoid considering just created buckets as latest. Perhaps this site
|
||||
// just joined cluster replication and yet to be sync'd
|
||||
if ss.meta.CreatedAt.Equal(ss.meta.TagConfigUpdatedAt) {
|
||||
continue
|
||||
}
|
||||
if ss.meta.TagConfigUpdatedAt.After(lastUpdate) {
|
||||
lastUpdate = ss.meta.TagConfigUpdatedAt
|
||||
latestID = dID
|
||||
latestTaggingConfig = ss.meta.Tags
|
||||
}
|
||||
}
|
||||
latestPeerName = info.Sites[latestID].Name
|
||||
var latestTaggingConfigBytes []byte
|
||||
var err error
|
||||
if latestTaggingConfig != nil {
|
||||
latestTaggingConfigBytes, err = base64.StdEncoding.DecodeString(*latestTaggingConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for dID, bStatus := range bs {
|
||||
if !bStatus.TagMismatch {
|
||||
continue
|
||||
}
|
||||
if isBucketMetadataEqual(latestTaggingConfig, bStatus.meta.Tags) {
|
||||
continue
|
||||
}
|
||||
if dID == globalDeploymentID() {
|
||||
if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketTaggingConfig, latestTaggingConfigBytes); err != nil {
|
||||
replLogIf(ctx, fmt.Errorf("Unable to heal tagging metadata from peer site %s : %w", latestPeerName, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
admClient, err := c.getAdminClient(ctx, dID)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
peerName := info.Sites[dID].Name
|
||||
err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypeTags,
|
||||
Bucket: bucket,
|
||||
Tags: latestTaggingConfig,
|
||||
})
|
||||
if err != nil {
|
||||
replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
|
||||
fmt.Errorf("Unable to heal tagging metadata for peer %s from peer %s : %w", peerName, latestPeerName, err)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return c.healBucketConfig(ctx, bucket, bucketTaggingConfig, info)
|
||||
}
|
||||
|
||||
func (c *SiteReplicationSys) healBucketPolicies(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
|
||||
bs := info.BucketStats[bucket]
|
||||
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
latestID, latestPeerName string
|
||||
lastUpdate time.Time
|
||||
latestIAMPolicy json.RawMessage
|
||||
)
|
||||
|
||||
for dID, ss := range bs {
|
||||
if lastUpdate.IsZero() {
|
||||
lastUpdate = ss.meta.PolicyUpdatedAt
|
||||
latestID = dID
|
||||
latestIAMPolicy = ss.meta.Policy
|
||||
}
|
||||
// avoid considering just created buckets as latest. Perhaps this site
|
||||
// just joined cluster replication and yet to be sync'd
|
||||
if ss.meta.CreatedAt.Equal(ss.meta.PolicyUpdatedAt) {
|
||||
continue
|
||||
}
|
||||
if ss.meta.PolicyUpdatedAt.After(lastUpdate) {
|
||||
lastUpdate = ss.meta.PolicyUpdatedAt
|
||||
latestID = dID
|
||||
latestIAMPolicy = ss.meta.Policy
|
||||
}
|
||||
}
|
||||
latestPeerName = info.Sites[latestID].Name
|
||||
for dID, bStatus := range bs {
|
||||
if !bStatus.PolicyMismatch {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(string(latestIAMPolicy), string(bStatus.meta.Policy)) {
|
||||
continue
|
||||
}
|
||||
if dID == globalDeploymentID() {
|
||||
if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketPolicyConfig, latestIAMPolicy); err != nil {
|
||||
replLogIf(ctx, fmt.Errorf("Unable to heal bucket policy metadata from peer site %s : %w", latestPeerName, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
admClient, err := c.getAdminClient(ctx, dID)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
peerName := info.Sites[dID].Name
|
||||
if err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypePolicy,
|
||||
Bucket: bucket,
|
||||
Policy: latestIAMPolicy,
|
||||
UpdatedAt: lastUpdate,
|
||||
}); err != nil {
|
||||
replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
|
||||
fmt.Errorf("Unable to heal bucket policy metadata for peer %s from peer %s : %w",
|
||||
peerName, latestPeerName, err)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return c.healBucketConfig(ctx, bucket, bucketPolicyConfig, info)
|
||||
}
|
||||
|
||||
func (c *SiteReplicationSys) healBucketQuotaConfig(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
|
||||
bs := info.BucketStats[bucket]
|
||||
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
latestID, latestPeerName string
|
||||
lastUpdate time.Time
|
||||
latestQuotaConfig *string
|
||||
latestQuotaConfigBytes []byte
|
||||
)
|
||||
|
||||
for dID, ss := range bs {
|
||||
if lastUpdate.IsZero() {
|
||||
lastUpdate = ss.meta.QuotaConfigUpdatedAt
|
||||
latestID = dID
|
||||
latestQuotaConfig = ss.meta.QuotaConfig
|
||||
}
|
||||
// avoid considering just created buckets as latest. Perhaps this site
|
||||
// just joined cluster replication and yet to be sync'd
|
||||
if ss.meta.CreatedAt.Equal(ss.meta.QuotaConfigUpdatedAt) {
|
||||
continue
|
||||
}
|
||||
if ss.meta.QuotaConfigUpdatedAt.After(lastUpdate) {
|
||||
lastUpdate = ss.meta.QuotaConfigUpdatedAt
|
||||
latestID = dID
|
||||
latestQuotaConfig = ss.meta.QuotaConfig
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if latestQuotaConfig != nil {
|
||||
latestQuotaConfigBytes, err = base64.StdEncoding.DecodeString(*latestQuotaConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
latestPeerName = info.Sites[latestID].Name
|
||||
for dID, bStatus := range bs {
|
||||
if !bStatus.QuotaCfgMismatch {
|
||||
continue
|
||||
}
|
||||
if isBucketMetadataEqual(latestQuotaConfig, bStatus.meta.QuotaConfig) {
|
||||
continue
|
||||
}
|
||||
if dID == globalDeploymentID() {
|
||||
if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketQuotaConfigFile, latestQuotaConfigBytes); err != nil {
|
||||
replLogIf(ctx, fmt.Errorf("Unable to heal quota metadata from peer site %s : %w", latestPeerName, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
admClient, err := c.getAdminClient(ctx, dID)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
peerName := info.Sites[dID].Name
|
||||
|
||||
if err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypeQuotaConfig,
|
||||
Bucket: bucket,
|
||||
Quota: latestQuotaConfigBytes,
|
||||
UpdatedAt: lastUpdate,
|
||||
}); err != nil {
|
||||
replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
|
||||
fmt.Errorf("Unable to heal quota config metadata for peer %s from peer %s : %w",
|
||||
peerName, latestPeerName, err)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return c.healBucketConfig(ctx, bucket, bucketQuotaConfigFile, info)
|
||||
}
|
||||
|
||||
func (c *SiteReplicationSys) healVersioningMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
latestID, latestPeerName string
|
||||
lastUpdate time.Time
|
||||
latestVersioningConfig *string
|
||||
)
|
||||
|
||||
bs := info.BucketStats[bucket]
|
||||
for dID, ss := range bs {
|
||||
if lastUpdate.IsZero() {
|
||||
lastUpdate = ss.meta.VersioningConfigUpdatedAt
|
||||
latestID = dID
|
||||
latestVersioningConfig = ss.meta.Versioning
|
||||
}
|
||||
// avoid considering just created buckets as latest. Perhaps this site
|
||||
// just joined cluster replication and yet to be sync'd
|
||||
if ss.meta.CreatedAt.Equal(ss.meta.VersioningConfigUpdatedAt) {
|
||||
continue
|
||||
}
|
||||
if ss.meta.VersioningConfigUpdatedAt.After(lastUpdate) {
|
||||
lastUpdate = ss.meta.VersioningConfigUpdatedAt
|
||||
latestID = dID
|
||||
latestVersioningConfig = ss.meta.Versioning
|
||||
}
|
||||
}
|
||||
|
||||
latestPeerName = info.Sites[latestID].Name
|
||||
var latestVersioningConfigBytes []byte
|
||||
var err error
|
||||
if latestVersioningConfig != nil {
|
||||
latestVersioningConfigBytes, err = base64.StdEncoding.DecodeString(*latestVersioningConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for dID, bStatus := range bs {
|
||||
if !bStatus.VersioningConfigMismatch {
|
||||
continue
|
||||
}
|
||||
if isBucketMetadataEqual(latestVersioningConfig, bStatus.meta.Versioning) {
|
||||
continue
|
||||
}
|
||||
if dID == globalDeploymentID() {
|
||||
if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketVersioningConfig, latestVersioningConfigBytes); err != nil {
|
||||
replLogIf(ctx, fmt.Errorf("Unable to heal versioning metadata from peer site %s : %w", latestPeerName, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
admClient, err := c.getAdminClient(ctx, dID)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
peerName := info.Sites[dID].Name
|
||||
err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypeVersionConfig,
|
||||
Bucket: bucket,
|
||||
Versioning: latestVersioningConfig,
|
||||
UpdatedAt: lastUpdate,
|
||||
})
|
||||
if err != nil {
|
||||
replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
|
||||
fmt.Errorf("Unable to heal versioning config metadata for peer %s from peer %s : %w",
|
||||
peerName, latestPeerName, err)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return c.healBucketConfig(ctx, bucket, bucketVersioningConfig, info)
|
||||
}
|
||||
|
||||
func (c *SiteReplicationSys) healSSEMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
latestID, latestPeerName string
|
||||
lastUpdate time.Time
|
||||
latestSSEConfig *string
|
||||
)
|
||||
|
||||
bs := info.BucketStats[bucket]
|
||||
for dID, ss := range bs {
|
||||
if lastUpdate.IsZero() {
|
||||
lastUpdate = ss.meta.SSEConfigUpdatedAt
|
||||
latestID = dID
|
||||
latestSSEConfig = ss.meta.SSEConfig
|
||||
}
|
||||
// avoid considering just created buckets as latest. Perhaps this site
|
||||
// just joined cluster replication and yet to be sync'd
|
||||
if ss.meta.CreatedAt.Equal(ss.meta.SSEConfigUpdatedAt) {
|
||||
continue
|
||||
}
|
||||
if ss.meta.SSEConfigUpdatedAt.After(lastUpdate) {
|
||||
lastUpdate = ss.meta.SSEConfigUpdatedAt
|
||||
latestID = dID
|
||||
latestSSEConfig = ss.meta.SSEConfig
|
||||
}
|
||||
}
|
||||
|
||||
latestPeerName = info.Sites[latestID].Name
|
||||
var latestSSEConfigBytes []byte
|
||||
var err error
|
||||
if latestSSEConfig != nil {
|
||||
latestSSEConfigBytes, err = base64.StdEncoding.DecodeString(*latestSSEConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for dID, bStatus := range bs {
|
||||
if !bStatus.SSEConfigMismatch {
|
||||
continue
|
||||
}
|
||||
if isBucketMetadataEqual(latestSSEConfig, bStatus.meta.SSEConfig) {
|
||||
continue
|
||||
}
|
||||
if dID == globalDeploymentID() {
|
||||
if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketSSEConfig, latestSSEConfigBytes); err != nil {
|
||||
replLogIf(ctx, fmt.Errorf("Unable to heal sse metadata from peer site %s : %w", latestPeerName, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
admClient, err := c.getAdminClient(ctx, dID)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
peerName := info.Sites[dID].Name
|
||||
err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
|
||||
Type: madmin.SRBucketMetaTypeSSEConfig,
|
||||
Bucket: bucket,
|
||||
SSEConfig: latestSSEConfig,
|
||||
UpdatedAt: lastUpdate,
|
||||
})
|
||||
if err != nil {
|
||||
replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
|
||||
fmt.Errorf("Unable to heal SSE config metadata for peer %s from peer %s : %w",
|
||||
peerName, latestPeerName, err)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return c.healBucketConfig(ctx, bucket, bucketSSEConfig, info)
|
||||
}
|
||||
|
||||
func latestCORSConfig(bs map[string]srBucketStatsSummary) (latestID string, latest corsReplicationState, ok bool) {
|
||||
@@ -5377,73 +4932,7 @@ func (c *SiteReplicationSys) healCORSMetadata(ctx context.Context, objAPI Object
|
||||
}
|
||||
|
||||
func (c *SiteReplicationSys) healOLockConfigMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
|
||||
bs := info.BucketStats[bucket]
|
||||
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
if !c.enabled {
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
latestID, latestPeerName string
|
||||
lastUpdate time.Time
|
||||
latestObjLockConfig *string
|
||||
)
|
||||
|
||||
for dID, ss := range bs {
|
||||
if lastUpdate.IsZero() {
|
||||
lastUpdate = ss.meta.ObjectLockConfigUpdatedAt
|
||||
latestID = dID
|
||||
latestObjLockConfig = ss.meta.ObjectLockConfig
|
||||
}
|
||||
// avoid considering just created buckets as latest. Perhaps this site
|
||||
// just joined cluster replication and yet to be sync'd
|
||||
if ss.meta.CreatedAt.Equal(ss.meta.ObjectLockConfigUpdatedAt) {
|
||||
continue
|
||||
}
|
||||
if ss.meta.ObjectLockConfig != nil && ss.meta.ObjectLockConfigUpdatedAt.After(lastUpdate) {
|
||||
lastUpdate = ss.meta.ObjectLockConfigUpdatedAt
|
||||
latestID = dID
|
||||
latestObjLockConfig = ss.meta.ObjectLockConfig
|
||||
}
|
||||
}
|
||||
latestPeerName = info.Sites[latestID].Name
|
||||
var latestObjLockConfigBytes []byte
|
||||
var err error
|
||||
if latestObjLockConfig != nil {
|
||||
latestObjLockConfigBytes, err = base64.StdEncoding.DecodeString(*latestObjLockConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for dID, bStatus := range bs {
|
||||
if !bStatus.OLockConfigMismatch {
|
||||
continue
|
||||
}
|
||||
if isBucketMetadataEqual(latestObjLockConfig, bStatus.meta.ObjectLockConfig) {
|
||||
continue
|
||||
}
|
||||
if dID == globalDeploymentID() {
|
||||
if _, err := globalBucketMetadataSys.Update(ctx, bucket, objectLockConfig, latestObjLockConfigBytes); err != nil {
|
||||
replLogIf(ctx, fmt.Errorf("Unable to heal objectlock config metadata from peer site %s : %w", latestPeerName, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
admClient, err := c.getAdminClient(ctx, dID)
|
||||
if err != nil {
|
||||
return wrapSRErr(err)
|
||||
}
|
||||
peerName := info.Sites[dID].Name
|
||||
err = admClient.SRPeerReplicateBucketMeta(ctx, newSRBucketObjectLockMeta(bucket, latestObjLockConfig, lastUpdate))
|
||||
if err != nil {
|
||||
replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
|
||||
fmt.Errorf("Unable to heal object lock config metadata for peer %s from peer %s : %w",
|
||||
peerName, latestPeerName, err)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return c.healBucketConfig(ctx, bucket, objectLockConfig, info)
|
||||
}
|
||||
|
||||
func (c *SiteReplicationSys) purgeDeletedBucket(ctx context.Context, objAPI ObjectLayer, bucket string) {
|
||||
@@ -5680,17 +5169,6 @@ func (c *SiteReplicationSys) healBucketReplicationConfig(ctx context.Context, ob
|
||||
return nil
|
||||
}
|
||||
|
||||
func isBucketMetadataEqual(one, two *string) bool {
|
||||
switch {
|
||||
case one == nil && two == nil:
|
||||
return true
|
||||
case one == nil || two == nil:
|
||||
return false
|
||||
default:
|
||||
return *one == *two
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SiteReplicationSys) healIAMSystem(ctx context.Context, objAPI ObjectLayer) error {
|
||||
info, err := c.siteReplicationStatus(ctx, objAPI, madmin.SRStatusOptions{
|
||||
Users: true,
|
||||
|
||||
@@ -133,28 +133,3 @@ func TestSRBucketMetaCorsRoundTrip(t *testing.T) {
|
||||
t.Fatalf("expected nil Cors for deletion, got %q", *gotDel.Cors)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsBucketMetadataEqualCors covers the pointer-comparison helper used by
|
||||
// the CORS heal path to decide whether a peer already holds the latest config.
|
||||
func TestIsBucketMetadataEqualCors(t *testing.T) {
|
||||
a := base64.StdEncoding.EncodeToString([]byte("config-a"))
|
||||
b := base64.StdEncoding.EncodeToString([]byte("config-b"))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
one *string
|
||||
two *string
|
||||
want bool
|
||||
}{
|
||||
{"both nil", nil, nil, true},
|
||||
{"one nil", &a, nil, false},
|
||||
{"other nil", nil, &b, false},
|
||||
{"equal", &a, &a, true},
|
||||
{"different", &a, &b, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isBucketMetadataEqual(tc.one, tc.two); got != tc.want {
|
||||
t.Errorf("%s: got %v want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
# Data-usage v9 retirement fixture
|
||||
|
||||
Generated using unmodified `scanDataFolder` and `dataUsageCache.serializeTo` from
|
||||
Server commit `89637554d60c27cfc51d2281d0a4fe15e415f06d` (Go 1.27.1, darwin/arm64).
|
||||
The scanner visits three real local files; its size callback supplies synthetic
|
||||
version/delete-marker/remote-tier summaries, following `TestDataUsageCacheSerialize`.
|
||||
It is a scanner/cache compatibility fixture, not a distributed object-store test.
|
||||
|
||||
The old scanner counts 74,962 bytes, 3 objects, 5 versions and 2 delete markers.
|
||||
Its nonzero retired `hts` totals 9,426 bytes. Remote tier `COLD` contains 65,536
|
||||
bytes, one version and one object. The cache includes nested children, both
|
||||
histograms, a fixed timestamp and scanner cycle 42. The JSON is the old scanner's
|
||||
complete expected cache (including `HotTierSize`, which the new reader ignores).
|
||||
|
||||
Binary SHA-256: `4c9c7e94cea7758fe9b498b19f639188764b2787582783e6cc950f2c44d52a8b`.
|
||||
|
||||
To regenerate, create a detached worktree at that exact source commit, copy
|
||||
`generate.go.txt` to `cmd/retirement-fixture_test.go`, and run:
|
||||
|
||||
```sh
|
||||
SILO_RETIRE_FIXTURE_DIR=/absolute/output/directory go test ./cmd -run '^TestGenerateAccessRetirementV9Fixture$' -count=1 -v
|
||||
```
|
||||
|
||||
The historical production writer adds the version byte, compresses with zstd
|
||||
and encodes msgp, including the nonzero `hts` field. Do not regenerate using the
|
||||
retired implementation or by changing the header of a v8 payload. Map order may
|
||||
change serialized bytes across regeneration; compare the decoded full cache.
|
||||
BIN
Binary file not shown.
+118
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"Info": {
|
||||
"Name": "/",
|
||||
"NextCycle": 42,
|
||||
"LastUpdate": "2026-09-15T00:00:00Z",
|
||||
"SkipHealing": true
|
||||
},
|
||||
"Cache": {
|
||||
"/": {
|
||||
"Children": {
|
||||
"v9-bucket": {}
|
||||
},
|
||||
"Size": 0,
|
||||
"HotTierSize": 0,
|
||||
"Objects": 0,
|
||||
"Versions": 0,
|
||||
"DeleteMarkers": 0,
|
||||
"ObjSizes": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"ObjVersions": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"AllTierStats": null,
|
||||
"Compacted": false
|
||||
},
|
||||
"v9-bucket": {
|
||||
"Children": {
|
||||
"v9-bucket/nested": {}
|
||||
},
|
||||
"Size": 1234,
|
||||
"HotTierSize": 1234,
|
||||
"Objects": 1,
|
||||
"Versions": 1,
|
||||
"DeleteMarkers": 0,
|
||||
"ObjSizes": [
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"ObjVersions": [
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"AllTierStats": null,
|
||||
"Compacted": false
|
||||
},
|
||||
"v9-bucket/nested": {
|
||||
"Children": null,
|
||||
"Size": 73728,
|
||||
"HotTierSize": 8192,
|
||||
"Objects": 2,
|
||||
"Versions": 4,
|
||||
"DeleteMarkers": 2,
|
||||
"ObjSizes": [
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"ObjVersions": [
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"AllTierStats": {
|
||||
"Tiers": {
|
||||
"COLD": {
|
||||
"TotalSize": 65536,
|
||||
"NumVersions": 1,
|
||||
"NumObjects": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"Compacted": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/cachevalue"
|
||||
)
|
||||
|
||||
// Run only on 89637554d, before access-tiering is removed. This uses the real
|
||||
// folder scanner and v9 serializer with synthetic file-size/tier summaries,
|
||||
// following TestDataUsageCacheSerialize; it does not relabel a v8 payload.
|
||||
func TestGenerateAccessRetirementV9Fixture(t *testing.T) {
|
||||
if dataUsageCacheVerCurrent != 9 { t.Fatal("requires the historical v9 writer") }
|
||||
base := t.TempDir()
|
||||
const bucket = "v9-bucket"
|
||||
createUsageTestFiles(t, base, bucket, []usageTestFile{
|
||||
{name: "root", size: 1234},
|
||||
{name: "nested/versions", size: 8192},
|
||||
{name: "nested/remote", size: 65536},
|
||||
})
|
||||
getSize := func(item scannerItem) (s sizeSummary, err error) {
|
||||
if item.Typ&os.ModeDir != 0 { return s, nil }
|
||||
info, err := os.Stat(item.Path)
|
||||
if err != nil { return s, err }
|
||||
s.totalSize, s.hotTierSize, s.versions = info.Size(), info.Size(), 1
|
||||
if filepath.Base(item.Path) == "versions" { s.versions, s.deleteMarkers = 3, 2 }
|
||||
if filepath.Base(item.Path) == "remote" {
|
||||
s.hotTierSize = 0
|
||||
s.tiers = map[string]tierStats{"COLD": {TotalSize: uint64(info.Size()), NumVersions: 1, NumObjects: 1}}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
xls := xlStorage{drivePath: base, diskInfoCache: cachevalue.New[DiskInfo]()}
|
||||
xls.diskInfoCache.InitOnce(time.Second, cachevalue.Opts{}, func(context.Context) (DiskInfo,error) {
|
||||
return DiskInfo{Total: 1<<40, Free: 1<<40}, nil
|
||||
})
|
||||
cache, err := scanDataFolder(t.Context(), nil, &xls, dataUsageCache{Info:dataUsageCacheInfo{Name:bucket, SkipHealing:true}}, getSize, 0, func()bool{return false})
|
||||
if err != nil { t.Fatal(err) }
|
||||
root := *cache.find(bucket)
|
||||
cache.replace(dataUsageRoot, "", dataUsageEntry{})
|
||||
cache.replace(bucket, dataUsageRoot, root)
|
||||
cache.Info.Name = dataUsageRoot
|
||||
cache.Info.LastUpdate = time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC)
|
||||
cache.Info.NextCycle = 42
|
||||
flat := cache.flatten(*cache.root())
|
||||
if flat.Size != 74962 || flat.Objects != 3 || flat.Versions != 5 || flat.DeleteMarkers != 2 || flat.HotTierSize != 9426 {
|
||||
t.Fatalf("unexpected scanner fixture: %+v", flat)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := cache.serializeTo(&buf); err != nil { t.Fatal(err) }
|
||||
dir := os.Getenv("SILO_RETIRE_FIXTURE_DIR")
|
||||
if dir == "" { t.Fatal("SILO_RETIRE_FIXTURE_DIR required") }
|
||||
if err := os.MkdirAll(dir, 0755); err != nil { t.Fatal(err) }
|
||||
if err := os.WriteFile(filepath.Join(dir,"data-usage-v9.bin"),buf.Bytes(),0644);err != nil{t.Fatal(err)}
|
||||
expected, err := json.MarshalIndent(cache,""," ")
|
||||
if err != nil { t.Fatal(err) }
|
||||
if err := os.WriteFile(filepath.Join(dir,"data-usage-v9.json"),append(expected,'\n'),0644);err != nil{t.Fatal(err)}
|
||||
t.Logf("old scanner/v9 writer: bytes=%d size=%d objects=%d versions=%d markers=%d hts=%d",buf.Len(),flat.Size,flat.Objects,flat.Versions,flat.DeleteMarkers,flat.HotTierSize)
|
||||
}
|
||||
@@ -583,7 +583,6 @@ func (s *xlStorage) NSScanner(ctx context.Context, cache dataUsageCache, updates
|
||||
}
|
||||
|
||||
poolIdx, setIdx, _ := s.GetDiskLoc()
|
||||
hotPool, hotPoolOK := globalILMConfig.accessCfg().HotPool()
|
||||
|
||||
disks, err := objAPI.GetDisks(poolIdx, setIdx)
|
||||
if err != nil {
|
||||
@@ -593,7 +592,6 @@ func (s *xlStorage) NSScanner(ctx context.Context, cache dataUsageCache, updates
|
||||
cache.Info.updates = updates
|
||||
|
||||
dataUsageInfo, err := scanDataFolder(ctx, disks, s, cache, func(item scannerItem) (sizeSummary, error) {
|
||||
item.poolIdx = poolIdx
|
||||
// Look for `xl.meta/xl.json' at the leaf.
|
||||
if !strings.HasSuffix(item.Path, SlashSeparator+xlStorageFormatFile) &&
|
||||
!strings.HasSuffix(item.Path, SlashSeparator+xlStorageFormatFileV1) {
|
||||
@@ -657,9 +655,6 @@ func (s *xlStorage) NSScanner(ctx context.Context, cache dataUsageCache, updates
|
||||
sizeS.versions++
|
||||
}
|
||||
sizeS.totalSize += sz
|
||||
if hotPoolOK && poolIdx == hotPool {
|
||||
sizeS.hotTierSize += sz
|
||||
}
|
||||
|
||||
// Skip tier accounting if object version is a delete-marker or a free-version
|
||||
// tracking deleted transitioned objects
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Build both release architectures from the same verified upstream source.
|
||||
# The previous binary supplier stopped publishing current ARM64 builds.
|
||||
CURL_VERSION=8.22.0
|
||||
CURL_SHA256=f7ef3ae8a22e521f289803fe93543eb64c329b58aa73a9e224dfd915a2a5f4f7
|
||||
|
||||
case ${TARGETARCH:?TARGETARCH is required} in
|
||||
amd64) expected_arch=x86_64 ;;
|
||||
arm64) expected_arch=aarch64 ;;
|
||||
*) echo "Unsupported cURL architecture: ${TARGETARCH}" >&2; exit 1 ;;
|
||||
esac
|
||||
[ "$(apk --print-arch)" = "$expected_arch" ] || {
|
||||
echo "cURL must be built natively for ${TARGETARCH}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
apk add --no-cache --upgrade build-base curl ca-certificates \
|
||||
openssl-libs-static nghttp2-static libpsl-static libidn2-static \
|
||||
libunistring-static zlib-static brotli-static zstd-static libssh2-static
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
curl --fail --location --silent --show-error --retry 3 \
|
||||
--connect-timeout 15 --max-time 180 \
|
||||
"https://curl.se/download/curl-${CURL_VERSION}.tar.xz" -o "$work/curl.tar.xz"
|
||||
printf '%s %s\n' "$CURL_SHA256" "$work/curl.tar.xz" | sha256sum -c -
|
||||
tar -xJf "$work/curl.tar.xz" -C "$work"
|
||||
cd "$work/curl-${CURL_VERSION}"
|
||||
PKG_CONFIG="pkg-config --static" LDFLAGS="-static" ./configure \
|
||||
--disable-shared --enable-static --with-openssl --with-libssh2 \
|
||||
--with-nghttp2 --with-brotli --with-zstd --with-libidn2 --with-libpsl \
|
||||
--with-ca-bundle=/etc/ssl/certs/ca-certificates.crt --without-ca-path \
|
||||
--disable-ldap --disable-ldaps
|
||||
# libtool's -static only embeds libcurl; -all-static also embeds its dependencies.
|
||||
make -j"${CURL_BUILD_JOBS:-4}" LDFLAGS="-all-static"
|
||||
strip src/curl
|
||||
if readelf -l src/curl | grep -q INTERP || readelf -d src/curl | grep -q NEEDED; then
|
||||
echo "cURL still requires a dynamic loader or shared libraries" >&2
|
||||
exit 1
|
||||
fi
|
||||
src/curl --version
|
||||
mkdir -p /go/bin /go/share/curl
|
||||
cp src/curl /go/bin/curl
|
||||
cp COPYING /go/share/curl/COPYING
|
||||
apk info -v | sort > /go/share/curl/build-packages.txt
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Pin to v8.11.0 – the last release that includes curl-aarch64.
|
||||
# v8.17.0 (current latest) dropped the aarch64 build.
|
||||
STATIC_CURL_VERSION="v8.11.0"
|
||||
|
||||
case ${TARGETARCH:?TARGETARCH is required} in
|
||||
amd64)
|
||||
asset=curl-amd64
|
||||
expected=d18aa1f4e03b50b649491ca2c401cd8c5e89e72be91ff758952ad2ab5a83135d
|
||||
;;
|
||||
arm64)
|
||||
asset=curl-aarch64
|
||||
expected=1b050abd1669f9a2ac29b34eb022cdeafb271dce5a4fb57d8ef8fadff6d7be1f
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported static cURL architecture: ${TARGETARCH}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
curl --fail --location --silent --show-error --retry 3 \
|
||||
"https://github.com/moparisthebest/static-curl/releases/download/${STATIC_CURL_VERSION}/${asset}" \
|
||||
--output /go/bin/curl
|
||||
printf '%s %s\n' "${expected}" /go/bin/curl | sha256sum -c
|
||||
chmod +x /go/bin/curl
|
||||
@@ -226,149 +226,11 @@ aws s3api restore-object --bucket srcbucket \
|
||||
|
||||
Note that transition event notification is a Silo extension.
|
||||
|
||||
## 5. Access-based tiering between server pools
|
||||
|
||||
Silo can move frequently read objects to a faster server pool and return them
|
||||
to a slower pool after they become idle. This is different from remote ILM
|
||||
transition: the object remains a native local object and all versions move
|
||||
together.
|
||||
|
||||
Access tiering requires at least two server pools. Pool indices follow the
|
||||
order on the server command line:
|
||||
|
||||
~~~sh
|
||||
silo server /srv/nvme{1...4} /srv/hdd{1...8}
|
||||
# pool 0 (fast) pool 1 (slow)
|
||||
~~~
|
||||
|
||||
Server pools are erasure-coding expansion units, not individual drives. Each
|
||||
pool should consist of internally homogeneous media.
|
||||
|
||||
The feature is disabled by default. Configure the topology and safety limits
|
||||
with `mc admin config set`; ILM is a dynamic subsystem, so this applies without
|
||||
a restart. Environment variables (`MINIO_ILM_ACCESS_TIERING`,
|
||||
`MINIO_ILM_ACCESS_POOLS`, `MINIO_ILM_ACCESS_MAX_SIZE`,
|
||||
`MINIO_ILM_ACCESS_PROMOTE_WATERMARK`, `MINIO_ILM_ACCESS_BIN_WIDTH`,
|
||||
`MINIO_ILM_ACCESS_BINS`, `MINIO_ILM_ACCESS_FLUSH`,
|
||||
`MINIO_ILM_ACCESS_MIN_RESIDENCY`, `MINIO_ILM_ACCESS_WORKERS`,
|
||||
`MINIO_ILM_ACCESS_MAX_TRACKED`) are read at process start and override the
|
||||
stored config.
|
||||
|
||||
~~~sh
|
||||
mc admin config set local ilm \
|
||||
access_tiering=on \
|
||||
access_pools="0,1" \
|
||||
access_max_size="2TiB" \
|
||||
access_promote_watermark=85 \
|
||||
access_bin_width=1m \
|
||||
access_bins=12 \
|
||||
access_flush=1m \
|
||||
access_min_residency=24h \
|
||||
access_workers=10 \
|
||||
access_max_tracked=1000000
|
||||
~~~
|
||||
|
||||
The pool list is ordered hottest to coldest. With three or more pools,
|
||||
promotion always targets the first index and demotion always targets the last;
|
||||
intermediate pools are not hop targets. An access_max_size value of zero means
|
||||
no cluster-wide logical-byte cap. Promotion also stops when the hottest pool
|
||||
reaches access_promote_watermark.
|
||||
|
||||
New PUTs still land via the usual free-space pool picker; they are not steered
|
||||
onto the cold pool. Size the capacity pool larger than the hot pool so new
|
||||
objects tend to land there.
|
||||
|
||||
Add an AccessTransition to the bucket lifecycle XML:
|
||||
|
||||
~~~xml
|
||||
<LifecycleConfiguration>
|
||||
<AccessTierQuota>500GiB</AccessTierQuota>
|
||||
<Rule>
|
||||
<ID>hot-logs</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<And>
|
||||
<Prefix>logs/</Prefix>
|
||||
<ObjectSizeGreaterThan>65536</ObjectSizeGreaterThan>
|
||||
</And>
|
||||
</Filter>
|
||||
<AccessTransition>
|
||||
<Window>10m</Window>
|
||||
<PromoteAfterAccesses>100</PromoteAfterAccesses>
|
||||
<DemoteAfterAccesses>5</DemoteAfterAccesses>
|
||||
<DemoteAfterIdle>24h</DemoteAfterIdle>
|
||||
</AccessTransition>
|
||||
</Rule>
|
||||
</LifecycleConfiguration>
|
||||
~~~
|
||||
|
||||
This promotes a matching object after 100 successful GETs in 10 minutes. An
|
||||
object becomes eligible to return to the coldest configured pool only after
|
||||
access tiering has already moved it (the `x-minio-internal-ilm-atier` stamp),
|
||||
it has stayed put for the server-wide minimum residency, it has been idle at
|
||||
least 24 hours, and it has no more than 5 GETs in the window. Objects that
|
||||
landed on the hot pool via a normal PUT never demote. Prefix, tag, and
|
||||
object-size lifecycle filters are honored.
|
||||
|
||||
Access-based moves are a parallel path: they are not lifecycle `Eval` actions
|
||||
and do not appear in S3 prediction headers. If the same object is also due
|
||||
for age-based remote `Transition` or expiry, that scanner action wins and
|
||||
demotion discovery is skipped for that pass; promotions still run from the
|
||||
GET tracker. Site replication copies expiry rules only, same as remote
|
||||
Transition, so AccessTransition stays local to the cluster.
|
||||
|
||||
AccessTierQuota is an optional bucket-wide cap. Promotion checks, in order:
|
||||
|
||||
1. hot-pool used percentage;
|
||||
2. cluster-wide access_max_size;
|
||||
3. bucket AccessTierQuota.
|
||||
|
||||
Demotion is not blocked by these caps and is processed before promotion.
|
||||
Access moves pause during rebalance or decommission, never target a suspended
|
||||
pool, skip remotely transitioned objects and objects with excessive version
|
||||
counts, and recheck eligibility while holding the object namespace lock.
|
||||
Moves also lock the source and destination write locations. All lock servers
|
||||
for those locations must be reachable; otherwise the background move is
|
||||
deferred. Ordinary S3 requests keep their existing quorum requirements.
|
||||
|
||||
Each move preserves version IDs, delete markers, ETags, checksums, encryption
|
||||
and user metadata. A retry copies only missing versions and retains versions
|
||||
already at the destination. If the same version ID has conflicting metadata
|
||||
in the two pools, the move is skipped and both copies are left intact. Source
|
||||
data is removed only after the complete version stack exists at the destination.
|
||||
|
||||
The hit counter is intentionally best effort. Only successfully served GET
|
||||
requests count; HEAD requests do not. Counters are merged across nodes and
|
||||
bounded by access_max_tracked. A rule window longer than
|
||||
access_bin_width multiplied by access_bins is clamped to retained history.
|
||||
|
||||
AccessTransition and AccessTierQuota are Silo lifecycle extensions. A stock
|
||||
AWS SDK that reads and rewrites the lifecycle configuration may discard
|
||||
unknown fields. Use a raw signed S3 PUT lifecycle request, such as
|
||||
[setup_ilm_access_tiering.sh](setup_ilm_access_tiering.sh), when installing
|
||||
the rule. Save the XML above as `rule.xml`, then run:
|
||||
|
||||
~~~sh
|
||||
AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin \
|
||||
./setup_ilm_access_tiering.sh http://127.0.0.1:9000 testbucket us-east-1 rule.xml
|
||||
~~~
|
||||
|
||||
Access-tier activity is exposed under /minio/metrics/v3/ilm, including move
|
||||
counts, moved bytes, queue depth, hot bytes per bucket, failed moves, dropped
|
||||
GET samples, and separate skip counters for each capacity limit.
|
||||
|
||||
To stop scheduling moves, set `access_tiering=off` (and remove any environment
|
||||
override). Objects already moved remain in their current pools and stay
|
||||
accessible; disabling the feature does not move them back. Before downgrading
|
||||
to a release without access tiering, disable it and let active moves finish.
|
||||
The object storage format is unchanged. The data-usage cache advances from
|
||||
v8 to v9: this release reads both, but an older binary discards v9 caches and
|
||||
rebuilds usage statistics through the scanner. Usage and quota statistics can
|
||||
therefore take time to repopulate after a downgrade. Keep a copy of lifecycle
|
||||
XML containing Silo extensions, since an older binary may omit those fields
|
||||
when rewriting a lifecycle rule.
|
||||
|
||||
## Explore Further
|
||||
|
||||
- [MinIO Go client API reference (S3-compatible SDK)](https://pkg.go.dev/github.com/minio/minio-go/v7)
|
||||
- [Object Lifecycle Management](https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lifecycle-mgmt.html)
|
||||
|
||||
## Access-frequency tiering removal
|
||||
|
||||
The opt-in cross-pool access-frequency extension has been removed. For upgrades from a main/snapshot build that included it, see [the migration notes](access-tiering-removal.md). Ordinary lifecycle expiration and remote-tier transitions remain supported.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Removing access-frequency pool tiering
|
||||
|
||||
PR #60 introduced an opt-in scheduler that moved objects between local server pools according to GET frequency. It and its feature-specific fixes have been removed. This does not remove ordinary lifecycle expiration, transitions to remote tiers, rebalance, decommission, or the general multi-pool correctness fixes from PR #178.
|
||||
|
||||
The [introduction and rollback record](../../investigations/access-tiering-revert.md) documents the commit history, scope decision, review corrections and unresolved validation findings.
|
||||
|
||||
The published Server 20260903 predates this feature. These instructions concern main/snapshot deployments that included #60; upgrading from the published version does not require access-tier configuration cleanup.
|
||||
|
||||
## Before upgrading a build with access tiering
|
||||
|
||||
1. Save a copy of the server ILM configuration and each affected bucket's lifecycle XML. Use an API client that preserves the nonstandard XML; do not rely on a client model that silently omits unknown elements.
|
||||
2. On the old server, set `ilm access_tiering=off` and remove or disable any access-tier environment overrides. Allow in-progress moves to finish before replacing nodes. This reduces movement intermediate states; the removal itself changes no storage RPC protocol.
|
||||
3. Remove top-level `AccessTierQuota` and rule-level `AccessTransition` elements. **Delete rules whose only action was `AccessTransition`**. For a mixed rule, retain its filter, status, ID and ordinary expiration/transition actions. An access-only rule loads harmlessly after upgrade, but becomes an actionless rule and fails validation on the next lifecycle edit. If no rules remain, delete the lifecycle configuration through the S3 API.
|
||||
4. Use a coordinated maintenance window: stop the deployment, install the same new binary on every node, then restart all nodes. The existing bootstrap check compares binary checksums; in a four-node test the first new node could not finish starting among three old nodes. Do not assume that an unchanged RPC protocol permits replacing one node at a time and waiting for it to become ready. This removal does not relax that check. Apply the same environment changes on every node: bootstrap also compares server environment settings, so removing an old override on only some nodes can block startup even with matching binaries. The check runs only during startup and is not a safety guarantee for nodes already running different binaries.
|
||||
5. After restarting, verify object reads, bucket listing, ILM worker settings and a lifecycle edit. Check storage access from every request-serving node to each pool's drives: successful reads or bucket listing establish less than complete drive reachability, and admin disk summaries aggregate server-local state. Retirement acceptance used unique probes and storage trace to confirm every node-to-drive path before and after version deletion. Startup connection times varied, so a fixed sleep is insufficient. Complete distributed upgrade acceptance for the exact binaries before production rollout.
|
||||
|
||||
## What happens to stored state
|
||||
|
||||
| State | Behavior after removal |
|
||||
| --- | --- |
|
||||
| Ten old ILM keys | `access_tiering`, `access_pools`, `access_max_size`, `access_promote_watermark`, `access_bin_width`, `access_bins`, `access_flush`, `access_min_residency`, `access_workers`, `access_max_tracked` are accepted but ignored. Existing transition/expiration worker settings are preserved. |
|
||||
| Admin configuration | Deprecated keys may still appear in `mcli admin config get ilm`; setting them may succeed but has no effect, even with `access_tiering=on`. Remove obsolete environment settings from deployment manifests. |
|
||||
| Lifecycle XML | `AccessTierQuota` and `AccessTransition` are ignored when read and omitted when re-encoded. The same parser handles new PUT requests, so these extensions are also silently discarded there; access-only rules still fail action validation. |
|
||||
| Data-usage cache | Both v8 and v9 caches are read, preserving ordinary counts, sizes, histograms and remote-tier statistics. The retired hot-tier byte count is discarded; subsequent writes use v8. No feature-driven full statistics rebuild is required. |
|
||||
| Objects already moved | Remain in their current pools with the same versions and timestamps. There is no bulk move-back or object metadata rewrite. |
|
||||
| Internal leftovers | `x-minio-internal-ilm-atier` and `.minio.sys/config/ilm/access/` counter objects may remain unused. They do not require a cleanup service or an object scan. |
|
||||
|
||||
Interrupted rebalance/decommission can leave the same version in more than one pool independently of access tiering. Removing the scheduler does not remove such existing copies. General Object Lock, conditional-delete, metadata reconciliation and shared remote-tier reference protections remain in place.
|
||||
|
||||
## Version deletion scope
|
||||
|
||||
Ordinary single-object `DELETE ?versionId=...` reconciles the addressed UUID, null version or delete marker across pools. Unqualified DELETE of a directory marker (a key ending in `/`) also addresses its null version and uses this path. A successful request applies the deletion to every resolved pool copy under the existing per-pool quorum rules; other version IDs remain. If outbound delete replication is pending, copies retain `VersionPurgePending` until the existing replication worker completes the purge. Success does not guarantee immediate physical removal from every drive.
|
||||
|
||||
If a pool is unreadable, these requests can return 503 even when another pool has a readable copy. Insufficient read quorum returns `503 SlowDownRead`; other failures retain their corresponding error codes. This extends an existing failure surface: previously the result could depend on whether the unreadable pool preceded the successful pool in traversal order; it now fails consistently. Retry after recovery. Cleanup failures also return an error. Ordinary unqualified DELETE retains its existing semantics. Batch `DeleteObjects` already fans out across pools.
|
||||
|
||||
Incoming replicated deletes, lifecycle expiration, free-version cleanup and movement-internal calls retain their existing contracts. In particular, an incoming replicated version delete can leave movement duplicates in other pools; this change does not solve that separate case. Expiration scanners process their own pools and may remove duplicate expired copies in later cycles; free-version cleanup remains local to a pool. Do not treat the ordinary DELETE repair as a guarantee for every source of deletion.
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Install a lifecycle XML document without an SDK normalizing away Silo's
|
||||
# AccessTransition and AccessTierQuota extension elements.
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 4 ]; then
|
||||
echo "usage: AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... $0 ENDPOINT BUCKET REGION LIFECYCLE_XML" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
endpoint=$1
|
||||
bucket=$2
|
||||
region=$3
|
||||
lifecycle_file=$4
|
||||
|
||||
: "$AWS_ACCESS_KEY_ID"
|
||||
: "$AWS_SECRET_ACCESS_KEY"
|
||||
|
||||
if [ ! -r "$lifecycle_file" ]; then
|
||||
echo "cannot read lifecycle document: $lifecycle_file" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
content_md5=$(openssl dgst -md5 -binary "$lifecycle_file" | openssl base64)
|
||||
endpoint=$(printf '%s' "$endpoint" | sed 's:/*$::')
|
||||
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--request PUT \
|
||||
--aws-sigv4 "aws:amz:$region:s3" \
|
||||
--user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
|
||||
--header "Content-MD5: $content_md5" \
|
||||
--header "Content-Type: application/xml" \
|
||||
--data-binary "@$lifecycle_file" \
|
||||
"$endpoint/$bucket?lifecycle"
|
||||
|
||||
echo "installed lifecycle configuration on $bucket"
|
||||
@@ -1,5 +1,11 @@
|
||||
# Password and user-management permissions
|
||||
|
||||
> **Release boundary, 2026-09-13:** this guide describes Server main, paired with
|
||||
> silo-pkg v3.14.0 and Console source `417559bb2c97` or its accepted successor.
|
||||
> The latest published Server 20260903 and Console v2.4.0 do not contain this
|
||||
> split. The pkg v3.14.0 and mcli 20260913 releases alone do not change an old
|
||||
> Server's authorization. See [the component matrix](https://silo.pgsty.com/compatibility/versions/).
|
||||
|
||||
**Breaking change: the password-permission split changes the meaning of
|
||||
existing IAM policies.** The same stored policy can authorize a request after
|
||||
this update that it denied before, or deny a request it previously authorized.
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# 访问频率分层:引入、修复与回退记录
|
||||
|
||||
记录日期:2026-09-15。本记录说明 PR #60 的设计、合入后的取舍,以及此次回退为什么同时保留并补齐通用多池正确性修复。操作步骤见[退役迁移说明](../bucket/lifecycle/access-tiering-removal.md)。合并、正式发布和生产部署是不同状态;本记录随回退变更交付,不代表已经发布。
|
||||
|
||||
初稿审查时(2026-09-15 03:20 UTC),最终候选尚未移植到远端基线、尚未冻结 PR head,合并前全量检查与三次有效 Linux 运行尚未执行,本变更尚未合并。后续执行状态以承载本记录的 PR 及其绑定提交的验收记录为准;下文的历史实验不替代这些检查。
|
||||
|
||||
**当前验收状态:** 回退、普通版本 DELETE 调和、扫描复用及文档由 [PR #188](https://github.com/pgsty/silo/pull/188) 交付。本地与 CI 检查通过;首次 Linux 验收因 DELETE204 后的 HEAD/GET503 停止。随后完成可控机制实验、匹配写入负载对照,并修正准备检查;独立的新轮次 R-Upgrade-2 三次完整升级验收均通过。旧失败没有改判,原单次请求的逐盘状态仍不可追溯。最终合并状态以 PR 为准,正式发布与部署另行验收。详情见第 9 至 11 节。
|
||||
|
||||
## 1. 引入的目标和实际范围
|
||||
|
||||
[@mrjavadseydi](https://github.com/mrjavadseydi) 在 [PR #60](https://github.com/pgsty/silo/pull/60) 提出了基于 GET 频率的本地池间分层。成功 GET 更新有界滚动计数,后台调度器把热对象提升到配置中的首个池,把已经迁移且变冷的对象降到末个池。功能默认关闭,至少需要两个池;它与普通生命周期过期、远端对象存储 transition、rebalance 和 decommission 是不同机制。
|
||||
|
||||
实现不只是一个后台任务:它增加了 GET 计数入口、leader 调度、跨池版本栈复制、来源清理及失败恢复、热池配额、生命周期 XML 扩展、配置项、指标和扫描统计。访问热度统计使 data-usage cache 从 v8 升到 v9。对象本体的存储格式没有因此改变。
|
||||
|
||||
搬移要保持完整版本历史、删除标记、null version、时间戳、ETag、校验和和加密元数据;同时需要处理并发写入、目的端已有版本、来源部分删除和远端 tier 引用。合入前的修补和测试针对并覆盖了这些边界,不应把回退解释成贡献无效。贡献者署名继续保留,独立的其他贡献也不回退。
|
||||
|
||||
## 2. 可追溯时间线
|
||||
|
||||
下表的 PR/Issue 时间使用 UTC;提交链接对应具体代码,不把“报告时间”当作“缺陷首次出现时间”。
|
||||
|
||||
| 时间 | 事件 | 本次处理 |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-15 10:15 | #60 创建;原始实现 [`7a060cab1`](https://github.com/pgsty/silo/commit/7a060cab1edd5bbc17da7f703bbd1ab7415b6f7c) | 随特性撤销 |
|
||||
| 2026-09-06 00:09 | [#133](https://github.com/pgsty/silo/issues/133) 报告多池副本写不能权威调和 Object Lock 状态 | 保留解决它的通用修复 |
|
||||
| 2026-09-06 15:12 | [#144](https://github.com/pgsty/silo/issues/144) 报告条件 DELETE 原子性仅限单个纠删码集合 | 保留解决它的通用修复 |
|
||||
| 2026-09-08 05:18 | [`9a6e1477f`](https://github.com/pgsty/silo/commit/9a6e1477f45067559def8423d431ee177795134f) 补访问分层兼容标识清单 | 删除功能专属标识,保留有依据的退役兼容 |
|
||||
| 2026-09-08 07:08 | [`374de0fa3`](https://github.com/pgsty/silo/commit/374de0fa32aa1d6eda57dbba6ac522ebf793b6be) 修复搬移的版本保全、写隔离和删除范围 | 随专属搬移器撤销 |
|
||||
| 2026-09-08 07:28 | [`5ac33e158`](https://github.com/pgsty/silo/commit/5ac33e1583e838ade3f56c7d60e80e7a854f9a88) 确定性覆盖搬移失败恢复 | 随已删除搬移器的专属测试撤销 |
|
||||
| 2026-09-08 07:42 | #60 以 [`a3df317ae`](https://github.com/pgsty/silo/commit/a3df317ae0725eb650e4d3e21551154f69be6229) 合入 | 以该 merge 的第一父差异确定功能边界 |
|
||||
| 2026-09-11 12:34 | [#178](https://github.com/pgsty/silo/pull/178) 合入通用多池写入、元数据与条件删除调和 | 保留,解除测试对访问搬移器的依赖 |
|
||||
| 2026-09-13 | [`2dd1e00da`](https://github.com/pgsty/silo/commit/2dd1e00da49faf995f2db29807fc6211b8376d7d) 的 CHANGELOG 同时汇总访问分层和通用多池修复 | 拆开表述,不整条删除独立修复历史 |
|
||||
| 2026-09-15 | 维护者决定收缩访问分层;完成来源分析、三种候选反证、退役兼容、普通版本 DELETE 修补与外部评审 | 形成此次选择性回退 |
|
||||
|
||||
#178 中的 [`e59a3d938`](https://github.com/pgsty/silo/commit/e59a3d938ed25c1bcd51efbb4ad6955073d195f7) 提供池级串行化、字段调和和条件删除;[`ccb676e60`](https://github.com/pgsty/silo/commit/ccb676e60cb7441ee65ff7c35f3b7828979101fd) 保护仍被其他副本使用的远端 tier 引用;[`51d41345f`](https://github.com/pgsty/silo/commit/51d41345f7ac532f9c6fea2b7dba5f29da930b8f) 保存 Linux 重启及 OIDC 验收记录。这三项不属于仅为访问频率调度而存在的代码。
|
||||
|
||||
截至回退评估时,公开 Server `RELEASE.2026-09-03T13-18-01Z` 早于 #60 合入。退役迁移主要针对运行过后续 main、自行构建或快照版本的实例;不能据此声称正式 release 用户普遍启用过此特性。
|
||||
|
||||
## 3. 为什么回退,为什么不能整批撤销后续修复
|
||||
|
||||
维护者的取舍是:默认关闭的可选调度能力,对配置、生命周期、缓存、统计和核心多池写入路径带来了过大的维护面。此次移除的是该能力及专属实现,没有测量并宣称吞吐量提升、延迟降低或固定减少一次分布式锁往返。
|
||||
|
||||
“后来改过同一文件”不等于“由 #60 引发”。#133/#144 的报告早于 #60 合入;更关键的是,原有 rebalance、decommission 和复制写入也会使同一版本暂时存在于多个池。访问分层消失后,这些状态仍然合法存在。移除调和与锁纪律会重新允许旧副本遮蔽新元数据、条件删除选错版本、清理错误被吞掉等问题。
|
||||
|
||||
评估在隔离工作树中实际比较了三条路线:
|
||||
|
||||
| 候选 | 通用多池回归 | 普通指定版本 DELETE |
|
||||
| --- | --- | --- |
|
||||
| A:撤销 #60,保留 #178 | 原有 13 组通过 | 仍能成功返回后留下可读副本 |
|
||||
| B:同时撤销 #60 和 #178 的存储修补 | 相同 13 组中 10 组失败 | 问题仍在 |
|
||||
| C:A 加普通版本 DELETE 调和 | 13 组原有及当时新增的 8 组通过 | 同一复现通过 |
|
||||
|
||||
这些是 2026-09-15 的历史对照结果,不是最终 PR head 的发布验收。后续补上目录标记、真实 rebalance 中断等覆盖后,通用多池测试达到 23 组。测试通过不能替代来源分析,来源分析也不能替代最终候选的运行验证。
|
||||
|
||||
## 4. 最终保留与删除的边界
|
||||
|
||||
- 删除访问 tracker、调度/搬移器、GET 和 scanner 钩子、热池配额、专属配置帮助、生命周期动作、指标及专属测试。
|
||||
- 保留普通过期、远端 transition、rebalance/decommission、复制写入,以及 #178 的 Object Lock、标签、条件删除、元数据调和与远端引用保护。
|
||||
- 原有 data-usage 生成解码器恢复到 #60 前的实现;允许读取 v8/v9,利用字段编码跳过已退役热度字段,继续写 v8。普通字段保真由历史真实 v9 样本测试覆盖。
|
||||
- 仅容忍准确的十个退役 ILM 键;读取生命周期时丢弃退役扩展。纯访问动作的规则需先清理才能再次编辑;混合规则保留普通动作。
|
||||
- 已经搬移的对象留在当前池;没有全量搬回、自动删除所有重复版本、后台清理服务或对象元数据重写。
|
||||
|
||||
曾对本地审查提交 `d06f1c614` 做过声明来源复核:消失的 357 个声明均不在 #60 之前,删除的十个文件均由 #60 引入;#60 原先删换的 41 行旧文本按忽略空白比较有 40 行恢复,剩下一行保留 #178 在已持有池锁时调用 `getWritePoolIdx(..., true)` 的修正,避免对同一对象再次取锁。生成缓存解码器与功能前逐字节一致,原有 13 组通用测试没有删除。这是该审查版本的保全证据,不能把数字脱离 SHA 当作未来所有版本的保证。
|
||||
|
||||
## 5. 普通版本 DELETE 是独立补洞
|
||||
|
||||
功能移除不会自动消除历史重复副本。普通单对象指定版本 DELETE 因而复用既有调和路径:在池级对象锁内读取每个池的目标版本,计算一次条件及回调,先删除非权威副本,再处理权威副本;任何不可读池或清理错误都不能当作成功。
|
||||
|
||||
范围包括 UUID、null version、delete marker,以及原先就被解析为 null version 的未指定版本目录标记 DELETE。入站复制、搬移内部调用、生命周期过期和 free-version 清理保留各自语义;批量 `DeleteObjects` 原本就会向池并发扇出,不是此次遗漏。
|
||||
|
||||
删除标记需要向 retention/metadata 回调传入与 set 层相同的 `MethodNotAllowed` 或 `ObjectNotFound` 语义。直接复用拒绝 marker 的元数据更新入口会错误地拒绝合法版本删除。回调从所有副本合并独立更新的 Object Lock 和标签,不能随意只采用一个池的状态。
|
||||
|
||||
存在两项明确的成功/失败边界:
|
||||
|
||||
1. 读法定多数不足时返回 `503 SlowDownRead`,即使另一个池有可读副本。旧路径的结果会受池遍历顺序影响;新路径把失败语义统一。它是正确性与可用性的取舍,需要恢复后重试。
|
||||
2. 出站删除复制尚未完成时,成功响应可以表示各副本进入 `VersionPurgePending`,由既有 worker 完成清理。原有每池 quorum 规则也继续适用;不能把成功响应等同于每一块盘立即物理删除。
|
||||
|
||||
## 6. 审查如何改变了方案
|
||||
|
||||
本地先形成三个线性审查提交:`8fdfdabd9` 移除特性,`d06f1c614` 补普通 DELETE,`6e3fdca97` 去除重复扫描。它们记录审查演进,最终 PR 在独立远端基线上重放,提交 ID 会改变;不应把线性演进误认为三份同时维护的实现。
|
||||
|
||||
Claude Code Opus 5 / max 的五轮实现评审要求补齐退役兼容、说明协调停机及环境一致性、验证真实池故障,并纠正运行证据措辞。随后 Claude 与 ZCode 的独立复核再次确认了回退边界和普通 DELETE 语义;最终两轮计划商榷收束了交付流程。
|
||||
|
||||
| 意见 | 裁定与处理 |
|
||||
| --- | --- |
|
||||
| 指定版本 DELETE 重复扫描所有池 | 接受。第一次扫描已在同一锁内得到目标副本,直接合并其结果。16 盘池在回调前的读取计数从 32 降为 16;这不等于总 I/O 或延迟减半。 |
|
||||
| N 个副本产生 N 条 DELETE 审计 | 反驳。底层调用只追加上下文标签,HTTP 层向每个配置审计目标发送一次请求事件。成功完成调和时池标签最终指向 primary;不增加额外 NoAuditLog 修改。 |
|
||||
| retention/metadata 顺序与 set 层不同 | 差异存在,但已有明确注释。保留 retention 优先,拒绝后不删除、不调度删除复制、不 Sweep;不宣称任意自定义回调都能交换。 |
|
||||
| 把优化 amend 进旧提交并直接丢弃 main 脏修改 | 不 amend 已审历史。先保存完整文件、补丁、哈希及可达恢复引用,核对覆盖后受控恢复。八组新增通用测试承接,访问搬移专用测试由真实 rebalance 中断覆盖替代。 |
|
||||
| 从当前本地分支直接开 PR | 调整。其祖先含另一个任务的 IAM/超时提交 `ebc9937d9`;从远端 `89637554d` 仅移植本次变更,不夹带或删除独立工作。 |
|
||||
| 合并之后再跑全量与 Linux 验收 | 不接受。先完成文档和移植,冻结 PR head,再验收;不能把旧 SHA 的测试直接提升为新基线的通过记录。 |
|
||||
| 不同基线 diff 必须逐字节一致 | 改为每提交 stable patch-id、路径与完整树等价性核验。blob hash 和行号随基线变化,不应成为错误的拒绝依据。 |
|
||||
|
||||
`objectPoolInfos` 的并行查询作为独立性能跟进,本次不增加并发实现。Contributor 署名、#132 配额指标、#77 桶元数据、federated COPY、IAM、超时及其他独立修复不因本次取舍被整体撤销。
|
||||
|
||||
## 7. 历史运行证据与未定案事项
|
||||
|
||||
以下是移植前 `d06f1c614` 的历史实验,不是最终 PR head 的验收替身。
|
||||
|
||||
| 运行 | 实际观察 | 不应推导的结论 |
|
||||
| --- | --- | --- |
|
||||
| `f590538f`,四节点双池 | 旧版真实 rebalance 中断留下 9 个重复 UUID;协调停机换新后对象四节点可读,旧配置与普通规则可编辑,真实缓存头 v9→v8;删除一个 addressed UUID 后四节点 HEAD/GET 404,另一个版本仍可读 | 没有验证全部 9 个不同 UUID 收敛;物理元数据仅每池抽查一盘;没有整份运行时统计守恒测量 |
|
||||
| `fd93cd37`,三节点双池 | 停掉一个池所在节点,保留 namespace 锁的 2/3 法定多数;DELETE 返回 503 SlowDownRead,源全部四盘保留目标版本;恢复后 DELETE204,各节点 HEAD/GET404 | 不能推广为跨主机网络、持久盘及压力验收 |
|
||||
| 被弃用的四节点停池拓扑 | 同时丢失 namespace 锁法定多数,发生客户端超时,记录脚本还遇到 NoneType 错误 | 不是“池读取返回503”的证明 |
|
||||
| `83676ca2` | 升级后配置/生命周期编辑之后一次 HeadObject 返回503;artifact 没有记录 DELETE 自身响应 | “DELETE之后”仅来自脚本顺序,不能写成已证实 DELETE204 后异常,也不能归类为已修复、既有问题或暂态 |
|
||||
|
||||
**开放项:`83676ca2` 的 HEAD503 仍未定案。** 可直接比较的目标阶段历史运行是一失败、一成功;一次未复现不足以关闭问题。仅凭 `SlowDownWrite` 等错误名字也不能给其他失败确定容量或环境根因。
|
||||
|
||||
最终候选的合并前复核采用有界规则:要求三次有效升级后 DELETE/HEAD 运行,最多五次总尝试;每次记录 DELETE 码/耗时、失败 HEAD 的节点与版本、GET 错误码、两池全盘元数据、固定间隔重试时序和旧版同拓扑对照。旧版可能保留副本返回200,不要求它满足新增跨池删除契约。
|
||||
|
||||
有证据证明在目标操作前失败的 harness 尝试才能不计入有效运行,但仍计入总尝试。任何目标阶段的新503或数据不变量失败都不能通过补跑抹掉,必须暂停合并并定位。三次通过也只满足这项工程检查,不证明历史异常已消失;开放项在合并和正式发布评估时仍须可见。
|
||||
|
||||
## 8. 交付与恢复纪律
|
||||
|
||||
最终候选使用独立分支;main 的五个旧修改先保存完整内容、二进制补丁、SHA-256 和具名 Git 恢复引用,再核对原 HEAD/哈希及测试覆盖,只恢复这五个文件。禁止用整树 reset 或 clean 代替受控归一;若用户已有新增编辑,应保留并重新核对。
|
||||
|
||||
全量 cmd/internal、相关 race、构建、vet、lint、生成文件和兼容检查,以及上述 Linux 验收,绑定最终候选的实际 SHA。若纳入新的远端提交,重新记录基线与 head,复核变更并重跑受影响验收。文档与原始测试记录各自保留其对应版本,不篡改旧失败、不用新通过覆盖旧记录。
|
||||
|
||||
逐节点滚动升级未通过既有二进制校验检查,采用[协调停机方案](../bucket/lifecycle/access-tiering-removal.md#before-upgrading-a-build-with-access-tiering)。实验使用单个 Docker Linux VM 和 tmpfs;正式 tag、包、镜像、跨主机及生产部署仍是独立交付。未验证全部重复 UUID 或运行时统计守恒应如实披露,不能反向引入自动搬回/清理需求或无关重构。
|
||||
|
||||
## 9. 执行后记:最终基线、恢复窗口与验收
|
||||
|
||||
本次实际交付由 [PR #188](https://github.com/pgsty/silo/pull/188) 承载。选择的远端基线为 `89637554d60c27cfc51d2281d0a4fe15e415f06d`,移植没有包含本地独立 IAM/超时提交 `ebc9937d9`。前三项实现和历史文档逐提交通过 stable patch-id 对照;虚拟补回独立 IAM 差异后,完整树与原审查分支一致。
|
||||
|
||||
首次本地 lint 发现新增回调选择分支触发 `gocritic/ifElseChain`,因此追加等价的无表达式 `switch` 改写,保持 marker、指定版本和普通元数据查找的条件顺序及分支体。重新固定的代码候选为 [`41aa84609`](https://github.com/pgsty/silo/commit/41aa84609754769cfb1861d7fd060c2e84182b98)。这一提交上,全量 cmd/internal 得到 6,428 个测试及子测试通过、166 个跳过,50 个有测试的包通过;相关 race 得到 283 个测试及子测试通过。`make build`、全包构建、vet、lint、生成文件及 rebrand/compat 检查均通过。[Go CI](https://github.com/pgsty/silo/actions/runs/34925534139)、[DCO](https://github.com/pgsty/silo/actions/runs/34925534110)、[VulnCheck](https://github.com/pgsty/silo/actions/runs/34925534142) 和[发布流水线的测试运行](https://github.com/pgsty/silo/actions/runs/34925534198)共 11 项检查通过;后者没有发布正式制品。
|
||||
|
||||
第一次最终候选停池实验 `dcd5c2e5` 在源版本保全断言后失败:停掉另一池返回 `503 SlowDownRead`,源四盘版本保留;恢复后 DELETE 成功,节点 0/2 的 HEAD/GET 返回 404,节点 1 返回 503。DELETE 成功由脚本已通过的 204 断言确定,原输出没有单独保存该次 DELETE 响应。此次失败如实保留,不能把后来的成功写回原记录。
|
||||
|
||||
复核发现,`ListBuckets` 以及位于源池的现存版本 GET,不能证明每个协调节点对另一池的读取连接已经恢复。随后进行了旧基线与候选的同拓扑对照,探测的是从未写入过的随机 UUID,并且在这些探测之前没有执行任何 DELETE:
|
||||
|
||||
| 高时间分辨率对照 | 桶列表和现存版本 | 从未写入版本的 HEAD/GET | 观察到的恢复窗口 |
|
||||
| --- | --- | --- | --- |
|
||||
| `20502a2f`,旧基线 `89637554d` | 三节点均为 200 | 节点 0/2 为 404,节点 1 为 503 SlowDownRead,连续 16 组 | 从重启后的观察循环起算约 1.60–2.50 秒 |
|
||||
| `246aaf77`,候选 `41aa84609` | 三节点均为 200 | 同样是节点 1 的 503,连续 11 组 | 约 1.67–2.30 秒 |
|
||||
|
||||
两边在缺失版本全节点连续三轮返回 404 后执行 DELETE,均得到 204、全节点 HEAD/GET 404、目标 UUID 在八盘均不存在且其他版本可读。另有两次较低时间分辨率诊断未捕捉到窗口,同样保留;这四次诊断不计入三次升级验收。
|
||||
|
||||
这给出了基线在零 DELETE 下的正向复现,证明原恢复条件不足。`getLatestObjectInfoWithIdx` 的读选择函数与基线文本相同:现存副本可以遮蔽另一池的读错误;缺失版本则必须确认所有池,不可读时返回 503。Claude 复核后同意修正实验准备条件并继续验收,明确反对把这一路径的 503 改成 404。最初失败没有瞬时 RPC 全貌,不能逐请求追溯每条连接;这些对照也不能给 `83676ca2` 归因。
|
||||
|
||||
修订后的验收在升级/恢复后逐节点探测从未写入的 UUID,记录首次全 404 时刻,要求连续三轮全 404;并列保存各节点 `admin info` 的全盘状态。最多等待 30 秒,超时仍失败,DELETE 之后仍严格要求 204/404,不把 503 纳入通过条件。后续失败保留实验资源供即时取证,再受控清理。
|
||||
|
||||
修订准备条件后的独立停池验收 `40a59b3b` 通过:离线 DELETE `503 SlowDownRead`,源四盘版本保留;恢复门约 1.48 秒完成,DELETE `204`,三节点 HEAD/GET `404`,目标在八盘均不存在,另一版本仍可读。恢复早期 `admin info` 中也记录到了各节点不同的离线盘视图,最后恢复为全盘正常。
|
||||
|
||||
完整升级验收随后实际进行了三次尝试:
|
||||
|
||||
| 尝试 | 运行 | 结果与证据边界 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `83f88c59` | 准备失败,未启动候选。旧版 rebalance 报 Completed、搬移版本数为 0;源池占用约 6.5%,到平均空闲目标的差值约 3.13%,落入代码既有 5% 容差。增加造数从 64 到 192 个 2 MiB 版本后再试;本次仍计入五次总尝试上限。 |
|
||||
| 2,第一轮有效运行 | `ea58d0c5` | 通过。实际 rebalance 中断产生跨八盘的重复版本;停机复制同一数据供旧版对照。旧版 DELETE204 后仍可读;候选 DELETE204 后四节点立即及后续固定间隔 HEAD/GET 均404,八盘目标清除、另一版本四节点可读;旧 ILM/生命周期编辑和真实缓存 v9→v8 通过。 |
|
||||
| 3,第二轮有效运行 | `2059bc6b` | **候选失败,阻断合并。** 两阶段逐节点缺失版本连续三轮404、admin info全盘ok之后,DELETE明确204(约12.98ms);节点2随后的HEAD503/GET503 SlowDownRead,另外三节点404;八盘快照均无目标,首次重试及后续采样全404,其他版本四节点可读。首次失败保留,不因重试恢复改判。 |
|
||||
|
||||
第三次尝试发生后停止剩余验收,保留原容器、卷、元数据与响应时序进行诊断。不能把四节点顺序探测中的“节点2异常”直接解释为永久节点故障:它也可能与采样时间有关。随后在保留环境中,对三个额外重复版本做并发、不同顺序的“刚删 UUID / 从未写入 UUID”对照,候选稳定期均为204/404,未复现;旧版克隆数据的双次 DELETE 对照则遇到 `SlowDownWrite`,没有完成其全部断言。这些是诊断结果,不补入有效升级通过计数,也不证明第三次尝试已解释。
|
||||
|
||||
为观察逐盘返回,另在独立临时工作树编译仅增加日志的诊断二进制,**没有进入 PR**。它证明了第二层准备检查盲点:`getObjectFileInfo` 的四个响应信号中,可以只有两个实际 `file version not found`,其余两个是被跳过的盘所保留的 `errDiskOngoingReq`;`objectQuorumFromMeta` 的预期读 quorum 为2,因而仍可返回404。同期 `admin info` 汇集各服务器本地盘态为ok,不能证明请求节点到各盘的路径都可用。一次诊断 DELETE 的逐盘返回为 `[nil, nil, drive not found, drive not found]`,达不到写 quorum 3,故返回 `SlowDownWrite`。
|
||||
|
||||
Claude 撤回了此前“逐节点三轮404已是最强全池准备条件”的表述,同意这只能证明读 quorum,不能证明全盘可达或写 quorum。诊断给出了候选机制,但**第三次尝试失败瞬间没有逐请求逐盘日志,仍不足以确定其具体原因**;不能把后来稳定期的成功、诊断中的配额不足,或对错误名的解释当成该次故障的直接证据。
|
||||
|
||||
## 10. 首次执行的停止位置与恢复资料
|
||||
|
||||
首次执行停止时,代码为 `41aa84609`;后续提交只记录执行,不改变已测试的生产代码。当时有效升级运行是一通过、一失败,未满足三次有效运行全部通过的约定;总尝试3次,没有通过继续补跑消耗剩余次数来冲淡失败。`2059bc6b` 和先前的 `83676ca2` 均保持 **OPEN**。Claude 与 Codex 当时的裁定是 **NO-GO for merge**,没有把证据不足升级为“已修复”“既有问题”或“暂态”。后续收尾见第 11 节;实际合并状态以 [#188](https://github.com/pgsty/silo/pull/188) 为准。
|
||||
|
||||
主工作区原五文件的完整内容、二进制补丁和 SHA-256 已归档;另有可达 Git 引用 `refs/archive/access-tiering-main-five-files-20260915`,指向快照 `c7fbfc6ada0f0f2abcbe8c0a9681f07e12dafe52`。逐文件核验快照与原已审内容一致。首次停止时因验收未通过,没有执行五文件恢复,也没有移动或改写独立 IAM 提交 `ebc9937d9`。当时唯一待交付候选在 PR 分支,旧变体冻结等待验收裁定。
|
||||
|
||||
本机执行资料归档在 `~/.codex/outputs/silo-access-revert-assessment-20260915/final-execution/`:保存了每次尝试、旧/新基线对照、二进制 SHA-256、准备条件、源/目的全盘元数据、诊断补丁、独立评审意见,以及受控清理记录。原始失败记录不覆写;保存的诊断卷内容用于继续调查,不是生产数据或发布制品。
|
||||
|
||||
继续推进需要一次能区分机制的取证:在条件可控的升级实验中,于失败请求当时记录每池每盘的真实应答和错误类型,并同时读刚删版本与从未写入版本;明确区分盘面不同步、请求节点的不可用路径与其他原因。若四盘均真实应答仍返回503,应沿错误归约/元数据路径定位;若盘不可用,应查明连接或初始化状态,并验证准备条件。后续成功本身不能关闭本次失败,更不能通过把不可判定的503改成404来满足验收。正式发布、制品和生产部署继续作为独立交付。
|
||||
|
||||
## 11. 收尾复核:准备检查、可控机制与独立新轮次
|
||||
|
||||
后续收尾没有继续修改生产代码。旧基线 `89637554d` 与候选 `41aa84609` 使用各自的独立诊断构建,仅对测试桶记录逐盘应答;这些日志补丁没有进入 PR。第一轮完整诊断 `53ebe161` 通过但未复现503,仅作为一个样本保留。第二轮 `c951f762` 得到了不同的、可直接解释的失败:两个池的删除调用均记录 `quorum=3 errs=[nil,nil,nil,drive not found]`,DELETE204、所有读样本404,但八盘快照的盘3、7仍保留目标版本。它符合既有写 quorum 契约,并正向证明旧准备门会放行尚未完成挂盘的协调节点;它没有复现或解释 `2059bc6b` 那一次请求。
|
||||
|
||||
审查还纠正了两项推断:后续诊断进程在04:19的重连日志不能用于解释03:56的原失败;错误归约按具体错误值计数,不能把“最高同值计数不足”简单等同于“实际应答盘数不足”。原失败之后的全盘快照也不是失败瞬间的原子快照。这些界限继续保留。
|
||||
|
||||
准备检查改用服务器已有的 storage trace:从每个 S3 节点,对各自唯一、从未写入的对象执行 `GetObjectTagging`,该路径等待所有盘;按唯一对象名和后端节点、盘路径匹配真实 `storage.ReadVersion` 应答。四节点双池需要每轮32条实际缺失应答,连续三轮完成才满足准备条件;单纯404或 `admin info` 的本地盘状态不够。探针不创建对象,不改变服务器读写语义。trace 输出是多行 JSON 对象流,按流解析;缺失 trace 证据会使检查失败,不能据此假定对应盘健康。
|
||||
|
||||
为了比较相同的跨池写入负载,停止真实 rebalance 夹具后复制两份相同数据。旧基线使用 #178 已有的 `If-Match` 条件 DELETE,候选使用普通指定版本 DELETE,二者都处理同一目标的两个池副本。两臂分别完成96次“已删版本/从未写入版本”的 HEAD/GET 对照,均404,目标八盘均清除。准备检查分别耗时约0.60秒和17.08秒。这是一对匹配样本,没有观察到候选专属差异,不是吞吐比较,也不能排除所有可能的故障机制。
|
||||
|
||||
`f55967b7` 另用明确制造的重复副本夹具完成因果实验,而非冒充真实 rebalance:pool0 的四盘由一个节点承载并保留 namespace 锁;pool1 的四盘分布到另外四个节点。先停止 pool1 一盘,DELETE204 后直接确认只有该盘残留目标版本。重新接入这份副本,再停止 pool1 两块已删除该版本的盘,保留“一盘返回版本、一盘返回缺失”的读视图。三个被测协调节点的已删版本均返回 `503 SlowDownRead`,从未写入版本均返回404;同一失败请求的逐盘记录明确为 `[drive not found, drive not found, file version not found, nil]`,没有足够的同值应答达到读 quorum。整个实验没有丢失 pool0 的 namespace 锁,也没有将不确定状态改为404。
|
||||
|
||||
该因果实验的部分节点重启未在35秒内恢复全部40条访问路径,原实验因此仍记 FAIL;正向机制观察与这个恢复失败分开记录。随后协调重启全部五节点,独立恢复检查通过,五节点 HEAD/GET 均404。这不是滚动恢复已通过的声明,也不追溯原 `2059bc6b` 的逐盘状态。可控复现确定的是故障机制类别,原单次实例继续 **OPEN**。
|
||||
|
||||
Claude 复核上述证据后同意显式开启 **R-Upgrade-2**:原轮次的一通过、一失败和总尝试3次保持原样,不合并计数,不静默重置。新轮使用未经诊断修改的 `41aa84609` 二进制,要求三次有效运行全部通过、最多五次总尝试;任何新503或数据不变量失败仍须停止。每次 DELETE 前后均检查32条路径,响应后的即时 HEAD/GET 先于后置准备检查执行,避免等待掩盖短暂错误。
|
||||
|
||||
| 新轮次运行 | 完整运行耗时 | 升级后32路径准备耗时 | 验收结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| `8eb016db` | 90.20秒 | 17.04秒 | PASS |
|
||||
| `2a2b7b64` | 80.83秒 | 0.62秒 | PASS |
|
||||
| `371e7b9f` | 96.28秒 | 0.60秒 | PASS |
|
||||
|
||||
三次均实际走到候选阶段:DELETE204,所有即时及后续 HEAD/GET 样本404,目标在八盘均不存在,其他版本从四节点读回;旧配置、普通生命周期规则编辑及真实缓存 v9→v8 均通过。删除前后各三轮32路径检查也全部通过。准备时间有明显波动,应验证访问路径,不能用固定等待秒数代替检查。这些结果满足修正准备条件后的有界验收;不把有限样本写成“历史503已消失”,不把不同阶段的诊断通过计入新轮次。
|
||||
|
||||
新轮仍绑定生产代码 `41aa84609`(Linux 二进制 SHA-256 `28e1339d630a22fa5a0e4659b6182224e81f6cd7cd4079856534390e40a697b1`),后续仅更新文档。合并前须核对源码等价性和最终 CI,按第8节的恢复纪律处理旧五文件,保留独立 IAM 提交。原 `2059bc6b`/`83676ca2`、部分重启恢复边界、单 VM/tmpfs、未验证全部不同重复 UUID 和整份运行时统计守恒继续可见;不为此新增自动搬回、清理服务或读错误降级。
|
||||
|
||||
本轮详细资料位于原归档的 `final-execution/closure-20260915/`,包括匹配对照、同请求逐盘日志、`qualification-summary.json`、独立 R-Upgrade-2 账本、诊断补丁和完整卷归档。原失败现场、后续对照及恢复后的卷分别标注时点。临时实验资源在归档验证后清理;这些资料与正式发布制品区分管理。
|
||||
@@ -1,5 +1,11 @@
|
||||
# SILO stack: Go 1.27 compatibility audit
|
||||
|
||||
> This is a dated investigation, with the source and runtime boundaries recorded
|
||||
> below. It does not establish the current dependency pins or a later release.
|
||||
> See [the current changelog](../../CHANGELOG.md) and
|
||||
> [component matrix](https://silo.pgsty.com/compatibility/versions/).
|
||||
|
||||
|
||||
2026-09-09. Scope: the maintained Server, silo-pkg, mcli, and Console. This extends
|
||||
the [OIDC #154 investigation](issue-154.md) to other paths using the same TLS
|
||||
configuration and to adjacent standard-library changes. It records local
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# SILO #154:OIDC discovery 连接重置调查
|
||||
|
||||
> This is a dated investigation, with the source and runtime boundaries recorded
|
||||
> below. It does not establish the current dependency pins or a later release.
|
||||
> See [the current changelog](../../CHANGELOG.md) and
|
||||
> [component matrix](https://silo.pgsty.com/compatibility/versions/).
|
||||
|
||||
|
||||
前两轮调查时间:2026-09-09;公开 issue 最后核对于 07:53 UTC。第二轮补充同源码、同依赖、不同 Go 工具链的 Linux 完整 Server 对照和候选补丁认证链路验证。
|
||||
|
||||
**后续更新:用户已授权扩展至整个 SILO 技术栈并修复。现已确认 Server 其他 TLS 路径也存在同类覆盖问题,并在产品工作区完成统一使用 Go 默认曲线的修复。当前实现、验证和交付状态见 [全栈调查](go127-stack.md)。下文保留前两轮的诊断与当时的 OIDC 局部候选;“未修改产品”和“不要扩大范围”等表述仅适用于当时的调查阶段,局部候选已被后续全路径修复取代。**
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Issue #77 当前核验与最小修复建议
|
||||
|
||||
> 历史快照(2026-09-12,实施前)。其中的 Issue 状态、待办和方案约束只描述当时情况;最终实现、修正与验收以 [归档总记录](issue-77.md) 为准。
|
||||
|
||||
核验日期:2026-09-12。对象:[SILO #77](https://github.com/pgsty/silo/issues/77)。代码基准:远端 `main` 的 `5c576581631561c446f30ae5b566f0aa793adc1c`,在独立 detached worktree 中运行测试。用户工作目录仍位于 `12f631b50`;两者差异为 #179 的 federation 修复,不涉及本次复制元数据代码。
|
||||
|
||||
**结论:问题真实,当前主干仍未修完,应保持打开。原方案的 A/B 核心必要,但不能照搬 8 月的实施清单;已有基础设施可以复用,同时必须补上批量复制入口和相同内容的时间戳同步。**
|
||||
|
||||
本次是分析与复现,没有修改产品代码、提交、发布或修改 GitHub Issue。下述测试是本地 ObjectLayer 与进程内 HTTP/RPC 复现,不是线上多站点验收。
|
||||
|
||||
## 1. 当前状态
|
||||
|
||||
GitHub API 实时返回 #77 为 `OPEN`,未分配负责人或 milestone,最后更新为 `2026-09-08T14:45:10Z`。核验时仓库没有打开的 PR。
|
||||
|
||||
| 范围 | 当前事实 | 是否仍属遗留项 |
|
||||
| --- | --- | --- |
|
||||
| #77-C,各站点统计 | [#91](https://github.com/pgsty/silo/pull/91) 于 8 月 29 日合并;本次现有统计回归测试通过 | 否,不应重复实现 |
|
||||
| #77-A,heal 选源与输入 | 仍先用 map 首项初始化,再跳过创建默认值;Tag 远端 heal 仍不携带时间戳 | 是 |
|
||||
| #77-B,源时间与删除状态 | 六类专用处理函数仍调用到达时间写入路径;部分删除状态不导出 | 是 |
|
||||
| #77-D,持续不一致诊断 | 已有损坏配置日志;旧事件静默跳过、默认状态无法选源等诊断未完成 | 是 |
|
||||
| Object Lock 错误 wire 字段、接管桶覆盖已有配置 | [#76](https://github.com/pgsty/silo/issues/76)、[#78](https://github.com/pgsty/silo/issues/78) 已关闭,对应 #89、#90 已合并;相关测试本次通过 | 否 |
|
||||
| 元数据整记录并发写、删除后重建 | [#103](https://github.com/pgsty/silo/pull/103)、[#156](https://github.com/pgsty/silo/pull/156) 已提供桶锁和保存前物理桶检查;删除后排队写测试本次通过 | 基础已具备,但不能代替同字段时间排序 |
|
||||
| 评论提及的 MRF 丢弃观测、delete-marker purge | [#152](https://github.com/pgsty/silo/issues/152)、[#153](https://github.com/pgsty/silo/issues/153) 已于 9 月 9 日随 [#162](https://github.com/pgsty/silo/pull/162) 关闭 | 不再列为 #77 的待实现项 |
|
||||
|
||||
#162 明确保留“405 表示 marker 仍存在”的语义,没有采用报告人建议的“405 一律判定永久删除完成”;它也未声称复现外部报告的请求量或持续 405 风暴。本次只核验其合并/关闭状态与提交说明,没有重跑那组故障实验。
|
||||
|
||||
最新公开 Release 仍是 `RELEASE.2026-09-03T13-18-01Z`。#77 的状态不能用“已有统计修复”或“已有桶锁”推导为已修复,也不能用较晚 PR 的合并推导为已发布。
|
||||
|
||||
## 2. 已复现的真实问题
|
||||
|
||||
最直接的例子:源端 10:00 PUT 配置,10:01 DELETE;目标端积压到 10:05 才接收 PUT,并把配置时间写成 10:05。随后接收源时间为 10:01 的 DELETE,判断它比本地旧,返回 HTTP 200,却保留配置。这个例子不需要机器时钟偏差,只需要事件延迟。
|
||||
|
||||
根因在 [updateAndParse](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/bucket-metadata-sys.go#L129):它在锁内统一使用 `UTCNow()`;专用 peer handler 在调用它之前,用 getter 返回的时间做比较。
|
||||
|
||||
| 配置类型 | peer PUT 保留源时间 | 较新源 DELETE | 本地删除后旧 PUT | 删除时间在元数据导出中可见 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Policy | 否 | HTTP 200,但未删除 | 会复活 | 是 |
|
||||
| Tags | 否 | HTTP 200,但未删除 | 会复活 | 否 |
|
||||
| SSE | 否 | HTTP 200,但未删除 | 会复活 | 否 |
|
||||
| Quota | 否 | HTTP 200,但未删除 | 本次未复活,getter 保留删除时间 | 否 |
|
||||
| Versioning | 否 | nil 正确地不修改配置 | 不适用 | 不应套用可删除配置规则 |
|
||||
| Object Lock | 否 | nil 正确地不修改配置 | 不适用 | 不应套用可删除配置规则 |
|
||||
|
||||
矩阵每项均在 `ErasureSD` 与 16 盘 `Erasure` 两个本地后端执行。六类专用事件通过实际签名 admin 路由调用,检查落盘状态;导出调用实际 `SiteReplicationMetaInfo`。不能把 Quota 与 Policy/Tags/SSE 的 getter 行为写成完全相同。
|
||||
|
||||
另外复现了以下路径:
|
||||
|
||||
1. **检查与写入不在同一临界区。** 阻塞旧 Tag 事件的锁获取,在锁内提交较新 Tag,再释放旧事件;旧事件仍覆盖新内容。#103 解决跨字段丢更新,没有解决 handler 的 getter → Update 竞态。
|
||||
2. **批量元数据入口绕过逐字段排序。** 已持久化较新 Tag 后,发送较旧 bulk 事件,实际 admin 路由返回 HTTP 200,Tag 内容和时间都倒退。该入口被 [桶元数据导入](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/admin-bucket-handlers.go#L1106) 使用;[PeerBucketMetadataUpdateHandler](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/site-replication.go#L1608) 只检查桶创建时间,非 CORS 字段缺少与已有字段时间的比较。这是旧实施清单漏掉的入口。
|
||||
3. **默认值能够成为错误的 heal 来源。** 两站点状态中,一个有真实 Policy,另一个只有更晚的 `CreatedAt == PolicyUpdatedAt` 默认值。最后一次复现分别有 5/32、2/32 轮错误删除有效 Policy,取决于 Go map 遍历顺序。该计数只是复现样本,不能推断生产发生率。
|
||||
4. **远端 Tag heal 丢失源时间。** 实际 HTTP 捕获的 `UpdatedAt` 为零,见 [发送字段](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/site-replication.go#L5011)。
|
||||
5. **同内容、不同时间戳没有同步。** 即便强制 `TagMismatch=true`,内容比较仍使 heal 跳过,较旧排序时间保持不变。两站点看似内容相同,之后却可能对同一个延迟事件做出不同决定。旧方案只统一写入时间、保留全部 heal 跳过条件,仍不完整。
|
||||
6. **Quota 的 nil heal 留下缓存。** 向现有 heal 传入显式较新删除状态,磁盘 `QuotaConfigJSON` 被清空,但 `GetQuotaConfig` 仍返回 1024 字节的旧硬配额。这里显式构造了删除状态,因为当前 exporter 正在隐藏它;这是新增 tombstone 导出不能直接交给旧 heal 的具体证据。
|
||||
|
||||
从当前 `git blame` 和基线比对看,核心错误继承自 MinIO:heal 初始化逻辑来自 2022 年 `3a64580663`,专用 handler 的旧事件检查来自 2022 年 `7cc9286e0f`,bulk 入口来自 2023 年 `0cde37be50`;0806 基线已包含相关逻辑。此次没有发现其由 SILO 最近的 CORS 或统计修改引入。
|
||||
|
||||
对于使用 site replication 的部署,建议按 P1 正确性问题处理:撤销的桶策略可能保留或复活,默认加密和配额也可能与源端不一致。没有启用站点复制的正常单站点请求不触发这些复制路径;本次没有证明对象数据本体丢失。
|
||||
|
||||
## 3. 最小且必要的修复范围
|
||||
|
||||
**第一部分:收束 A 的选源和输入修复。** 提取一个小的内部选择函数,在初始化候选之前排除零时间和创建默认值;无可信候选时不写入。六类 heal 使用它,补齐 Tag 的源 `UpdatedAt`。Versioning/Object Lock 遇到 nil 保持 no-op,Quota 删除使用正确的删除路径。不能重新做 #76/#78 或把 Lifecycle、CORS 一并重构。
|
||||
|
||||
**第二部分:收束 B 的状态更新,包含本次发现的入口遗漏。** 在既有 `metadata.lock` 内完成读取原始字段时间、判旧、写入源时间和保存;普通本地写入保留现有契约。复用 `updateAndParse` 的类型分支及现有 `saveMetadata`,不再从公开 getter 获取删除时间,也不改变 getter 的 S3 错误语义。peer 更新和删除保留各自解析规则,内部返回是否应用,重复且相同的状态不重复保存。
|
||||
|
||||
本地 heal 必须使用同一排序路径,不能选好源后调用普通 `Update/Delete` 再生成到达时间。批量复制入口也必须在其现有整记录锁内逐字段判旧;bulk 的 nil 继续表示“未提供此字段”,不能改成批量删除。heal 需要同步较新的时间戳,即便内容已经一致;这不要求重写 #91 的计数或增加公开 API。
|
||||
|
||||
当前 `saveMetadata` 已在锁内检查物理桶存在,桶删除也使用同一把锁。8 月方案里另加一套 `peer-require-existing` 防幽灵桶机制已没有必要,应保留并复用现有保护。确有 `Created == 0` 的历史桶如何补齐创建时间仍应覆盖,但不能退化当前所有写入都执行的存在性检查。
|
||||
|
||||
**第三部分:单独启用删除时间导出,补足轻量诊断。** Policy 已导出时间;实际新增的是 Tags/SSE/Quota 的 nil payload 时间。先使接收与 heal 能正确处理删除,再开放这些状态。混合旧版 SILO 的风险已经有 Quota 缓存复现支撑;这是产品自身的滚动升级问题,不是要求兼容未经修改的上游 MinIO。
|
||||
|
||||
可以使用仅针对这项导出的明确 opt-in:默认关闭新增导出,全站点升级到具备 A/B 修复的版本后启用。若项目选择自动能力协商,可后续单独实现;不必为了它阻塞没有新增 wire 字段的选源与接收端修复,也不需要为本 Issue 建一个通用能力框架。具体开关尚未实现,不应把这里的建议当成现有配置。
|
||||
|
||||
诊断只需解释“过旧事件”“早于桶创建”“只有默认/未知候选”等跳过原因,使用现有有界去重/限频设施,保持 RPC 的既有成功语义。不需要另建重试队列、每轮日志或大型监控系统。
|
||||
|
||||
有三项边界必须在实现中明确:
|
||||
|
||||
- 旧版 Tag heal 会发送零时间。新实现不能无说明地全部拒绝;可保留明确的兼容降级路径,在旧节点存在时不承诺完整排序收敛。
|
||||
- 相同时间戳、不同内容的冲突:只给 heal 增加 deployment ID 平局规则,并不能让到达顺序不同的 peer apply 本身确定。若承诺此类冲突也收敛,apply/heal 应共用一个类型内比较规则;可参考 CORS 的删除优先和载荷排序,不需要新增持久化源站 ID。若不纳入本轮,必须列为剩余边界,不能称全量收敛审计完成。
|
||||
- 旧版本已经写坏的到达时间无法从现有记录还原。升级不会自动恢复历史操作顺序;应由操作者确认权威状态,并在升级和时钟检查后重新提交相关配置/删除,再验证各站点。
|
||||
|
||||
因此,建议交付为有限的选源修复、统一源时间更新、删除导出与诊断三个可审核部分。已有锁、物理桶保护和 C 的计数修复直接复用。预期产品修改集中在 Server 的 site-replication 与 bucket-metadata 路径,无须为核心修复升级 Console/mcli/silo-pkg、改存储格式或重写复制架构。
|
||||
|
||||
## 4. 验证证据与关闭条件
|
||||
|
||||
本次最后一轮执行:
|
||||
|
||||
```text
|
||||
GOWORK=off go test -tags kqueue,dev ./cmd \
|
||||
-run 'TestIssue77Current|TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig|TestPeerBucketObjectLockMetadata|TestPeerBucketAdoption|TestQueuedMetadataUpdateAfterDelete' \
|
||||
-count=1 -timeout=5m -v
|
||||
```
|
||||
|
||||
四组审计复现测试包含 24 个后端/场景子测试,均暴露预期的现存缺陷;八个现有回归测试通过。总命令退出码为 1,原因是上述针对期望正确行为的断言失败,不代表修复验收通过。未运行全量测试、race suite、真实多站点中断/重启或滚动升级实验。
|
||||
|
||||
保留的证据:
|
||||
|
||||
- [完整运行日志](issue-77/current-tests.log)
|
||||
- [源时间、删除和 bulk 审计测试](issue-77/issue77_review_test.go.txt)
|
||||
- [heal 与锁竞态审计测试](issue-77/issue77_heal_review_test.go.txt)
|
||||
|
||||
测试以 `.go.txt` 保存,避免将故意失败的审计用例加入正常 Go 测试集。可在上述 SHA 的独立 worktree 中复制为 `cmd/issue77_review_test.go` 和 `cmd/issue77_heal_review_test.go` 后重跑。创建默认值测试使用重复 map 遍历来观测缺陷,正式回归应在抽出选择函数后改成确定性用例。
|
||||
|
||||
关闭 #77 前需要:上述失败场景转为正确行为;分别覆盖六类配置、落盘重载、漏发/重复/乱序、同字段竞态、同内容新时间、批量导入入口、legacy 桶、删除后的桶及混合版本。全站点升级后的实际断线重连/重启验证应保留独立证据。当前结论是“缺陷与实施范围已核实”,不是“修复完成”。
|
||||
@@ -0,0 +1,123 @@
|
||||
# Issue #77 最小充分修复计划
|
||||
|
||||
> 历史快照(2026-09-12,实施前)。其中的 Issue 状态、待办和方案约束只描述当时情况;最终实现、修正与验收以 [归档总记录](issue-77.md) 为准。
|
||||
|
||||
版本:v4 定稿,2026-09-12。状态:作者复核完成,Claude Code `claude-opus-5 --effort max` 四轮实际评审后,最终结论为 **GO_WITH_NONBLOCKING_NOTES,实施前阻断 0**。非阻断说明已落实到本计划,见 [最终评审汇总](issue-77/review/final-review.md)。
|
||||
|
||||
基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](issue-77-current.md),意见处置见 [首轮处置](issue-77/review/decisions-v2.md)、[第二轮处置](issue-77/review/decisions-v3.md)、[接管边界补查](issue-77/review/decisions-v4.md)。
|
||||
|
||||
## 目标与边界
|
||||
|
||||
修复 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类桶配置的源时间丢失、锁外判旧、heal 错选源和删除传播不完整。覆盖普通本地写入、专用 peer 事件、bulk/import、local/remote heal、元数据导出与初次同步。
|
||||
|
||||
在修复 PGSTY 栈、同一已知桶世代、合法带源时间事件的范围内,使重复、乱序和漏发后的状态能够确定性收敛。开关关闭期间新增删除信息不可见,不承诺完整删除收敛;无时间旧事件、旧版污染时间和桶创建世代冲突需要单独解释,不能自动推断历史真相。
|
||||
|
||||
不重做 #91 的计数、#76 的 Object Lock wire 修复、#78 的桶接管、#103/#156 的锁与删除保护。不改变 CORS、Lifecycle/expiry、notification、对象复制、MRF、resync、IAM 的语义;不改存储 schema、SDK、Console、mcli 或 silo-pkg,不新建能力协商、复制框架、锁或重试系统。
|
||||
|
||||
## 行为契约
|
||||
|
||||
### 1. 明确事件、缺省值与删除
|
||||
|
||||
| 类型 | 专用事件 | bulk 字段未提供 | bulk 字段明确提供 |
|
||||
| --- | --- | --- | --- |
|
||||
| Policy | nil 为删除;沿用现有解析器 `IsEmpty()` 为删除 | 保留 | 非空 RawMessage 按现有解析器处理;语义空策略归一为删除 |
|
||||
| Quota | nil 为删除;非 nil 按现有 quota 解析器处理 | 保留 | 非空 RawMessage 按现有解析器处理,零 quota 仍是 live 文档 |
|
||||
| Tags / SSE | nil 或 base64 解码后空内容为删除 | 保留 | 空字符串为删除,其他内容按原规则解码、校验 |
|
||||
| Versioning / Object Lock | nil/空内容为 no-op | 保留 | nil/空内容仍为 no-op,不能清空配置 |
|
||||
|
||||
“未提供”必须依据真实 wire 类型判定:Policy/Quota 的 `json.RawMessage` 为 nil 或空切片时经 `omitempty` 省略;**显式 JSON `null` 解码后是非 nil 的 `[]byte("null")`**,不能与缺省混淆。按现有解析器,Policy `null` 是语义空策略,Quota `null` 是零值 quota 文档。`*string` 类型的 JSON `null` 则解码为 nil。不得使用统一的 `len(payload)==0 => 删除` 来处理 bulk。
|
||||
|
||||
合法 `{}`/零值 quota 保持 live;取消普通 quota PUT 出站时“零配额改写为 nil”的逻辑,保存与发送同一个语义状态。Policy 保留现有专用 peer 的“空策略=删除”解释,本地 PUT、导入和 bulk 统一归一为同样的删除状态;这是需要写入兼容说明的小范围变化:空策略本地立即按删除处理,GET 返回既有 NotFound 行为,不再先保留空文档、等复制后才被清除。
|
||||
|
||||
**同一次操作的落盘状态与出站事件,经同一归一规则后,必须具有相同的 `(kind, payload key, source time)`。** JSON 的无意义编码次序不要求字节相同;Object Lock 改写后的有效 Versioning 文档必须来自提交结果。
|
||||
|
||||
### 2. 统一状态与排序
|
||||
|
||||
内部仅需一个小状态表示:baseline / live / tombstone,以及比较键和字段源时间。baseline 可携带历史有效配置,但没有真实修改时间;它绝不能被当成删除。复用 CORS 已有设计思路,不改 CORS 本身或扩展为通用框架。
|
||||
|
||||
- 使用 `BucketMetadata` 原始字段时间;字段时间为零时在比较视图中补为 Created,与 `defaultTimestamps()` 一致,不借用会隐藏墓碑时间的 getter 或整记录 `lastUpdate()`。
|
||||
- 在已知 Created 下,零字段时间回退后等于 Created 的状态是 baseline:有有效非空配置的 baseline-live 可作为初始化候选;空 baseline 只是缺省值,不能作为删除或 heal 来源。真正严格早于自身 Created 的字段不是候选;Versioning/Object Lock 的空候选无论时间如何都不参与选源。真实 live/tombstone 的时间必须大于 Created。
|
||||
- 专用 peer 的零时间保留兼容例外:锁内按本地新操作分配时间并限频记录 legacy-zero;不受删除传播开关影响,不在源时间排序保证之内。bulk 零时间仍按现状拒绝。
|
||||
- 非零事件**严格早于**目标桶 Created 才成功/no-op 并记录 before-created,heal 对这种目标跳过。等于目标 Created 的 live 事件可更新仍是 baseline 的目标字段,时间仍保存为源 Created,不盖上到达时间;同时间的空/nil 只算空 baseline,不能删除配置。不同桶世代不能自动合并,需要运维处理,不纳入收敛承诺。
|
||||
- 排序先比较是否为真实状态:任何真实 live/tombstone 都胜 baseline,不能让较晚创建的默认值压过较早的真实修改。真实状态之间再比较源时间,同时间 tombstone 胜 live、live/live 的**稳定比较键字节序较大者胜**。baseline-live 只胜空 baseline,或在 baseline-live 之间按同一稳定键较大者胜;永远不能覆盖真实 live/tombstone。所有候选都是 baseline-live 时仍可确定性初始化并收敛;全部为空 baseline 才安静 found=false。键、状态级别和源时间相同为 no-op。deployment ID 不参与上述比较,也不作为新字段保存。
|
||||
- Quota 使用现有解析结果的 JSON 编码作为比较键。Policy 在 Server 内生成确定性比较表示:既有解析器校验/去重后,对其**完整 JSON 树**的对象键和集合数组递归排序,统一覆盖 Statement、Action/NotAction、Resource/NotResource、Principal、Condition;保留数字类型和精度。比较键必须是已解析策略的纯函数,同一配置从两个站点分别解析也必须得到相同键。普通 `json.Marshal(BucketPolicy)` 不稳定,不能直接作键;不增加依赖不支持的 NotPrincipal 语法,不改 wire/schema。新状态比较仅使用这一套键:不把忽略 Sid 且对 Statement 顺序敏感的 `BucketPolicy.Equals` 再叠加为另一套判等规则,既有公开统计对 Equals 的使用保持不变。
|
||||
- XML 使用有效文档字节,保留大小写和实际内容;Versioning 先应用下述现有 Object Lock 约束。比较器不能依靠字节序方向来补偿保存阶段的隐式改写。
|
||||
|
||||
### 3. 先得到有效状态,再比较与提交
|
||||
|
||||
原始读取、类型处理、比较、修改及保存均在现有 `metadata.lock` 内。内部入口必须传递现有 lock context,避免 legacy migration 再次取锁。
|
||||
|
||||
- 提取并复用 `parseAllConfigs` 已有的 Object Lock→Enabled Versioning 归一规则,使比较视图和真正保存一致;不得扩大 suspend、prefix exclusion、retention 的限制。bulk 先确定实际接受的 Object Lock,再比较该约束下的 Versioning,并在最终提交前应用同一规则。
|
||||
- 以归一化后的有效 `(kind, key, time)` 判定变化;更新了时间也算变化。完全重复不保存、不通知;一次 bulk 校验失败不保存部分结果;成功至多保存一次,解锁后通知。
|
||||
- 可删除字段清空必须基于现有 parse=false 的新加载对象,不能在已经解析且仍持有旧 quota 的对象上执行 Update(nil)。bulk 保留原始读取再解析保存的方式。不要顺手修改 Quota getter 或所有 `parseAllConfigs` 空分支。
|
||||
- 保存函数必须让需要发送 hook 的调用方拿到**本次提交的最终快照**,不能先解锁再读取“最新”状态拼接旧时间。最小做法是让内部 `saveMetadata` 接收元数据指针并回写 Save 的归一化结果,机械更新现有少量调用;对外 `Update/Delete` 签名不变,新增内部提交结果仅供需要该快照的本地 handler/import 使用。不得原地修改已发布到缓存的引用字段。
|
||||
- 保留现有物理桶存在检查、删除锁序、迁移、后台通知上下文。真实历史桶 Created 为零时仅走少见的物理桶 Created 补齐路径;物理桶缺失返回现有错误;创建时间仍未知则不伪造到达时间,报告 indeterminate。
|
||||
|
||||
## 三个实现提交
|
||||
|
||||
### 提交 1:原子 apply、发送一致性与本地时间
|
||||
|
||||
主要文件:`cmd/bucket-metadata-sys.go`、`cmd/bucket-metadata.go`、`cmd/site-replication.go`、`cmd/admin-bucket-handlers.go`,以及实际需要提交快照的本地配置 handler。
|
||||
|
||||
1. 在现有 update/delete 内部路径增加源时间、状态比较和提交结果;公开签名不变。六个 peer handler 移除锁外 getter 判旧,锁内持久化原始源时间。保留 Object Lock 的 legacy Tags 字段载荷回退。
|
||||
2. bulk 对明确提供的六类字段在已有锁内逐字段比较,再原子保存;未提供字段不动,不能循环调用会重入锁的公开 handler。保留 CORS 独立分支与既有行为。
|
||||
3. 六类本地实际写入在锁内分配 `max(UTCNow(), Created+1ns, 当前字段时间+1ns)`。其他类型不变。`enablePeerBucketVersioning` 的实际变更也使用它,只有缺失配置的创建 bootstrap 继续 Created 默认值。
|
||||
接管已有桶时保留原 Created,再执行现有 `SetCreatedAt`;如果 Created 改变,仅将这六类中原本为零或等于原 Created 的默认字段时间调整到新 Created,随后再做既有 versioning/lock bootstrap。原本晚于旧 Created 的真实修改/删除时间及其配置保持不变。不能仅凭 payload 为 nil 判断默认值。该小分支防止 Created 前移时默认值变成假墓碑、后移时历史初值被误判为无效,复用已有接管锁,不重做 #78 的配置保护。代码注释明确限定六类的原因;本轮不改新比较器未读取的其它配置时间。
|
||||
4. quota 本地 PUT 保留零值文档并原样表示该语义;Policy 空策略本地与 peer 一致走删除。需要归一化的本地 handler 从本次提交快照生成 hook;其他内容不发生归一变化的路径可保留既有编码,但必须满足三元组一致性。
|
||||
5. 导入在每桶最终提交锁内,为本次涉及的六类字段生成共同 commitAt,严格大于 Created 和这些字段当前时间且不早于锁内现在。该时间同时用于落盘和 bulk hook,不能沿用 ZIP 开始时间。bulk hook 从最终提交快照构建;若导入的空 Policy 已归一成删除,另外使用现有专用 Policy nil 事件表达它,不能因 `omitempty` 漏发。未导入字段不改,Object Lock 的既有派生 Versioning 修正保留原时间语义;CORS 继续独立时间/事件,其他字段不参与该上界。
|
||||
|
||||
完成条件:六类源时间落盘;旧事件不能越过锁覆盖新状态;四类删除不会被旧 PUT 复活;真实 wire、落盘和出站状态一致;重复无写入;bulk 与 import 没有绕过排序或静默遗漏删除。
|
||||
|
||||
### 提交 2:heal 选源与应用同规则
|
||||
|
||||
主要文件:`cmd/site-replication.go`。
|
||||
|
||||
1. 先过滤空 baseline、无效来源、严格早于自身 Created 的字段和 update-only 空配置,再按上述强弱排序选最大状态;无候选必须显式返回 found=false。保留历史 baseline-live 的初次同步和 heal 能力,消除六处“先 seed map 首项,再过滤默认值”的写法。
|
||||
2. 选源和目标遍历都跳过 `info.Sites` 中不存在的 deployment ID,包括不可达站点的空 ID 占位项;单一 peer 失败记录后继续其它目标,不因 map 顺序放弃健康站点。不改变状态计数或新建重试机制。
|
||||
3. 本地 heal 使用提交 1 的源时间 update/delete;远端仍用原有逐类型 RPC,全部携带源时间,补齐 Tag 的 UpdatedAt。
|
||||
4. 比较完整有效状态,去掉公开 mismatch/payload-only 对写入的门控;同内容较旧时间也同步。对已归一且相同的目标不写入、不发 RPC。Versioning 比较使用与该站点 Object Lock 一致的有效文档;全站点 Lock 状态补齐后不再因旧原始文档产生空转。
|
||||
5. 保留 #91 的计数与公开字段;创建世代冲突、无可用来源通过有限诊断解释。已知 baseline 且各站点无实质差异时安静 no-op。
|
||||
|
||||
完成条件:map 顺序不影响结果;默认空值不再删真配置;历史 baseline-live 可以初始化新站点、不能覆盖真实状态;同内容不同时间、同时间冲突最终一致;Tag 时间完整;Quota 删除后磁盘、缓存、重载一致;不可达占位项不阻断健康目标;完整状态已可见且稳定时,第二轮 heal 无写入/广播。
|
||||
|
||||
### 提交 3:新增删除传播与有界诊断
|
||||
|
||||
只增加一个启动开关,暂定 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off/on`,默认 off;实现沿用现有 env 开关写法,不做能力协商。
|
||||
|
||||
| 行为 | off:升级阶段 | on:所有参与节点修复后 |
|
||||
| --- | --- | --- |
|
||||
| 带时间 peer apply、锁内排序与 heal | 使用提交 1/2 | 同左 |
|
||||
| 专用事件零时间 | 兼容应用并记录 legacy-zero | 同左,不新增协议拒绝 |
|
||||
| Tags/SSE/Quota nil payload 时间导出 | 保留旧版条件导出 | 导出 `time > Created` 的真实删除时间 |
|
||||
| Policy 时间导出 | 保留已有行为 | 保留已有行为 |
|
||||
| 初次同步的真实删除 | 保留已有行为 | Policy/Tags/SSE/Quota 都发送专用 nil + source time 事件 |
|
||||
|
||||
开关只控制**新增**删除信息的导出/初次发送,普通本地删除事件照常复制。**off 不等于禁止删除传播:Policy 墓碑原本已导出,修复后在 off 下也照常参与 heal;Tags/SSE/Quota 的新增墓碑信息才被门控。** off 期间这些字段的隐藏墓碑会使 heal 继续尝试过时 RPC,由接收端排序拒绝;这是状态不可见时的已知代价,不承诺第二轮零 RPC,也不为这种正常拒绝增加每轮日志。
|
||||
|
||||
开关不检测或证明远端能力。启用条件是所有参与站点的全部节点已经修复,同一站点配置一致,旧请求排空;旧节点仍在线时保持 off。此隔离有实证依据:旧版接到新增 Quota heal 墓碑会留下已解析缓存残留。
|
||||
|
||||
日志仅保留三个实际原因:legacy-zero、before-created、indeterminate(未知创建时间、缺失/不可达来源或有实际差异却无可用候选)。精确重复、正常旧事件和成功裁决的同时间冲突不记警告。复用 `LogOnceIf`,以稳定的桶/字段/原因作为 key,**错误正文也必须稳定**;变化的时间与 peer 详情放入日志 ReqInfo,沿用现有每小时清理,不新增限流框架、不输出完整策略。
|
||||
|
||||
Server 文档解释启用顺序和回滚:降级前所有修复节点先关开关,然后滚动降级;旧软件缺陷会恢复。点名旧版 Tag heal 无 UpdatedAt 的来源。旧版到达时间污染、legacy-zero 产生的新本地时间以及创建世代分歧无法自动还原;操作者查看状态后在权威站点重新提交需要纠正的配置/删除。历史世代冲突先处理桶身份,不能靠任意站点强刷绕过创建保护。
|
||||
|
||||
## 最小验收矩阵
|
||||
|
||||
| 组 | 必须覆盖 | 证据方式 |
|
||||
| --- | --- | --- |
|
||||
| T1 | 六类 PUT 源时间;四类 DELETE;旧事件不回退;重复无写入;零 quota 三元组一致;带合法 Version 的空 Policy PUT 成功、GET NotFound、专用删除事件与落盘一致 | 真实 admin/S3 路由、ErasureSD/Erasure16、磁盘重载、RPC 捕获 |
|
||||
| T2 | 同时间两种到达顺序结果相同、删除优先;Policy 多集合及 NotAction/NotResource/Condition 反复编码与排列后,两站点独立解析得到相同键;Sid 差异及大整数不被错误合并 | 确定性比较器测试与代表性真实 handler |
|
||||
| T3 | Versioning/Object Lock nil/空 no-op;legacy Tags 载荷回退;Object Lock + prefix exclusion/ExcludeFolders 在普通写、peer、bulk/import 后有效状态一致,第二轮 heal 无额外写入 | 原有 #76/#78 回归加针对性用例 |
|
||||
| T4 | 锁前旧事件排队、较新写先提交后旧事件不得覆盖;不同字段并发均保留 | 复用已有 `lockBucketMetadataAcquireHook` / RMW 屏障、两种 ObjectLayer、目标 race |
|
||||
| T5 | bulk 新/旧/缺省字段混合;真实 JSON 编解码的 nil、空 RawMessage、显式 null、空字符串、空策略、零 quota;非法字段不部分保存;只导入 tags 不修改 Policy/Quota | 真实 bulk 路由、缓存与磁盘 |
|
||||
| T6 | 本地时间胜过已有未来时间;相邻提交不倒序;ZIP 导入期间插入写入,最终落盘和发出事件的状态与时间一致,空 Policy 删除不漏发 | 本地 API、import 路由与 RPC 捕获 |
|
||||
| T7 | map 排列、空 baseline/全无候选;baseline-live 初始化与同级冲突收敛,但不能覆盖真实 live/tombstone;nil@Created 不删除配置;空 update-only;Tag 时间、Quota 清缓存、空 ID、世代冲突 | 确定性 heal 本地/远端用例 |
|
||||
| T8 | 墓碑经保存/缓存失效/重启仍有效;缺桶、排队写入、零 Created;历史字段时间等于 Created 的桶经初次同步、一轮 heal 后一致,第二轮无写入/广播;接管 Created 前移/后移/不变时默认时间仍是 baseline,真实 PUT/DELETE 时间不变;on/off 与 legacy-zero,off 下 Policy 仍 heal、其它隐藏墓碑允许被拒 RPC 但无每轮警告 | ObjectLayer 与进程内旧版 wire/SRInfo 模拟;复用既有删除/迁移回归 |
|
||||
| T9 | 修复版双站点短暂断线、漏发/重复/乱序后六类合法配置收敛;删除传播启用后第二轮稳定无写入;日志确实有界 | 隔离双站点实验,不把未知桶世代或零时间事件算成通过 |
|
||||
|
||||
固定旧版与修复版混合进程仅作一次性升级冒烟,不新增为长期提交门槛。将已有审计用例改成正式确定性回归,不能把遍历 map 的概率复现直接提交。先记录未修复失败,再验证通过;复用原有 Object Lock、adoption、metadata-lock、计数、CORS 回归,并在实现后运行目标 race、仓库必需检查和完整 Go CI。方案审查、局部测试、双站点结果、合并与发布是不同证据。
|
||||
|
||||
## 作者复核结论
|
||||
|
||||
三个提交分别处理写入正确性、heal 收敛和新增删除信息的升级边界;每项对应已证实缺陷或本次修复直接触及的实际路径。缩减了拒绝零时间的新协议行为、混合版本长期测试门槛和新锁屏障;保留已有持久化、通知与缓存语义。
|
||||
|
||||
Opus 第二轮已确认首轮 R1/R2/R3 实质关闭,第三轮确认历史桶初始化边界,第四轮确认接管默认时间修正必要、充分且未扩大范围;最后两轮均为 0 阻断。四轮原文、版本快照、逐项处置与模型调用证据均保留。方案可以进入实现;当前只完成方案和诊断,仍须通过上述实现期验证,不能据此认定问题已修复或可关闭。
|
||||
@@ -0,0 +1,85 @@
|
||||
# #77:桶配置复制修复与验收归档
|
||||
|
||||
归档日期:2026-09-12。对象为 [SILO #77](https://github.com/pgsty/silo/issues/77)。问题真实;来源时间、删除状态和 heal 选源共同决定是否收敛,不能只补一个删除分支。最终实际 Claude Code Opus 5 Max 实现审查结论为 **GO_WITH_NONBLOCKING_NOTES,阻断 0**,完整 cmd 和最终 lint 随后通过。
|
||||
|
||||
本文固定研究与验收时的事实。合并状态以 Issue 关联 PR 为准;主干包含代码不等于镜像、软件包或生产部署已经更新。研究叙述见伴生站的[中文设计记录](https://github.com/pgsty/silo.pgsty.com/blob/main/content/blog/design/bucket-metadata-convergence.zh.md)和[英文设计记录](https://github.com/pgsty/silo.pgsty.com/blob/main/content/blog/design/bucket-metadata-convergence.md),操作契约见 [Server site-replication README](../site-replication/README.md)。
|
||||
|
||||
## 实现为什么最小、必要且足够
|
||||
|
||||
源端 10:00 PUT、10:01 DELETE,目标到 10:05 才收到 PUT。旧实现把字段时间写成 10:05,随后把源时间为 10:01 的删除当作旧事件跳过,返回成功但保留配置。删除后没有导出时间的字段,在漏发后还会被对端旧值恢复。另有锁外判旧、批量入口绕过排序、默认值抢占来源、Tags heal 丢失时间和同内容不更新时间等独立入口。
|
||||
|
||||
| 必要改动 | 少了它会发生什么 | 复用的边界 |
|
||||
| --- | --- | --- |
|
||||
| 在现有整桶锁内读取、比较并保存来源状态 | 锁外判旧仍可覆盖并发的新状态;到达时间继续污染排序 | 既有 `.metadata.bin`、`metadata.lock` 和物理桶存在检查 |
|
||||
| Policy、Tags、SSE、Quota、Versioning、Object Lock 共用确定性比较 | 等时冲突仍依赖到达顺序;heal 与接收端可能选出不同结果 | 既有载荷、字段时间及 Created,不增加 wire 或持久化字段 |
|
||||
| 专用事件、bulk、导入、本地写和 heal 都使用提交后的状态 | 只改一个 handler 会留下旁路;归一化后的 Versioning/Quota 与出站事件可能不同 | 既有解析、保存与复制钩子,bulk 仍是一次原子保存 |
|
||||
| 删除导出开关默认关闭 | 直接对旧版节点导出新增删除状态有已复现的 Quota 缓存风险 | 全部参与节点升级后统一启用,不引入能力协商框架 |
|
||||
| 物理 Created 恢复与初次同步传播 | 历史无创建时间的真实桶会失去六类配置写入能力 | 现有物理探测;目录 mtime 只是近似值,不推断真实桶世代 |
|
||||
| Policy 状态键与 heal 一致;异常按原因去重 | 永久假 mismatch 无法修复;正常空基线噪声或无来源异常静默 | 既有每站点统计与 logger,不增加后台协调系统 |
|
||||
|
||||
真实状态优先于创建基线;真实状态按来源时间、等时删除优先、规范内容键排序。历史 `live@Created` 可以初始化空目标,但空基线不能删除真实配置。本地纠正时间在锁内取 `max(now, Created+1ns, fieldTime+1ns)`;带时间 peer 事件保留来源时间。Versioning/Object Lock 的 nil 保持 no-op,Quota `null`/`{}` 保持零配额配置,空 Policy 归一为删除。
|
||||
|
||||
Policy 编码器保留在 GET/export/peer 是必要的:既有解析器可以保存的负集合策略必须能读回和复制。PUT/import 继续使用同一编码器,避免额外表示分支。没有为了本轮重写 CORS、Lifecycle、对象复制、IAM 或 #91 已完成的计数,也没有修改 SDK、模块依赖或升级协议。
|
||||
|
||||
充分性限定在同一桶世代、有效且可排序的来源状态、全部参与节点升级并开启删除导出的范围内。历史时间污染、零时间兼容事件和桶身份冲突不属于自动恢复承诺。
|
||||
|
||||
## 计划与实际对抗审查
|
||||
|
||||
- [实施前核验与失败复现](issue-77-current.md)、[最终 v4 计划](issue-77-plan.md)。这些文件保留历史状态,不能当成当前待办。
|
||||
- [四轮计划审查与意见处置](issue-77/review/final-review.md):前两轮 NO_GO,后两轮零阻断;保留各版计划、完整最终意见、调用元数据和必要探针。
|
||||
- 三轮实现审查均使用实际 Claude Code `claude-opus-5 --effort max`。模型身份取自实际 assistant 消息,effort 取自显式调用参数。源码在独立快照中审查,审查者读代码和执行结果,没有代为运行这些验收。
|
||||
|
||||
| 实现审查 | 固定源码 | 结论与处置 |
|
||||
| --- | --- | --- |
|
||||
| [首轮完整意见](issue-77/implementation-review/opus5-max-review.md) / [调用记录](issue-77/implementation-review/session.json) | `4089113e3` | GO_WITH_NONBLOCKING_NOTES,条件性阻断 F1;物理 Created、Policy 假 mismatch、诊断和证据缺口随后修复 |
|
||||
| [第二轮意见](issue-77/implementation-review/round-2/opus5-max-review.md) / [调用记录](issue-77/implementation-review/round-2/session.json) | `62cf066ff` | 条件性和无条件阻断均为 0;修正首轮对 Policy 编码器的过宽质疑,进一步补齐无来源诊断和初次同步回归 |
|
||||
| [最终定向意见](issue-77/implementation-review/round-3/opus5-max-review.md) / [调用记录](issue-77/implementation-review/round-3/session.json) | `fcbb93e89` | 阻断 0,确认后续 `461e9a721` 的测试改写等价;读取时尚在运行的 cmd/lint 后续均退出 0 |
|
||||
|
||||
最初实现审查 SHA `4089113e3` 补签 DCO 后对应 `1ee64a8d8`,两者树完全相同。生产代码最终固定在 `fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025`;验收源码 HEAD 为 `461e9a721047c63e1a95f54ad4b533a6b89def30`,只在两个测试文件有格式和等价条件改写,见 [tree 对照](issue-77/implementation-review/round-3/final-tree-equivalence.json)与 [patch](issue-77/implementation-review/round-3/test-style.diff)。本次归档不改变生产代码或正式回归测试。
|
||||
|
||||
审查原文保留当时的判断,不将后续作者修正倒写成评审者已经观察到的结果。首轮误读 README 中既有的空 Policy / 零 Quota 说明,第二轮修正编码器必要性的判断;最终处置以本文和伴生站的逐项表为准。
|
||||
|
||||
## 验收证据
|
||||
|
||||
| 检查 | 执行对象与结果 |
|
||||
| --- | --- |
|
||||
| [完整 cmd](issue-77/implementation-review/round-3/final-cmd.log) / [命令及退出码](issue-77/implementation-review/round-3/final-cmd.json) | `fcbb93e89`,CGO=0,全部通过,包测试 492.776 秒 |
|
||||
| [最终目标 race](issue-77/implementation-review/round-3/final-target-race.log) / [命令](issue-77/implementation-review/round-3/final-target-race.json) | `fcbb93e89`,真实创建时间、诊断、初次同步、接管、Policy 状态和全部 CORS 命名用例通过 |
|
||||
| [测试改写后的 race](issue-77/implementation-review/round-3/final-style-target-race.log) | `461e9a721`,受影响用例通过 |
|
||||
| [build](issue-77/implementation-review/round-3/final-build.json)、[vet](issue-77/implementation-review/round-3/final-vet.json)、[lint](issue-77/implementation-review/round-3/final-lint.json) | 最终生产树 build/vet 通过,`461e9a721` lint 零问题;可选 typos 未安装,按 Makefile 跳过 |
|
||||
| internal、S3 Select race、生成文件、兼容检查、六平台编译 | `62cf066ff` 的 `ci-*-after.log` 作为较早阶段的补充证据,不冒充最终树在全部平台运行通过;归档中的空生成日志本身不证明退出状态 |
|
||||
| [两个真实站点进程](issue-77/implementation-review/round-3/final-runtime.log) / [执行元数据](issue-77/implementation-review/round-3/final-runtime.json) | 干净 `fcbb93e89` 构建:六类历史配置、真实漏发恢复、乱序、四类删除跨重启、两次各 65 秒零 metadata RPC、异常去重、gate=off 与固定旧版的 PUT/DELETE 冒烟均通过 |
|
||||
| [伴生站构建](issue-77/implementation-review/round-3/docs-check.log) | 提交为 `9fa6248` 的文章内容通过 Hugo 严格构建和站内链接检查,EN 1207 / ZH 1219 页;后续合并状态文案单独检查 |
|
||||
|
||||
[归档清单](issue-77/archive-manifest.json)保存每份材料的原始和归档后 SHA-256,以及全部改动源码的 SHA-256。[二进制身份](issue-77/implementation-review/round-3/binary-identity.json)保留实际版本、Go 构建身份和摘要:最终运行二进制来自干净 `fcbb93e89`,SHA-256 为 `4825a801ce0ac48d636d9428ce4cd5c17a20b6cfec0b569de70a124c9c005049`;旧版来自干净基线 `5c5765816`。第三轮审查的 cmd/lint 条件由上述已完成的 JSON 关闭。
|
||||
|
||||
### 反向复现与排除的证据
|
||||
|
||||
- [confirmed-before.log](issue-77/implementation-review/round-2/confirmed-before.log):正式测试叠加 `1ee64a8d8` 的 `erasure-server-pool.go` / `site-replication.go`,真实 ObjectLayer 创建时间恢复及旧 Policy 顺序失败;[修复后](issue-77/implementation-review/round-2/confirmed-after.log)和 [race](issue-77/implementation-review/round-2/confirmed-after-race.log)通过。
|
||||
- [confirmed-diagnostics-before.log](issue-77/implementation-review/round-2/confirmed-diagnostics-before.log):叠加旧诊断路径,普通空基线误报。第一处断言已经终止测试,不能声称它同时证明后面的 Warning 级别和去重断言在旧码失败。
|
||||
- [no-source-before.log](issue-77/implementation-review/round-2/no-source-before.log):直接运行 `62cf066ff`,三种已有无效状态在没有有效来源时缺少诊断;这一次不是旧码 overlay。
|
||||
- [initial-sync-before.log](issue-77/implementation-review/round-2/initial-sync-before.log):保留物理恢复修复,仅叠加 `1ee64a8d8` 的 `site-replication.go`,首次同步出站仍传零 Created。该单测的 peer 只确认 RPC,不证明真实对端或本地 peer 分支已经落盘。
|
||||
|
||||
`findings-before.log` / `findings-after-1.log` 是早期夹具失败,不能证明产品缺陷;过渡阶段的旧 helper 遗留引用和测试格式失败也不能当成通过记录。这些草稿不进入本归档的验收证据。另一次 Codex 调用因使用限额失败,不计入完成审查次数。
|
||||
|
||||
### 重跑真实双站点验收
|
||||
|
||||
[独立 Go 驱动](issue-77/runtime/main.go)与原执行内容相同,仅添加 `ignore` 构建标记及说明,避免进入普通包测试。它只启动回环地址上的一次性实验实例,每站四个数据目录,使用文件内声明的专用实验凭据。给它一个全新目录和自行从固定提交构建的两个二进制:
|
||||
|
||||
```sh
|
||||
go run docs/investigations/issue-77/runtime/main.go \
|
||||
/absolute/new-lab-directory /absolute/silo-fixed /absolute/silo-before
|
||||
```
|
||||
|
||||
固定构建分别为 `fcbb93e89` 和 `5c5765816`,可在独立干净 worktree 使用 `CGO_ENABLED=0 go build -trimpath`;记录 `--version`、`go version -m` 和 SHA-256。省略旧版二进制参数会跳过混合版本冒烟,不能将其报告为执行过。最终删除收敛快照见 [converged.json](issue-77/runtime/converged.json),数组顺序由驱动的 `states` 函数定义。
|
||||
|
||||
原执行环境为 `go1.27.1 darwin/arm64`。因宿主盘可用空间比例触发存储保留阈值,完整 cmd 和进程实验使用独立 16 GiB APFS 测试卷,没有降低生产阈值。临时卷、审查 worktree 和实例进程均已清理。上述是本地可复核的执行证据,不替代 GitHub Actions、Linux 多节点集群、发布制品或生产验证。
|
||||
|
||||
## 剩余边界
|
||||
|
||||
1. `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off` 是默认值。全部站点全部节点升级、配置一致并排空旧请求后,统一开启并重启,才具有新增 Tags/SSE/Quota 删除的漏发自愈能力。普通删除事件与原有 Policy 删除导出仍保留。
|
||||
2. 到达时间污染、零时间旧事件和桶世代冲突无法凭现有数据重建。物理 Created 只是目录 mtime 的近似值;较早事件仍跳过,由操作者核对权威状态后重新提交纠正。
|
||||
3. 日志按每桶/字段/原因去重,仍可能随桶和有值字段数量增长。本轮不增加全站日志调度。
|
||||
4. 最终审查保留三项非阻断改进:解码失败诊断中的时间可能为零;初次同步单测没有证明本地 peer 分支落盘;未来并发后台日志可能需要更强的测试隔离。生产路径已复核,本轮不为这些建议新增 helper 或 hook。
|
||||
|
||||
归档保留最终报告与调用身份,不发布原始模型推理流、二进制、实验数据卷或无效夹具日志。工作站路径、历史文档链接与非必要 JSON 字段经过整理;哈希清单区分原始产物与归档副本,不能将整理后的报告哈希冒充原文件哈希。
|
||||
@@ -0,0 +1,437 @@
|
||||
{
|
||||
"archived_at": "2026-09-12",
|
||||
"issue": "https://github.com/pgsty/silo/issues/77",
|
||||
"base": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"tested_source_head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
|
||||
"reviewed_production_head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"source_sha256": {
|
||||
"buildscripts/rebrand-guard/compat-baseline.json": "7ea6485008dd998373144ebded82483fe2c33ae60712abcf2fba973a7506b3e1",
|
||||
"cmd/admin-bucket-handlers.go": "3aa8fc80cb8f2c4725acb8fbec026d47cd71a60ad58d06f905aa013540b30db7",
|
||||
"cmd/bucket-cors-site-replication_test.go": "2cff52d3857e912526aaae40736b077e6b8bd640e098934b8af0ec76354f3747",
|
||||
"cmd/bucket-metadata-replication.go": "07730f6a19b78a9b51dd9d56696812299b3aedce1a779b23178643e2684ba686",
|
||||
"cmd/bucket-metadata-sys.go": "6d0936f537f104bdae1c460554c5ef6f34e6f057d8eb16569a23f012a9498d60",
|
||||
"cmd/bucket-metadata.go": "2b692e42df36373fb98a74792098e1251ae886f5e2759add91a00909b596d04c",
|
||||
"cmd/bucket-policy-handlers.go": "1ac487ef97f63ab19358212f1d582659b4d6fbd17888f5bb21cd5e5028e783e9",
|
||||
"cmd/bucket-versioning-handler.go": "23ec85d45bacf687ada30ea6a06cb5e6d5d5dd32a81865a24aa52a030759e3ce",
|
||||
"cmd/common-main.go": "c06df6c3051ceb264f7ddbdddea33ebe38a59e0c113b5c4c44906c1c2e08a01b",
|
||||
"cmd/erasure-server-pool.go": "1ebb616ac971a22bd736d1b381845c0011cbc95b18dc273e4a946eec8244c80d",
|
||||
"cmd/site-replication-metadata-gate_test.go": "1f170b84d2d25d67a4386451ce26597a752f96c9215155f3156ac1fd140ead86",
|
||||
"cmd/site-replication-metadata-heal_test.go": "37191261f643dae88fbe5be44da8f08b2c52455245d5c61a2df564ecb7c8bffd",
|
||||
"cmd/site-replication-metadata.go": "5756a5495723a2d9456b1c3ed7875cec789298eefb8230ea57126f9de83c4287",
|
||||
"cmd/site-replication-metadata_test.go": "2898c88d48d9693d1c974ee7fd6846b86fe354f378caa630eac2ad0d9b9edfe8",
|
||||
"cmd/site-replication.go": "5015550411bd4d82119cdb96b7f663fd082e4906baee259b30aedbaa616354c8",
|
||||
"cmd/site-replication_test.go": "d85dd2aa991a6cce1fccfa17205e4a478f4ced00e0585fbe2aee2dbf97884b8a",
|
||||
"docs/site-replication/README.md": "abe39529bf5a864dc7c945fd368b647f3fa2121fb324cea1cf63373eff9683a7"
|
||||
},
|
||||
"original_review_head": "4089113e3edbd21a29be8e6af74662462bcc22af",
|
||||
"equivalent_signed_commit": "1ee64a8d895f0876580d5e27d0ce6facf41c347e",
|
||||
"reviewed_tree": "0d18c940c6dec380dffc977cb5720b4e2384e46a",
|
||||
"environment": "go version go1.27.1 darwin/arm64",
|
||||
"scope": "Historical analysis and plan, final review reports and invocation metadata, selected executed test logs, source/binary identity, and rerunnable loopback lab. Excludes model reasoning streams, binaries, temporary volumes and invalid fixture logs. Paths, historical document links and trailing console whitespace were normalized; JSON billing fields and build-info dependency lists were omitted. Original and archived hashes distinguish these editorial transformations.",
|
||||
"final_verdict": "GO_WITH_NONBLOCKING_NOTES",
|
||||
"blocking_findings": 0,
|
||||
"records": [
|
||||
{
|
||||
"file": "issue-77-current.md",
|
||||
"source_record": "issue-77-current.md",
|
||||
"original_sha256": "702f9fe93703c8d4b79a3e2c4709e7f49614dc20211ed5221f3adbd5e3809e2d",
|
||||
"archived_sha256": "b22b50e584de73cfe2f101d35bb912d314feb7f0e0ce48018e1b4a48dc4cf5c5"
|
||||
},
|
||||
{
|
||||
"file": "issue-77-plan.md",
|
||||
"source_record": "issue-77-plan.md",
|
||||
"original_sha256": "2ee214694f16e5949ed58290364770149aee3748faee3614122fee189ef83add",
|
||||
"archived_sha256": "36ae4f99bbc22827cc9a1a437b413713e61f442a568347eeef836b4245c67081"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/current-tests.log",
|
||||
"source_record": "issue-77/current-tests.log",
|
||||
"original_sha256": "d6ed0ef373a37f8254d387a0757a431c2785b6b9a30c4e06d0034688ca3a41e8",
|
||||
"archived_sha256": "d6ed0ef373a37f8254d387a0757a431c2785b6b9a30c4e06d0034688ca3a41e8"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/issue77_heal_review_test.go.txt",
|
||||
"source_record": "issue-77/issue77_heal_review_test.go.txt",
|
||||
"original_sha256": "f1b164afff783282253660b8fa5c131b0de946a078d370cdedd95c338fa73810",
|
||||
"archived_sha256": "f1b164afff783282253660b8fa5c131b0de946a078d370cdedd95c338fa73810"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/issue77_review_test.go.txt",
|
||||
"source_record": "issue-77/issue77_review_test.go.txt",
|
||||
"original_sha256": "77e557bd098fad1345085b6bca27ff8a733984092c68c6b26d0dc74e5fca8caa",
|
||||
"archived_sha256": "77e557bd098fad1345085b6bca27ff8a733984092c68c6b26d0dc74e5fca8caa"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/adoption-baseline-probe.go.txt",
|
||||
"source_record": "issue-77/review/adoption-baseline-probe.go.txt",
|
||||
"original_sha256": "4cf7aae6763f1ae736af087244dae01e32af1c546923fa782891759967981574",
|
||||
"archived_sha256": "4cf7aae6763f1ae736af087244dae01e32af1c546923fa782891759967981574"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/adoption-baseline-probe.log",
|
||||
"source_record": "issue-77/review/adoption-baseline-probe.log",
|
||||
"original_sha256": "d5ad1bfe7d4faafc7bfc6f55800dc47a7d60b7ec7124cc31ec9ed11b0154bde7",
|
||||
"archived_sha256": "d5ad1bfe7d4faafc7bfc6f55800dc47a7d60b7ec7124cc31ec9ed11b0154bde7"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/adoption-baseline-probe.metadata.json",
|
||||
"source_record": "issue-77/review/adoption-baseline-probe.metadata.json",
|
||||
"original_sha256": "618e81cb5852b08fbc3029d812647b9d2dbe3560349ee2e44b860356e12cffa9",
|
||||
"archived_sha256": "a462ad6b10cb7e2737330eed5d5e0a5f98ac0d664aa6fcf40a1f5f2f073709f6"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/author-followup-findings.md",
|
||||
"source_record": "issue-77/review/author-followup-findings.md",
|
||||
"original_sha256": "b6d60d813702db300313430325046aa89e45071d3cdc20083d82f061b75ab2c4",
|
||||
"archived_sha256": "b6d60d813702db300313430325046aa89e45071d3cdc20083d82f061b75ab2c4"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/decisions-v2.md",
|
||||
"source_record": "issue-77/review/decisions-v2.md",
|
||||
"original_sha256": "52c85f0b45073471e96abe86aa5a182858ff00a5dfe00f290f570f2aad86c5a1",
|
||||
"archived_sha256": "52c85f0b45073471e96abe86aa5a182858ff00a5dfe00f290f570f2aad86c5a1"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/decisions-v3.md",
|
||||
"source_record": "issue-77/review/decisions-v3.md",
|
||||
"original_sha256": "7325864c0a3e93689989612da0427b88f890ef8baf197f609c570636b16a2b85",
|
||||
"archived_sha256": "7325864c0a3e93689989612da0427b88f890ef8baf197f609c570636b16a2b85"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/decisions-v4.md",
|
||||
"source_record": "issue-77/review/decisions-v4.md",
|
||||
"original_sha256": "982b2e802aed883f2b5e211f0e1677388571ccde8e32af878a35b4192c1f4d16",
|
||||
"archived_sha256": "982b2e802aed883f2b5e211f0e1677388571ccde8e32af878a35b4192c1f4d16"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/final-review.md",
|
||||
"source_record": "issue-77/review/final-review.md",
|
||||
"original_sha256": "2410f7b92e2c74b235d54bc155b822675c6b5f9ab0b7226fccca42dc38b0a7ea",
|
||||
"archived_sha256": "2410f7b92e2c74b235d54bc155b822675c6b5f9ab0b7226fccca42dc38b0a7ea"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v1.md",
|
||||
"source_record": "issue-77/review/opus5-max-v1.md",
|
||||
"original_sha256": "8a3b750a261ef520acbe658395d3303cea0835b07184b96022781a2a5d0d4dcf",
|
||||
"archived_sha256": "8a3b750a261ef520acbe658395d3303cea0835b07184b96022781a2a5d0d4dcf"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v1.metadata.json",
|
||||
"source_record": "issue-77/review/opus5-max-v1.metadata.json",
|
||||
"original_sha256": "959a9eac3a8449ef27027fbc65aca55a32b1f46063175d7187922296ecd0a957",
|
||||
"archived_sha256": "d5e3be5c5377adeb88fdcb444ad5b6ee338af91d095027e1c9502298eda51d6d"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v2.md",
|
||||
"source_record": "issue-77/review/opus5-max-v2.md",
|
||||
"original_sha256": "848a476f18ce88208b72d8bf6cb49a2316b2b14dcba64ad66e636fe0a3d70595",
|
||||
"archived_sha256": "848a476f18ce88208b72d8bf6cb49a2316b2b14dcba64ad66e636fe0a3d70595"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v2.metadata.json",
|
||||
"source_record": "issue-77/review/opus5-max-v2.metadata.json",
|
||||
"original_sha256": "ff63843a993830ea48e1a44987390b94613dea99a796453bc91bebca2f4e119f",
|
||||
"archived_sha256": "3cd83d43f461fb1e13bbc4f4163769fa6daf4d151d8442d8615a3e813da38853"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v3.md",
|
||||
"source_record": "issue-77/review/opus5-max-v3.md",
|
||||
"original_sha256": "557ef70288a9a3939842a2f76d63694e2c103a8bfcaa99b7c161ee89e1f58061",
|
||||
"archived_sha256": "557ef70288a9a3939842a2f76d63694e2c103a8bfcaa99b7c161ee89e1f58061"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v3.metadata.json",
|
||||
"source_record": "issue-77/review/opus5-max-v3.metadata.json",
|
||||
"original_sha256": "ad488156f42e33e47ed5d0a267ee69ee82efff69881d0e6400e88cf88a09ef60",
|
||||
"archived_sha256": "1c84da615bccc2f1d7a2aa917ef7af751b0943cc7e413b39f59164a65acc2c98"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v4.md",
|
||||
"source_record": "issue-77/review/opus5-max-v4.md",
|
||||
"original_sha256": "cf10b9d7963ff2b7d4c0fbb1fe744a0e853549eaf7ce675a94cc000f74fe6682",
|
||||
"archived_sha256": "cf10b9d7963ff2b7d4c0fbb1fe744a0e853549eaf7ce675a94cc000f74fe6682"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/opus5-max-v4.metadata.json",
|
||||
"source_record": "issue-77/review/opus5-max-v4.metadata.json",
|
||||
"original_sha256": "95bae4427322fc42974a9d94ed5dd6d272a46b6eb669f95669a2fe1ef6ee4d42",
|
||||
"archived_sha256": "60d68b2db11557ee09d60b67748844a56d7b1f323803163ef03d420ad1cc8c42"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/plan-v1.md",
|
||||
"source_record": "issue-77/review/plan-v1.md",
|
||||
"original_sha256": "0218a2ca59750acd6a24dc5ece1d55a19762a863f1a2de8edc6ffc9b4ab072e0",
|
||||
"archived_sha256": "97d88bbd010cbd19744c2e2fee0fed8d3713ca553b5d0f06161299772ea8104b"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/plan-v2.md",
|
||||
"source_record": "issue-77/review/plan-v2.md",
|
||||
"original_sha256": "2b2c712b17f757ca1a8501714c6c2472bd442a6be50b29d92f2d87e23c412ba8",
|
||||
"archived_sha256": "b193ee6e47b24576f3f4d06608f1bcd0f7f2b0c27ef782ea4f74ee3706f7aa4d"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/plan-v3.md",
|
||||
"source_record": "issue-77/review/plan-v3.md",
|
||||
"original_sha256": "8c4ab51a397246a07bb1cc967df3377d26ac084b7928191eb8fecacf6e7c3aed",
|
||||
"archived_sha256": "fb3b37ab9dd2f4ab12b8050293603ed589645a730f1e23c81e2f7162ca46f7d2"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/plan-v4.md",
|
||||
"source_record": "issue-77/review/plan-v4.md",
|
||||
"original_sha256": "fbfd37498ab32a589d06254f32182869d5d49fc97722416c46c10b41e5f77816",
|
||||
"archived_sha256": "8624e9b86d3cb6daa497d43ac8cb50318ebabdd7eb4bb7c605e0720522a73bd9"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/policy-encoding-probe.go.txt",
|
||||
"source_record": "issue-77/review/policy-encoding-probe.go.txt",
|
||||
"original_sha256": "4e07cba70bdc329a0003ca0b6711886a6de22323293e8de3625cdc838ccaf537",
|
||||
"archived_sha256": "4e07cba70bdc329a0003ca0b6711886a6de22323293e8de3625cdc838ccaf537"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/wire-state-probe.go.txt",
|
||||
"source_record": "issue-77/review/wire-state-probe.go.txt",
|
||||
"original_sha256": "cafc6e2d482cc8e178ae3e17651410e3070049cd60009f0edd30fbac29e1309c",
|
||||
"archived_sha256": "cafc6e2d482cc8e178ae3e17651410e3070049cd60009f0edd30fbac29e1309c"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/review/wire-state-probe.log",
|
||||
"source_record": "issue-77/review/wire-state-probe.log",
|
||||
"original_sha256": "5077cd264a50cc05fe9dd628a6e98a3b0aaf94e04b65b712563191e11e4ca9f1",
|
||||
"archived_sha256": "5077cd264a50cc05fe9dd628a6e98a3b0aaf94e04b65b712563191e11e4ca9f1"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/opus5-max-review.md",
|
||||
"source_record": "implementation-review/opus5-max-review.md",
|
||||
"original_sha256": "e40dad6e1969898e35c82b7fd24517a5ee714d603a54fe3598b9e69c20922118",
|
||||
"archived_sha256": "e40dad6e1969898e35c82b7fd24517a5ee714d603a54fe3598b9e69c20922118"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/session.json",
|
||||
"source_record": "implementation-review/session.json",
|
||||
"original_sha256": "c347259a0a501242eb787319d43fe16c117d021ea4e418ce74b09b6c9c33ad08",
|
||||
"archived_sha256": "d26e9c3a7f3d0ba3d34de54e95c64e5939b8d5da1d190bb94d80423b6ca79205"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/opus5-max-review.md",
|
||||
"source_record": "implementation-review/round-2/opus5-max-review.md",
|
||||
"original_sha256": "306a856010f7f9bd1cbec39aba83236978f5a811c98f7fc3f8a08217f72de111",
|
||||
"archived_sha256": "306a856010f7f9bd1cbec39aba83236978f5a811c98f7fc3f8a08217f72de111"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/session.json",
|
||||
"source_record": "implementation-review/round-2/session.json",
|
||||
"original_sha256": "64113634f4e8d0525ed17dd74d092c74419d172aa94b501187daae8ebc27e279",
|
||||
"archived_sha256": "4f4dfe9c50b43b64f15fad830c39c116a296c302655d2c718475249521a68f19"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/opus5-max-review.md",
|
||||
"source_record": "implementation-review/round-3/opus5-max-review.md",
|
||||
"original_sha256": "9106938a7a34cfbc6a11d372dd5ffb71ae7417a77a56d10bbed178f539de8fba",
|
||||
"archived_sha256": "9106938a7a34cfbc6a11d372dd5ffb71ae7417a77a56d10bbed178f539de8fba"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/session.json",
|
||||
"source_record": "implementation-review/round-3/session.json",
|
||||
"original_sha256": "28944efc7a7b2c5451beedf0a05202ffc23b29fad2d9bd04cc89fad3d72184b9",
|
||||
"archived_sha256": "bf31d2dbd8009f0a966c0e820dfdbd5a2820e8b36e71e615f783932fd4ecc9de"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/confirmed-before.log",
|
||||
"source_record": "implementation-review/round-2/confirmed-before.log",
|
||||
"original_sha256": "af2df1c296c3377edb6aae45caa9fafd5e620ffb5a09438c15ca50e3d2cb1c10",
|
||||
"archived_sha256": "af2df1c296c3377edb6aae45caa9fafd5e620ffb5a09438c15ca50e3d2cb1c10"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/confirmed-after.log",
|
||||
"source_record": "implementation-review/round-2/confirmed-after.log",
|
||||
"original_sha256": "0c8be7325817049afe2bd2d3ed668e61cbde2887a443b988b8defbf12304b5bc",
|
||||
"archived_sha256": "0c8be7325817049afe2bd2d3ed668e61cbde2887a443b988b8defbf12304b5bc"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/confirmed-after-race.log",
|
||||
"source_record": "implementation-review/round-2/confirmed-after-race.log",
|
||||
"original_sha256": "da2725133b6c1b41a50a92700ff6adaf6dae7e9ac9d6b5812844715fd2b70e55",
|
||||
"archived_sha256": "da2725133b6c1b41a50a92700ff6adaf6dae7e9ac9d6b5812844715fd2b70e55"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/confirmed-diagnostics-before.log",
|
||||
"source_record": "implementation-review/round-2/confirmed-diagnostics-before.log",
|
||||
"original_sha256": "3d2c1325f1612c9ce79172b148d1ae02cf5443087270e9caeb46046fec1ea2c8",
|
||||
"archived_sha256": "3d2c1325f1612c9ce79172b148d1ae02cf5443087270e9caeb46046fec1ea2c8"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/no-source-before.log",
|
||||
"source_record": "implementation-review/round-2/no-source-before.log",
|
||||
"original_sha256": "e6bac0d117eb195b0ff67ef55eaff302c5f7ee36abf236fdcb69486330b31185",
|
||||
"archived_sha256": "e6bac0d117eb195b0ff67ef55eaff302c5f7ee36abf236fdcb69486330b31185"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-2/initial-sync-before.log",
|
||||
"source_record": "implementation-review/round-2/initial-sync-before.log",
|
||||
"original_sha256": "f200f9b961baf20e48f22b50cebd82525dd4647ea42203179d140b5474a28427",
|
||||
"archived_sha256": "f200f9b961baf20e48f22b50cebd82525dd4647ea42203179d140b5474a28427"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/docs-check.json",
|
||||
"source_record": "implementation-review/round-3/docs-check.json",
|
||||
"original_sha256": "76747f4d197d250e6bd3aaa1eba5a7013e56b655f10d143b984faed9d22b503b",
|
||||
"archived_sha256": "322f206ca48816c415ba29f9c67935c0eba038823eddfac8fb7e298ff50bda4a"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/docs-check.log",
|
||||
"source_record": "implementation-review/round-3/docs-check.log",
|
||||
"original_sha256": "af8752f3d8a072954dc6de2b8c72a4054cffbe3583aeae225a8d6623fc95e9fa",
|
||||
"archived_sha256": "a1d67bf392e45da04af9d819a5d76cec98006d1fc78c1a4cce486eb305bdc3ac"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-build.json",
|
||||
"source_record": "implementation-review/round-3/final-build.json",
|
||||
"original_sha256": "924df2a034af81acfe8961489664fa213791096db637e962ced6b78ba7ea8388",
|
||||
"archived_sha256": "c5de13eceee16b23886290fbe351a5269735a549723ed400ddb3e5f0bf7c88e5"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-build.log",
|
||||
"source_record": "implementation-review/round-3/final-build.log",
|
||||
"original_sha256": "d5d78e4d1a5b0dc5bc6042ae94ad64aaf2c500ff3e9091ea1dc9dbdfff04b263",
|
||||
"archived_sha256": "b212590ec72fc2489ea00c36458b3ce900b5b23543c7b141d211ac7b4b06f07b"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-cmd.json",
|
||||
"source_record": "implementation-review/round-3/final-cmd.json",
|
||||
"original_sha256": "431f663b748e81b9c311fa1b931483fe0b1ed09dec796b0894bb5d4a37f5f6b9",
|
||||
"archived_sha256": "39a02d590c9615721fbded8e95cc3b04ebacb32ba5c943d8e9a6f55d912c80c7"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-cmd.log",
|
||||
"source_record": "implementation-review/round-3/final-cmd.log",
|
||||
"original_sha256": "681576adac6d627140a791a22085fc520b61f4a6e04172bc7fb5d0f3967e9c40",
|
||||
"archived_sha256": "67723bedd3472d22a2e3f043e48fdab9bc5e8a212d932c513d44d61cc13eb09d"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-lint.json",
|
||||
"source_record": "implementation-review/round-3/final-lint.json",
|
||||
"original_sha256": "1dc2b38de9c4f37983e45e11ad5f416810f0c96e2cfeeff64b25846544918751",
|
||||
"archived_sha256": "35229aa6e418c54072abf141b4296be491a43cdba87c2f56f853076d78d66559"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-lint.log",
|
||||
"source_record": "implementation-review/round-3/final-lint.log",
|
||||
"original_sha256": "40eb5ec64ae64b73489fc7b6e145aabfea6554e0c73fa22be7b4a8201bb63d18",
|
||||
"archived_sha256": "d4b9455691ff7bcce76a0d886f603eda2179e7fa4e408b8e3bb987d693987c3a"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-runtime.json",
|
||||
"source_record": "implementation-review/round-3/final-runtime.json",
|
||||
"original_sha256": "6f76d83dbf9749f9dde0d6386f0b8a6b52cda6677c17ef9d739f05ea9379c52f",
|
||||
"archived_sha256": "b57c593e1023a0226e7950c142d5bd9a231832a9e7aefc7d9d584075b70b9aca"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-runtime.log",
|
||||
"source_record": "implementation-review/round-3/final-runtime.log",
|
||||
"original_sha256": "4c0137bbe554426026e1d4c8baf48e28368e2450e886c506e0055e0591cd5e5e",
|
||||
"archived_sha256": "2b6228f3c6003e896193be17a7c94970992c1fbcbaba013b1984348bf772bc1f"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-style-target-race.json",
|
||||
"source_record": "implementation-review/round-3/final-style-target-race.json",
|
||||
"original_sha256": "09b77aeb0d720de59ea5b1c31f017e8bf32e89c8dc0d4ecc3f35704f21bc0863",
|
||||
"archived_sha256": "075c8d7393164e5b43364d25c163f5da8675f5b69c67e7523a579943a710aaca"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-style-target-race.log",
|
||||
"source_record": "implementation-review/round-3/final-style-target-race.log",
|
||||
"original_sha256": "3089ad9a1c7283a23853a137311728598a7e1ae3bdbbf16e5f000e1149a7e12f",
|
||||
"archived_sha256": "a2609e9a889bcc68828e58c15967bb04820a92462c3f94de86ea584e33a6660a"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-target-race.json",
|
||||
"source_record": "implementation-review/round-3/final-target-race.json",
|
||||
"original_sha256": "68dfcad5fc63d5981065f86fb00482cc466c46f275c4c340095c26ad719f079c",
|
||||
"archived_sha256": "5fb25fa150e66478b2d170faeed3374d71325311f7e625996fe1653cfc957b66"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-target-race.log",
|
||||
"source_record": "implementation-review/round-3/final-target-race.log",
|
||||
"original_sha256": "b437b206ebd12fff304f9cdb59b39f25fd0ecf54e9f510aca98c444b0b516c89",
|
||||
"archived_sha256": "a5b6b6337ebbcf0eca02c2ca15bb1fa562d39ab6fe29f41cd304dd2979976f59"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-tree-equivalence.json",
|
||||
"source_record": "implementation-review/round-3/final-tree-equivalence.json",
|
||||
"original_sha256": "f7d1563d2f675ae282b4bec0bcedcc9809b0a0c985332d862c61ff4b8c75a59f",
|
||||
"archived_sha256": "f7d1563d2f675ae282b4bec0bcedcc9809b0a0c985332d862c61ff4b8c75a59f"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-vet.json",
|
||||
"source_record": "implementation-review/round-3/final-vet.json",
|
||||
"original_sha256": "934ae2812aef8b8635517f838a07cc81dc4780603d621770f9a150bf00d43561",
|
||||
"archived_sha256": "be1b653811da4f6503f329f7e7c8b321b3426107548f0079c8560b177bc0c37f"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/final-vet.log",
|
||||
"source_record": "implementation-review/round-3/final-vet.log",
|
||||
"original_sha256": "f8166088e218c7c8afa25988c02cee77ec58b552f531121a07959b31bd97ebbb",
|
||||
"archived_sha256": "012d45737551ed5ffdccc3b3535ce0be152a9e9f869f50779d5da04e965a9936"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/test-style.diff",
|
||||
"source_record": "implementation-review/round-3/test-style.diff",
|
||||
"original_sha256": "69a7f7efbb17d8dcdd2a0d7f91098569a48d7f1aeeecb6d2aefe04b6047a9025",
|
||||
"archived_sha256": "69a7f7efbb17d8dcdd2a0d7f91098569a48d7f1aeeecb6d2aefe04b6047a9025"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-crosscompile-after.log",
|
||||
"source_record": "implementation-review/ci-crosscompile-after.log",
|
||||
"original_sha256": "68ef16136fabb16babd8fa26d2e3fcf6ffddac30002f874f7b9dfdd61961e82f",
|
||||
"archived_sha256": "68ef16136fabb16babd8fa26d2e3fcf6ffddac30002f874f7b9dfdd61961e82f"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-gen-after.log",
|
||||
"source_record": "implementation-review/ci-gen-after.log",
|
||||
"original_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"archived_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-internal-after.log",
|
||||
"source_record": "implementation-review/ci-internal-after.log",
|
||||
"original_sha256": "775e5b2b35fda828ffd69f47902e065f58aa2e2ecd915ace9e50f4085aa2d0c4",
|
||||
"archived_sha256": "775e5b2b35fda828ffd69f47902e065f58aa2e2ecd915ace9e50f4085aa2d0c4"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-lint-after.log",
|
||||
"source_record": "implementation-review/ci-lint-after.log",
|
||||
"original_sha256": "cd29668fa61e94079dda874e10920623279083c491798fa9cbdb903ebeee4d82",
|
||||
"archived_sha256": "cd29668fa61e94079dda874e10920623279083c491798fa9cbdb903ebeee4d82"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-s3select-after.log",
|
||||
"source_record": "implementation-review/ci-s3select-after.log",
|
||||
"original_sha256": "30fc4888ba394ef0daa6c76fa497c28086b7307ec30d195fe4f2b725badc59c9",
|
||||
"archived_sha256": "30fc4888ba394ef0daa6c76fa497c28086b7307ec30d195fe4f2b725badc59c9"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/ci-verify-after.log",
|
||||
"source_record": "implementation-review/ci-verify-after.log",
|
||||
"original_sha256": "2b6860f20bdfd453bcc299f8b57a117cd698bab77d6a474913e31794c3937bd3",
|
||||
"archived_sha256": "2b6860f20bdfd453bcc299f8b57a117cd698bab77d6a474913e31794c3937bd3"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/implementation-review/round-3/binary-identity.json",
|
||||
"source_record": "implementation-review/round-3/binary-identity.json",
|
||||
"original_sha256": "ffdb768a1931388f174eaca0980cc840015a8e0995b5509ef68718d10044f5e5",
|
||||
"archived_sha256": "d4ea7abc1f419e834746820b5b2d2f1b3264e42114161e7c6803f4e952fcf17d"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/runtime/converged.json",
|
||||
"source_record": "runtime-reviewed/converged.json",
|
||||
"original_sha256": "d6d97bc01a12fd624af37432e9558356255b5cd9d4333eaa616e39998c2ce94f",
|
||||
"archived_sha256": "d6d97bc01a12fd624af37432e9558356255b5cd9d4333eaa616e39998c2ce94f"
|
||||
},
|
||||
{
|
||||
"file": "issue-77/runtime/main.go",
|
||||
"source_record": "twosite/main.go",
|
||||
"original_sha256": "ec63a5e2553907a826727af416145057215adabfb9fa2839861f0c05b7cc2a8f",
|
||||
"archived_sha256": "023459a3873753d5a373c2ba6c078d690c9a2a18c31efa98ecd5f4976ecaba4a"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
=== RUN TestQueuedMetadataUpdateAfterDelete
|
||||
=== RUN TestQueuedMetadataUpdateAfterDelete/expiry=false
|
||||
=== RUN TestQueuedMetadataUpdateAfterDelete/expiry=true
|
||||
--- PASS: TestQueuedMetadataUpdateAfterDelete (0.43s)
|
||||
--- PASS: TestQueuedMetadataUpdateAfterDelete/expiry=false (0.22s)
|
||||
--- PASS: TestQueuedMetadataUpdateAfterDelete/expiry=true (0.21s)
|
||||
=== RUN TestIssue77CurrentHealInputs
|
||||
=== RUN TestIssue77CurrentHealInputs/ErasureSD/quota-tombstone-cache
|
||||
issue77_heal_review_test.go:58: QUOTA_CACHE: disk has no quota, cache still enforces 1024
|
||||
=== RUN TestIssue77CurrentHealInputs/ErasureSD/same-payload-time-barrier
|
||||
issue77_heal_review_test.go:79: BARRIER_NOT_HEALED: same payload remains at 2026-09-12 06:59:55.50105 +0800 CST, latest is 2026-09-11 23:00:55.50105 +0000 UTC
|
||||
=== RUN TestIssue77CurrentHealInputs/ErasureSD/creation-default-selection
|
||||
issue77_heal_review_test.go:105: DEFAULT_SELECTED: creation-default erased valid policy in 5/32 heal rounds
|
||||
=== RUN TestIssue77CurrentHealInputs/ErasureSD/tag-remote-source-time
|
||||
issue77_heal_review_test.go:144: TAG_WIRE_TIME: sent 0001-01-01 00:00:00 +0000 UTC, want 2026-09-11 23:00:55.50105 +0000 UTC
|
||||
=== RUN TestIssue77CurrentHealInputs/Erasure/quota-tombstone-cache
|
||||
issue77_heal_review_test.go:58: QUOTA_CACHE: disk has no quota, cache still enforces 1024
|
||||
=== RUN TestIssue77CurrentHealInputs/Erasure/same-payload-time-barrier
|
||||
issue77_heal_review_test.go:79: BARRIER_NOT_HEALED: same payload remains at 2026-09-12 06:59:55.71847 +0800 CST, latest is 2026-09-11 23:00:55.71847 +0000 UTC
|
||||
=== RUN TestIssue77CurrentHealInputs/Erasure/creation-default-selection
|
||||
issue77_heal_review_test.go:105: DEFAULT_SELECTED: creation-default erased valid policy in 2/32 heal rounds
|
||||
=== RUN TestIssue77CurrentHealInputs/Erasure/tag-remote-source-time
|
||||
issue77_heal_review_test.go:144: TAG_WIRE_TIME: sent 0001-01-01 00:00:00 +0000 UTC, want 2026-09-11 23:00:55.71847 +0000 UTC
|
||||
--- FAIL: TestIssue77CurrentHealInputs (1.21s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/ErasureSD/quota-tombstone-cache (0.01s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/ErasureSD/same-payload-time-barrier (0.00s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/ErasureSD/creation-default-selection (0.08s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/ErasureSD/tag-remote-source-time (0.00s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/Erasure/quota-tombstone-cache (0.04s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/Erasure/same-payload-time-barrier (0.02s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/Erasure/creation-default-selection (0.83s)
|
||||
--- FAIL: TestIssue77CurrentHealInputs/Erasure/tag-remote-source-time (0.02s)
|
||||
=== RUN TestIssue77CurrentPeerCheckBeforeLock
|
||||
=== RUN TestIssue77CurrentPeerCheckBeforeLock/ErasureSD
|
||||
issue77_heal_review_test.go:209: CHECK_OUTSIDE_LOCK: queued older event replaced newer committed tags with "<Tagging><TagSet><Tag><Key>key</Key><Value>older</Value></Tag></TagSet></Tagging>"
|
||||
=== RUN TestIssue77CurrentPeerCheckBeforeLock/Erasure
|
||||
issue77_heal_review_test.go:209: CHECK_OUTSIDE_LOCK: queued older event replaced newer committed tags with "<Tagging><TagSet><Tag><Key>key</Key><Value>older</Value></Tag></TagSet></Tagging>"
|
||||
--- FAIL: TestIssue77CurrentPeerCheckBeforeLock (0.30s)
|
||||
--- FAIL: TestIssue77CurrentPeerCheckBeforeLock/ErasureSD (0.01s)
|
||||
--- FAIL: TestIssue77CurrentPeerCheckBeforeLock/Erasure (0.07s)
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/policy
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.017159 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/tags
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.029759 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.03401 +0800 CST
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/sse
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.04395 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.048028 +0800 CST
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/quota
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.057746 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.061453 +0800 CST
|
||||
issue77_review_test.go:112: older source PUT did not resurrect config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/versioning
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.069105 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:87: nil payload is correctly a no-op
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/objectlock
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.077601 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
|
||||
issue77_review_test.go:87: nil payload is correctly a no-op
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/policy
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.247526 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/tags
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.369557 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.394147 +0800 CST
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/sse
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.466111 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.489771 +0800 CST
|
||||
issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/quota
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.562629 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
|
||||
issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.590096 +0800 CST
|
||||
issue77_review_test.go:112: older source PUT did not resurrect config
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/versioning
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.640941 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:87: nil payload is correctly a no-op
|
||||
=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/objectlock
|
||||
issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.690024 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
|
||||
issue77_review_test.go:87: nil payload is correctly a no-op
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion (0.78s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/policy (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/tags (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/sse (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/quota (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/versioning (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/objectlock (0.01s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/policy (0.12s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/tags (0.10s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/sse (0.10s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/quota (0.08s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/versioning (0.05s)
|
||||
--- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/objectlock (0.05s)
|
||||
=== RUN TestIssue77CurrentBulkApplyOrdering
|
||||
=== RUN TestIssue77CurrentBulkApplyOrdering/ErasureSD
|
||||
issue77_review_test.go:143: BULK_STALE_OVERWRITE: older bulk event overwrote newer config, value="<Tagging><TagSet><Tag><Key>key</Key><Value>old</Value></Tag></TagSet></Tagging>" time=2026-09-12 08:48:57.792608 +0800 CST
|
||||
=== RUN TestIssue77CurrentBulkApplyOrdering/Erasure
|
||||
issue77_review_test.go:143: BULK_STALE_OVERWRITE: older bulk event overwrote newer config, value="<Tagging><TagSet><Tag><Key>key</Key><Value>old</Value></Tag></TagSet></Tagging>" time=2026-09-12 08:48:57.880379 +0800 CST
|
||||
--- FAIL: TestIssue77CurrentBulkApplyOrdering (0.27s)
|
||||
--- FAIL: TestIssue77CurrentBulkApplyOrdering/ErasureSD (0.01s)
|
||||
--- FAIL: TestIssue77CurrentBulkApplyOrdering/Erasure (0.05s)
|
||||
=== RUN TestPeerBucketAdoptionPreservesLockAndVersioningConfigs
|
||||
--- PASS: TestPeerBucketAdoptionPreservesLockAndVersioningConfigs (0.30s)
|
||||
=== RUN TestPeerBucketAdoptionBootstrapsMissingConfigs
|
||||
--- PASS: TestPeerBucketAdoptionBootstrapsMissingConfigs (0.24s)
|
||||
=== RUN TestPeerBucketAdoptionNormalizesVersioningWhenEnablingLock
|
||||
--- PASS: TestPeerBucketAdoptionNormalizesVersioningWhenEnablingLock (0.28s)
|
||||
=== RUN TestPeerBucketAdoptionEnablesSuspendedVersioning
|
||||
--- PASS: TestPeerBucketAdoptionEnablesSuspendedVersioning (0.27s)
|
||||
=== RUN TestPeerBucketObjectLockMetadataCurrentAndLegacyPayloads
|
||||
--- PASS: TestPeerBucketObjectLockMetadataCurrentAndLegacyPayloads (0.30s)
|
||||
=== RUN TestPeerBucketObjectLockMetadataWithoutLockEnabled
|
||||
--- PASS: TestPeerBucketObjectLockMetadataWithoutLockEnabled (0.24s)
|
||||
=== RUN TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig
|
||||
--- PASS: TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig (0.52s)
|
||||
FAIL
|
||||
FAIL github.com/minio/minio/cmd 7.167s
|
||||
FAIL
|
||||
@@ -0,0 +1,7 @@
|
||||
Testing builds for OS/Arch: linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64
|
||||
--> linux/amd64:github.com/minio/minio
|
||||
--> linux/arm64:github.com/minio/minio
|
||||
--> darwin/amd64:github.com/minio/minio
|
||||
--> darwin/arm64:github.com/minio/minio
|
||||
--> windows/amd64:github.com/minio/minio
|
||||
--> windows/arm64:github.com/minio/minio
|
||||
@@ -0,0 +1,78 @@
|
||||
ok github.com/minio/minio/internal/amztime 5.149s
|
||||
ok github.com/minio/minio/internal/arn 13.830s
|
||||
ok github.com/minio/minio/internal/auth 5.472s
|
||||
ok github.com/minio/minio/internal/bpool 13.244s
|
||||
ok github.com/minio/minio/internal/bucket/bandwidth 12.671s
|
||||
ok github.com/minio/minio/internal/bucket/cors 2.705s
|
||||
ok github.com/minio/minio/internal/bucket/encryption 12.228s
|
||||
ok github.com/minio/minio/internal/bucket/lifecycle 3.223s
|
||||
ok github.com/minio/minio/internal/bucket/object/lock 11.599s
|
||||
ok github.com/minio/minio/internal/bucket/replication 12.817s
|
||||
ok github.com/minio/minio/internal/bucket/versioning 13.157s
|
||||
ok github.com/minio/minio/internal/cachevalue 18.526s
|
||||
? github.com/minio/minio/internal/color [no test files]
|
||||
ok github.com/minio/minio/internal/config 14.365s
|
||||
? github.com/minio/minio/internal/config/api [no test files]
|
||||
? github.com/minio/minio/internal/config/batch [no test files]
|
||||
? github.com/minio/minio/internal/config/browser [no test files]
|
||||
? github.com/minio/minio/internal/config/callhome [no test files]
|
||||
ok github.com/minio/minio/internal/config/compress 15.754s
|
||||
ok github.com/minio/minio/internal/config/dns 17.351s
|
||||
? github.com/minio/minio/internal/config/drive [no test files]
|
||||
ok github.com/minio/minio/internal/config/etcd 18.460s
|
||||
? github.com/minio/minio/internal/config/heal [no test files]
|
||||
ok github.com/minio/minio/internal/config/identity/ldap 19.132s
|
||||
ok github.com/minio/minio/internal/config/identity/openid 19.829s
|
||||
? github.com/minio/minio/internal/config/identity/openid/provider [no test files]
|
||||
? github.com/minio/minio/internal/config/identity/plugin [no test files]
|
||||
? github.com/minio/minio/internal/config/identity/tls [no test files]
|
||||
ok github.com/minio/minio/internal/config/ilm 19.256s
|
||||
? github.com/minio/minio/internal/config/lambda [no test files]
|
||||
ok github.com/minio/minio/internal/config/lambda/event 18.225s
|
||||
? github.com/minio/minio/internal/config/lambda/target [no test files]
|
||||
ok github.com/minio/minio/internal/config/notify 17.465s
|
||||
? github.com/minio/minio/internal/config/policy/opa [no test files]
|
||||
? github.com/minio/minio/internal/config/policy/plugin [no test files]
|
||||
? github.com/minio/minio/internal/config/scanner [no test files]
|
||||
ok github.com/minio/minio/internal/config/storageclass 17.289s
|
||||
ok github.com/minio/minio/internal/config/subnet 17.168s
|
||||
ok github.com/minio/minio/internal/crypto 18.081s
|
||||
ok github.com/minio/minio/internal/deadlineconn 21.703s
|
||||
ok github.com/minio/minio/internal/disk 17.514s
|
||||
ok github.com/minio/minio/internal/dsync 154.956s
|
||||
ok github.com/minio/minio/internal/etag 17.755s
|
||||
ok github.com/minio/minio/internal/event 17.598s
|
||||
ok github.com/minio/minio/internal/event/target 16.108s
|
||||
ok github.com/minio/minio/internal/grid 22.027s
|
||||
ok github.com/minio/minio/internal/handlers 14.030s
|
||||
ok github.com/minio/minio/internal/hash 14.016s
|
||||
? github.com/minio/minio/internal/hash/sha256 [no test files]
|
||||
ok github.com/minio/minio/internal/http 13.661s
|
||||
? github.com/minio/minio/internal/init [no test files]
|
||||
ok github.com/minio/minio/internal/ioutil 14.637s
|
||||
ok github.com/minio/minio/internal/jwt 13.120s
|
||||
ok github.com/minio/minio/internal/kms 12.536s
|
||||
ok github.com/minio/minio/internal/lock 13.029s
|
||||
ok github.com/minio/minio/internal/logger 12.231s
|
||||
? github.com/minio/minio/internal/logger/message/audit [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/console [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/http [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/kafka [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/loggertypes [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/testlogger [no test files]
|
||||
ok github.com/minio/minio/internal/lsync 22.092s
|
||||
? github.com/minio/minio/internal/mcontext [no test files]
|
||||
? github.com/minio/minio/internal/mountinfo [no test files]
|
||||
? github.com/minio/minio/internal/net [no test files]
|
||||
? github.com/minio/minio/internal/once [no test files]
|
||||
ok github.com/minio/minio/internal/pubsub 11.507s
|
||||
ok github.com/minio/minio/internal/rest 10.318s
|
||||
ok github.com/minio/minio/internal/ringbuffer 10.927s
|
||||
ok github.com/minio/minio/internal/s3select 8.665s
|
||||
ok github.com/minio/minio/internal/s3select/csv 8.862s
|
||||
ok github.com/minio/minio/internal/s3select/json 8.547s
|
||||
ok github.com/minio/minio/internal/s3select/jstream 8.936s
|
||||
? github.com/minio/minio/internal/s3select/parquet [no test files]
|
||||
? github.com/minio/minio/internal/s3select/simdj [no test files]
|
||||
ok github.com/minio/minio/internal/s3select/sql 8.637s
|
||||
ok github.com/minio/minio/internal/store 9.594s
|
||||
@@ -0,0 +1,13 @@
|
||||
Installing golangci-lint v2.13.1
|
||||
golangci/golangci-lint info checking GitHub for tag 'v2.13.1'
|
||||
golangci/golangci-lint info found version: 2.13.1 for v2.13.1/darwin/arm64
|
||||
golangci/golangci-lint info installed .bin/golangci/v2.13.1/golangci-lint
|
||||
Running lint check
|
||||
0 issues.
|
||||
typos binary is not found.. skipping..
|
||||
LINT_EXIT=0
|
||||
compatibility manifest: imports=119 env=438 metrics=19 headers=87 routes=223 roots=1 grid=3 storage=15 policy=58 brand=181 sha256=db0d00c26360412a5388be1eb87c2dd763633ae243c558015f7b3ae86c80baaf
|
||||
Silo rebrand compatibility baseline is unchanged
|
||||
Silo delivery and runtime rebrand checks passed
|
||||
docker entrypoint argv compatibility tests passed
|
||||
REBRAND_EXIT=0
|
||||
@@ -0,0 +1,7 @@
|
||||
ok github.com/minio/minio/internal/s3select 2.150s
|
||||
ok github.com/minio/minio/internal/s3select/csv 3.612s
|
||||
ok github.com/minio/minio/internal/s3select/json 2.105s
|
||||
ok github.com/minio/minio/internal/s3select/jstream 2.937s
|
||||
? github.com/minio/minio/internal/s3select/parquet [no test files]
|
||||
? github.com/minio/minio/internal/s3select/simdj [no test files]
|
||||
ok github.com/minio/minio/internal/s3select/sql 2.620s
|
||||
@@ -0,0 +1,2 @@
|
||||
Silo delivery and runtime rebrand checks passed
|
||||
docker entrypoint argv compatibility tests passed
|
||||
@@ -0,0 +1,292 @@
|
||||
# SILO #77 实现深度对抗性代码审查报告
|
||||
|
||||
审查对象 `4089113e3`(基线 `5c5765816`)。只读 worktree,全部结论来自直接阅读 `cmd/` 生产代码、`silo-pkg`/`madmin-go` 依赖源码与调用链,并与证据目录交叉核对。测试 PASS 仅当作"已覆盖的观察",不作正确性证明。
|
||||
|
||||
> 说明:本会话的 Write/ExitPlanMode 工具不可用,报告直接输出于此,未落盘。
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict
|
||||
|
||||
**GO_WITH_NONBLOCKING_NOTES**
|
||||
|
||||
- **无条件阻断项:0**
|
||||
- **条件性阻断项:1**(F1)——条件:支持矩阵中存在 `.metadata.bin` 缺失或 `Created == 0` 的桶
|
||||
- 其余:F2 (S2)、F3 (S3)、F4 (S3)、F5(最小性)、F6(nit)、F7(范围说明)、F8(证据不足)、F9/F10(残留边界与覆盖缺口)
|
||||
|
||||
我逐条读了六类配置在全部入口的生产路径,**没有**找到在"同一桶世代、Created 已知、合法带源时间事件"范围内会发生回退、删除复活、锁外判旧或重复广播的反例。核心机制是正确的。缺陷集中在两处边缘:零 Created 历史桶被新代码判为不可写(F1,且其自带测试用 stub 掩盖了生产行为),以及 Policy 规范编码排序与既有公开 mismatch 统计口径冲突(F2)。
|
||||
|
||||
---
|
||||
|
||||
## 1. Findings
|
||||
|
||||
### F1 — 零 Created / 无 metadata.bin 的桶:六类配置写入全部硬失败;设计中的"物理桶 Created 补齐"在生产中不可达;配套测试用 stub 掩盖
|
||||
|
||||
**严重度 S1(条件性阻断)/ 否则 S2 · 已证实(源码级链路完整),需运行定量确认**
|
||||
|
||||
位置:
|
||||
- `cmd/bucket-metadata-replication.go:265-278` `ensureBucketMetadataCreated`
|
||||
- `cmd/bucket-metadata-sys.go:181-185`(六类分支入口,失败即 `return err`)
|
||||
- `cmd/erasure-server-pool.go:2280-2285` —— **`GetBucketInfo` 无条件用缓存 `meta.Created` 覆盖物理卷 Created**
|
||||
- `cmd/admin-bucket-handlers.go:1092`(import)、`cmd/site-replication.go:1671`(bulk peer apply)
|
||||
- `cmd/site-replication.go:2156-2166` + `cmd/site-replication-metadata.go:45-55`(初次同步静默跳过)
|
||||
- `cmd/site-replication-metadata-gate_test.go:145-159`(stub)
|
||||
|
||||
最短触发链:
|
||||
1. 桶存在,但 `.minio.sys/buckets/<b>/.metadata.bin` 不存在且无 legacy 配置文件 → `loadBucketMetadataParse` 走 `errConfigNotFound` 分支并**返回 nil error 且 `Created == 0`**(`cmd/bucket-metadata.go:230-297`;注意 `defaultTimestamps()` 只在 `err == nil` 时调用)。
|
||||
2. 启动时 `concurrentLoad`(`cmd/bucket-metadata-sys.go:737-763`)把这个 `Created == 0` 的 meta 写进 `metadataMap`。
|
||||
3. `PUT ?tagging`(或 policy/sse/quota/versioning/object-lock)→ `updateAndParseMetadata` → `ensureBucketMetadataCreated` → `objAPI.GetBucketInfo(..., NoMetadata:true)` → `erasureServerPools.GetBucketInfo` 先取到物理卷时间,**随后被缓存里的 0 覆盖** → `info.Created.IsZero()` → `errors.New("bucket metadata creation time is unknown")`。
|
||||
4. 客户端收到错误。基线版本此处成功(直接赋值 + `saveMetadata`)。
|
||||
|
||||
注:`getAllLegacyConfigs` 在 `cmd/bucket-metadata.go:468` 会用 `info.ModTime` 填 `Created`,所以**有** legacy 配置文件的桶反而没事;**从未设置过任何桶配置**的老桶才是高发场景。
|
||||
|
||||
用户影响:
|
||||
- 六类配置 PUT/DELETE 全部返回错误,**无运维恢复路径**;`mc admin bucket import` 对该桶整体失败(`rpt.SetStatus(bucket, "", err)`);peer bulk apply 返回错误导致该桶复制持续报错。
|
||||
- `syncToAllPeers` 对这类桶**静默跳过全部五类配置**(`state.candidate()` 要求 `valid`,而 `valid = !created.IsZero() && ...`),且**无任何诊断日志** —— 基线版本会发送。
|
||||
|
||||
最小修复(推荐 a):
|
||||
- **(a)** `cmd/erasure-server-pool.go:2282` 改为 `if !meta.Created.IsZero() { bucketInfo.Created = meta.Created }`。一行;恢复计划中"物理桶 Created 补齐路径"的本意;顺带修掉"无 metadata.bin 的桶在 `mc ls` 里创建时间为零"这一既有瑕疵。需复核 `bucketExists`、`hasBucket = !bi.CreatedAt.IsZero()` 等消费点 —— Created 由零变非零对它们都是变好。
|
||||
- **(b)** 另外在 `syncToAllPeers`(`cmd/site-replication.go:2138-2156`)把已在手的 `bucketInfo.Created` 兜底进 `meta.Created`,或在"有内容但 send==false"时打一条 indeterminate,避免静默漏发。
|
||||
|
||||
应补验证:
|
||||
- 把 `TestPeerBucketMetadataUnknownCreated` 的 `"physical-created"` 子用例**去掉 stub**:用真实 ObjectLayer,把 `newBucketMetadata(bucket)`(Created 为零)存盘并让缓存持有它,断言 `globalBucketMetadataSys.Update(ctx, bucket, bucketTaggingConfig, tagXML)` 成功且 `Created` 被补齐为物理时间。**当前实现会失败。**
|
||||
- 一个 `syncToAllPeers` 用例:`Created == 0` 且 Tags 非空的桶,断言初次同步仍发 Tags 事件(或至少有诊断)。
|
||||
|
||||
---
|
||||
|
||||
### F2 — Policy 规范编码对 `Statement` 数组排序,与既有公开统计的顺序敏感 `Equals` 冲突:永久假 mismatch,heal 永不修复
|
||||
|
||||
**严重度 S2(公开状态/可观测性,非数据损失)· 已证实(源码级),需运行确认**
|
||||
|
||||
位置:
|
||||
- `cmd/bucket-metadata-replication.go:110` —— `sort.Slice` 作用于**每个**数组,含顶层 `Statement`
|
||||
- `cmd/bucket-metadata-replication.go:124-161` `canonicalBucketPolicy`
|
||||
- `cmd/site-replication.go:3796` `isBktPolicyReplicated` → `prev.Equals(*p)`(`silo-pkg .../policy/bucket-policy.go:190-194` 按 `Statements[i]` **下标**逐一比较,顺序敏感)
|
||||
- `cmd/site-replication-metadata.go:174` vs `:179` —— 本地 heal 写**源站原始字节**,远端 heal 经 `PeerBucketPolicyHandler` → `canonicalBucketPolicy` 写**规范字节**
|
||||
|
||||
最短反例:
|
||||
1. 站点 A 存在升级前写入的桶策略,statement 顺序不是规范字节序(极常见,例如 Deny 在 Allow 之前)。
|
||||
2. 加入新站点 B:`syncToAllPeers` → `initialBucketConfigReplicationEvent` 发送 **A 的原始字节**,`UpdatedAt = A.PolicyConfigUpdatedAt`。
|
||||
3. B 的 admin 入口解析后调 `PeerBucketPolicyHandler` → `canonicalBucketPolicy` → 存**排序后**字节。
|
||||
4. 此后 A/B 的比较键(canonical)完全相同、时间相同 → `compareBucketConfigStates == 0` → heal 永不写、永不发 RPC;而 `Equals` 因 statement 顺序不同返回 false → `mc admin replicate status` **永久**报 bucket policy mismatch,`ReplicatedBucketPolicies` 少计。
|
||||
|
||||
同机制第二条路径:同一轮 heal 中本地目标拿原始字节、远端目标拿规范字节,三站点集群会出现持久分叉。
|
||||
|
||||
为何是新问题:基线 `PeerBucketPolicyHandler` 用 `json.Marshal(policy)`,集合(Action/Resource/Principal)顺序随机但 **statement 切片顺序被 marshal/unmarshal 保持**,`Equals` 一直成立。
|
||||
|
||||
最小修复(三选一,推荐 a 或 b):
|
||||
- (a) `canonicalBucketPolicyJSON` 不对顶层 `Statement` 数组排序(只排集合数组)——顺序在全链路被保留,两站点独立解析同一文档仍得相同键。
|
||||
- (b) `isBktPolicyReplicated` 改用 `canonicalBucketPolicy` 的键比较,而不是 `Equals`。
|
||||
- (c) `healBucketConfig` 本地分支改写 `incoming.data`(与远端同一编码)——只修本地/远端分叉,**不**修 A 与新站点的分叉。
|
||||
|
||||
应补验证:双站点用例——A 侧直接把 legacy 顺序的策略字节写盘(绕过 canonical 编码器),join B,跑两轮 heal,断言 `SiteReplicationStatus` 不报 policy mismatch。
|
||||
|
||||
---
|
||||
|
||||
### F3 — heal 诊断以 ERROR 级、按"桶 × 字段"输出;正常瞬态也报警,且单一 key 会吞掉真实 RPC 失败
|
||||
|
||||
**严重度 S3 · 已证实**
|
||||
|
||||
位置:`cmd/site-replication-metadata.go:134-145`(两个诊断循环)、`:182-186`(peer 错误)、`cmd/logging.go:19-21`(`replLogOnceIf` 无 errKind ⇒ **ErrorKind**)
|
||||
|
||||
1. 第二个循环对 `!state.valid` 的目标打 `indeterminate / unusable peer`。`valid` 要求对端 `CreatedAt != 0` —— 而**对端还没有这个桶**时 `CreatedAt` 就是 0(`cmd/site-replication.go:3096-3103` 给缺桶站点填零值 `SRBucketInfo`)。于是"桶刚建、尚未传播"这种完全正常的瞬态,对每个桶产生 6 条 **ERROR**。
|
||||
2. 第一个循环在对端 `RemoteTargetConnectionErr`(`cmd/site-replication.go:3020-3026` 填空 ID)时,对**每个本地桶 × 6 字段**打 `missing peer`。一个站点掉线 ≈ 每小时 6×桶数 条 ERROR。
|
||||
3. 四种完全不同的情况(缺 peer / peer 不可用 / peer RPC 失败 / Created 未知)共用同一个 key `"bucket-metadata/<bucket>/<file>/indeterminate"` **且错误正文相同**,而 `logOnceIf` 只按 key+正文去重(`internal/logger/logonce.go:100-119`)。结果:**真正的 heal RPC 失败可能被同桶同字段的"缺 peer"消息顶掉而完全不打印**;基线版本这些失败走 `replLogIf`,必然打印。
|
||||
|
||||
最小修复:`target.CreatedAt.IsZero()`(桶不在对端)与 `found == false` 时不打诊断;把 4 种情况拆成不同 reason(`unreachable`/`peer-error`/`unknown-created`)避免 key 撞车;给 `logBucketConfigReplication` 传 `logger.WarningKind`。
|
||||
|
||||
---
|
||||
|
||||
### F4 — 三处用户可见语义变更未写入文档
|
||||
|
||||
**严重度 S3(文档/兼容)· 已证实(逐条比对 `docs/site-replication/README.md:65-118`)**
|
||||
|
||||
1. **空 Bucket Policy PUT 现在立即按删除处理**:`cmd/bucket-policy-handlers.go:102` + `cmd/bucket-metadata-replication.go:176-180` → `canonicalBucketPolicy` 对 `IsEmpty()` 返回 nil → 落盘 nil → `GetBucketPolicy` 从"200 + 空策略文档"变为 **404 NotFound**。计划 §1 明确写了"需要写入兼容说明",文档里没有。
|
||||
2. **零 Quota (`{}`) 在对端从"被删除"变为"保留 live 文档"**:`cmd/admin-bucket-handlers.go:79-96` 删除了出站 `bucketMeta.Quota = nil` 改写。这是正确的对齐(本地原本就保留 `{}`),但对端 `GetBucketQuotaConfig` 的结果会变。
|
||||
3. **`GET ?policy` 与 `mc admin bucket export` 的 JSON 形态改变**:`cmd/bucket-policy-handlers.go:202`、`cmd/admin-bucket-handlers.go:439` 改用 `canonicalBucketPolicy` → 对象键按字母序、集合数组与 **Statement 数组被排序**。语义等价(S3 策略求值与 statement 顺序无关),但字节级比对输出的工具会看到变化。
|
||||
|
||||
---
|
||||
|
||||
### F5 — 最小性:Policy 规范编码器接入 PUT / GET / export / peer 落盘,并非 #77 不变量所必需
|
||||
|
||||
**非缺陷,最小性判断 · 已证实**
|
||||
|
||||
比较键在 `bucketConfigPayload`(`cmd/bucket-metadata-replication.go:172-180`)里**从已解析策略现算**,与落盘字节无关。即使 PUT/peer 仍用 `json.Marshal`(字节不确定),两站点的键依旧相同,收敛性完全不受影响。
|
||||
|
||||
因此这部分改动带来的是两件**额外**的事:(a) 让原本被 `ActionSet.MarshalJSON`(`silo-pkg .../policy/actionset.go:144-148`,空集合报错)挡掉的 `NotAction`/`NotResource` 策略首次可以写入 —— 这是**功能新增**;(b) 直接导致 F2。
|
||||
|
||||
判断:如果作者**有意**支持负集合策略,应作为独立特性声明并单独记录/测试(目前只有 `site-replication-metadata_test.go:530-541` 一个用例);如果只为 #77,最小做法是 `canonicalBucketPolicy` 只用于比较键。我**不**主张必须删除——它确实修掉了"能存不能读"的潜在坑——但必须承认这是范围外的行为扩张,且未在文档中声明。
|
||||
|
||||
其它可删复杂度(都很小,不影响不变量):
|
||||
- `cmd/bucket-metadata-sys.go:151` 在零值结构体上用 `replicatedBucketConfig(&result.meta, configFile)` 做"是不是这六类"的判断,语义晦涩;一个 `isReplicatedBucketConfig(file) bool` 更清楚。
|
||||
- 修掉 F3 后,`healBucketConfig` 的第二个诊断循环(`:139-145`)可并入主循环。
|
||||
|
||||
---
|
||||
|
||||
### F6 — Nit
|
||||
|
||||
- `isBucketMetadataEqual`(`cmd/site-replication.go:5164`)现在只被测试引用,生产死代码。
|
||||
- `cmd/bucket-metadata-replication.go:36-38` 注释"Object Lock is applied before Versioning"对 bulk apply 循环成立,但 `healBuckets`(`cmd/site-replication.go:4745-4746`)先 heal Versioning 再 heal Object Lock。我推演过仍收敛(最多 2 个周期、无写入环),但注释与 heal 顺序不一致,建议补一句。
|
||||
|
||||
---
|
||||
|
||||
### F7 — 范围说明(非缺陷,但必须进入决策)
|
||||
|
||||
**默认 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off` 时,Tags / SSE / Quota 的"漏发删除"不会通过 heal 收敛。** 只有 Policy 墓碑默认导出(`cmd/site-replication.go:3953-3954` 无条件导出;另外三类在 `:3960 / :3980 / :3988` 被 gate 挡住)。
|
||||
|
||||
我完整推演了 off 模式,结论与文档一致:本地真实墓碑在锁内比较时**不会**被旧 PUT 复活(`applyBucketConfig` 用真实落盘状态比较);但持有旧数据的对端会每 30 秒发一次过期 RPC 被拒绝,**状态永不收敛**,直到运维在权威站点重新提交删除,或全站点升级后统一开启开关。
|
||||
|
||||
也就是说:**默认配置下交付的是"顺序正确 + 不复活 + Policy 删除可 heal",不是"四类删除都能自愈"。** 这是 v4 计划的既定取舍(计划 §提交 3 表格),实现与文档都如实写了;我在此只是确保批准时看到这一点。
|
||||
|
||||
---
|
||||
|
||||
### F8 — 证据不足项
|
||||
|
||||
1. **F2 对双站点实验不可见**:`twosite/main.go:141-144` 的所有策略都经 `SRPeerReplicateBucketMeta` → `canonicalBucketPolicy` 写入,两侧都是规范字节,`states()`(`:160-164`)的字节比较自然通过。**从未构造过"升级前旧编码字节"的策略。**
|
||||
2. **F1 被 stub 掩盖**:`cmd/site-replication-metadata-gate_test.go:151-159` 自定义 `GetBucketInfo`,在 `opts.NoMetadata` 时直接返回物理 Created,绕过了 `erasureServerPools.GetBucketInfo` 的缓存覆盖。`"physical-created"` 子用例证明的是一个**生产中不会发生**的行为。
|
||||
3. **"稳态 0 metadata RPC"的边界**:`twosite/main.go:182-190` 只统计 `event.Bucket == bucket` 的单桶、双站点、**gate=on**。结论有效但窄;gate=off 的稳态不为零(文档已声明),多桶/多站点未观测。
|
||||
4. **二进制身份**:`manifest.json` 给了 `silo-final` 的 sha256,但目录里没有 `--version`/build-info 输出(驱动用 `--quiet --json` 启动),`runtime-final.log` 也没有版本行。验收自述"编译信息为 `c8f264f79 + dirty`"是**诚实的**,且日志里的 gate 行为(`gate=on/off`、`off exporter exposed new tombstone`)只能来自第三个提交的生产代码,所以**没有夸大**;但也**不能从证据目录内独立复核**。补一份 `silo-final --version` 或 `go build` 复现即可闭环。
|
||||
5. **baseline.log 行号与最终测试文件不一致**(日志 93/105/117 vs 现文件 96/97/109/121):说明"修复前复现"跑的是测试文件的早期版本。可接受,但严格讲不是同一份用例。
|
||||
|
||||
---
|
||||
|
||||
### F9 — 残留边界:桶世代冲突(计划内已声明,非本轮回归)
|
||||
|
||||
各站点 `Created` 不同时,A 的 baseline-live(`at == A.Created`)在 B 上会被重算为 `real`(`newBucketConfigState` 用**目标**的 `created` 判定,`cmd/bucket-metadata-replication.go:215-219`),可能压过 B 的真实墓碑。计划明确排除在收敛承诺外,且 `AddPeerClusters`(`cmd/site-replication.go:458-466`,"only one cluster may have data")封死了最常见入口。剩余入口:分区期间两站点各自建同名桶,或 #78 接管。基线版本在同场景下是**不确定**的(seed 首个 map 项),所以不算回归。
|
||||
|
||||
---
|
||||
|
||||
### F10 — 覆盖缺口:接管时 `Created` 前移越过真实修改时间
|
||||
|
||||
`rebaseBucketConfigDefaults`(`cmd/bucket-metadata-replication.go:306-318`)只调整"零/等于旧 Created"的默认时间。若 `opts.CreatedAt` 晚于某个**真实**字段时间,该字段变成 `at < Created` ⇒ `valid == false`:作为 heal 源 `candidate()` 为 false;作为 heal 目标 `incoming.valid > current.valid` ⇒ **必被覆盖**,本地真实配置被丢弃。
|
||||
|
||||
在"远端世代胜出"的语义下可以论证这是对的,但 `TestPeerBucketAdoptionRebasesOnlyDefaults`(`cmd/site-replication-metadata_test.go:394-425`)用的 shift 是 ±1h 而真实时间在 `created+2h`,**恰好没有覆盖这一情形**。建议加一个 `shift = +3h` 的子用例,把期望行为固定下来。
|
||||
|
||||
---
|
||||
|
||||
## 2. 六类配置 × 各入口 覆盖判断
|
||||
|
||||
| 配置 | 本地写 | typed peer | bulk | import | initial sync | heal | 接管 | 结论 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Policy | ✅ `bucket-policy-handlers.go:108/152`,hook 用 `result.meta`+`result.updatedAt` | ✅ `site-replication.go:1716-1730` | ✅ `len(item.Policy)!=0` 判"已提供" | ✅ 共同 `commitAt` + 空策略另发专用 nil 事件(`admin-bucket-handlers.go:1150-1155`) | ✅ gate 控墓碑 | ✅ | ✅ rebase | 通过(F2/F4 为附带) |
|
||||
| Tags | ✅ `bucket-handlers.go:1940/2021` | ✅ `:1733-1747` | ✅ `*string != nil` | ✅ | ✅ gate | ✅ 补齐 `UpdatedAt`(旧版 heal 缺此字段) | ✅ | 通过 |
|
||||
| SSE | ✅ `bucket-encryption-handlers.go:106/199` | ✅ `:1787-1801` | ✅ | ✅ | ✅ gate | ✅ | ✅ | 通过 |
|
||||
| Quota | ✅ `admin-bucket-handlers.go:85-99`(删掉零值改写) | ✅ `:2032-2047` | ✅ `len(item.Quota)!=0` | ✅ | ✅ gate | ✅ 含缓存清除(`parse=false` 修复) | ✅ | 通过 |
|
||||
| Versioning | ✅ `bucket-versioning-handler.go:100-116`,广播用归一后的 `result.meta` | ✅ 空=no-op | ✅ 空=no-op | ✅ 归一后落盘并广播 | ⛔ 不发(由 MakeBucketHook bootstrap + heal 对齐,与基线一致) | ✅ 按**目标** Lock 状态归一后比较 | ✅ `enablePeerBucketVersioning` 用 `localBucketConfigUpdatedAt` | 通过 |
|
||||
| Object Lock | ✅ `bucket-handlers.go:1841-1852` | ✅ 空=no-op,保留 `item.Tags` legacy 回退 | ✅ 空=no-op | ✅ | ✅ | ✅ | ✅ | 通过 |
|
||||
|
||||
逐条核对结论:
|
||||
|
||||
- **来源时间不会变成本地 now**:六个 typed handler 全部删除了锁外 `GetXConfig()` 判旧,统一走 `updateAndParseMetadata(..., &updatedAt)`;非零 `sourceTime` 直接 `sourceTime.UTC()`(`bucket-metadata-sys.go:191-193`)。只有 `sourceTime == nil`(本地写)或为零(legacy 兼容)才分配本地单调时间。✔
|
||||
- **本地写严格单调**:`localBucketConfigUpdatedAt`(`:255-263`)保证 `> Created` 且 `> 当前字段时间`,涵盖"已有未来时间"。✔
|
||||
- **重复/乱序不保存不广播**:`applyBucketConfig` 在 `compare <= 0` 返回 `changed=false`,据此跳过 `saveMetadata` 与 `LoadBucketMetadata`;bulk 用 `changed` 汇总后一次保存。✔
|
||||
我特别核对了所有使用 `result.meta`/`result.updatedAt` 的本地 handler:在这些路径上 `changed` 恒为 true(`localBucketConfigUpdatedAt` 使 `incoming.at` 严格更大,且 `incoming.candidate()` 恒真),所以**不会**出现"零时间 + 空载荷"被当成删除广播出去。这是一个隐式依赖,建议加一行注释或断言固定住。
|
||||
- **整桶 `.metadata.bin` 读-比较-写全在既有分布式锁内**:`updateAndParseMetadata`、`PeerBucketMetadataUpdateHandler`、import 最终提交、`PeerBucketMakeWithVersioningHandler`、CORS 路径,我逐个确认锁的获取在读之前、释放在 `saveMetadata` 之后、fan-out 在释放之后。✔
|
||||
- **无关字段 / CORS / lifecycle 不被覆盖**:所有写路径都是锁内**重新加载**后只改目标字段;import 用 `applyImportedBucketMetadata` 只按 `fields` 拷贝并 `bytes.Clone`。✔
|
||||
- **缓存快照正确**:`saveMetadata` 改收 `*BucketMetadata`,`meta.Save` 内部 `parseAllConfigs` 回写归一化结果,`sys.Set(name, *meta)` 发布的是提交后快照;调用方拿到的 `result.meta` 是同一份值拷贝,不会原地修改已发布引用。✔
|
||||
- **`parse` 被强制为 false 对缓存无害**:`Save()` 先 `parseAllConfigs`,发布到 `metadataMap` 的解析字段是新的;而"删除后 quota 解析残留"正是靠 `parse=false` 的新加载对象修掉的(`parseAllConfigs` 对空 `QuotaConfigJSON` **不会**把 `quotaConfig` 置 nil,`bucket-metadata.go:400-405`)。✔
|
||||
- **锁/返回值/错误处理**:`unlock()` + `locked=false` 模式保留;`updateAndParse`、`Update`、`Delete` 对外签名不变。✔
|
||||
|
||||
---
|
||||
|
||||
## 3. 六字段 state 比较:传递性、交换序无关、幂等收敛
|
||||
|
||||
`compareBucketConfigStates`(`bucket-metadata-replication.go:227-253`)实际是按 `(valid, real, real?at:—, real?isTombstone:—, key)` 的字典序全序。
|
||||
|
||||
- **传递 / 反对称**:是。`real == false` 时不比较 `at`(只比 key),但空 baseline 的 key 为 nil,`bytes.Compare` 使其恒最小,效果等同"空 baseline 永不获胜"。✔
|
||||
- **交换顺序无关**:`latestBucketConfig` 取全序最大值,与 map 遍历顺序无关;`applyBucketConfig` 同样只依赖全序。`TestLatestBucketConfigCandidates` 跑了全部 6 种排列。✔
|
||||
- **幂等收敛**:写入后目标状态等于源状态 ⇒ 下一轮 `compare == 0` ⇒ 不写不发。我另外手工推演了 Object Lock/Versioning 的"Enabled vs Suspended 同时间"场景,归一后双侧都不再写,稳定无振荡。✔
|
||||
- **baseline Created 与真修改的区分**:`real = valid && at.After(created)`,零字段时间先回填为 `created`(与 `defaultTimestamps()` 一致)。✔
|
||||
- **同时间删除胜出**:`:244-250`,仅在 `a.real` 分支内生效,空 baseline 不会借此删配置。✔
|
||||
- **等时不同载荷的稳定键**:
|
||||
- **Policy**:递归排序的规范 JSON + `json.RawMessage` 保留整数精度(测试 `:308-311` 验证 `9007199254740993` 不失真)。我对照了 `silo-pkg .../policy/bucket-policy-statement.go:27-36`,`BPStatement` 的 8 个字段(SID/Effect/Principal/Actions/NotActions/Resources/NotResources/Conditions)**全部覆盖,无字段静默丢失**;`ParseBucketPolicyConfig` 先做 `Validate` 保证 `Principal.MarshalJSON` 不会失败。✔ 但顶层 Statement 排序引出 F2。
|
||||
- **Quota**:`json.Marshal(parseBucketQuota(...))`。零 quota 仍是 live 文档(`candidate()` 靠 `real`,不靠 `len(data)`)。`{}` 与对端重编码字节不同但键相同,不产生 heal 环;`isBktQuotaCfgReplicated` 按解析值比较,不会误报。✔
|
||||
- **Tags / SSE / Object Lock**:有效文档字节,保留大小写与实际内容。✔
|
||||
- **Versioning**:先按**该站点**的 Object Lock 归一(`effectiveBucketVersioning`),键即归一后的文档,避免"保存阶段隐式改写"造成的空转。✔
|
||||
- **空策略**:`bucketConfigPayload` 对 `cfg.IsEmpty()` 返回 `(nil, nil, nil)`,与 peer 侧既有"空策略=删除"解释统一。✔(副作用见 F4-1)
|
||||
- **Object Lock 强制 Versioning 后的持久化 vs 广播**:本地只广播 Object Lock 事件,派生的 Versioning 由对端在 `Save → parseAllConfigs` 自行推导,**内容一致、时间各自保留**;heal 用归一后的有效文档比较,最多一次写入即收敛。与计划一致。✔
|
||||
|
||||
---
|
||||
|
||||
## 4. Created / 时间的异常输入
|
||||
|
||||
| 输入 | 行为 | 判断 |
|
||||
|---|---|---|
|
||||
| Created 为零 / metadata.bin 缺失 | 六类写入报错;初次同步静默跳过 | **F1,回归** |
|
||||
| 字段时间为零 | 回填为 Created ⇒ baseline | ✔ 与 `defaultTimestamps()` 一致 |
|
||||
| 事件时间 == Created | 可更新仍为 baseline 的字段,时间保存为 Created;同时间 nil/空只算空 baseline,不删除 | ✔ 有用例 |
|
||||
| 事件时间 < Created | `before-created`,跳过 + 单条日志;heal 对该目标 `continue` | ✔ |
|
||||
| 未来时间 | 接受为真实状态;后续本地写用 `max(now, at+1ns)` 压过 | ✔ 有用例 |
|
||||
| 各站点 Created 不同 | 见 F9(计划外,已声明) | 残留 |
|
||||
| 空 deployment ID | `latestBucketConfig` / `healBucketConfig` 均 `id == "" \|\| !known` 跳过 | ✔ 有用例 |
|
||||
| peer 报错 / 缺元数据 | `candidate()` 为 false ⇒ 不参与选源;作为目标 `CreatedAt.IsZero()` ⇒ `continue`。**不会被当成删除** | ✔ |
|
||||
| 单个 peer 不可达 | 记录后继续其它目标(`site-replication-metadata.go:182-186`),不因 map 顺序放弃健康站点 | ✔ 有用例(`"broken"`) |
|
||||
|
||||
**"已知历史不可恢复"与"新实现回归"的区分**:
|
||||
- 历史不可恢复(可接受):旧版到达时间污染、legacy-zero 事件产生的新本地时间、桶世代分歧。
|
||||
- 新实现回归(应修):F1 的零 Created 硬失败与初次同步静默漏发;F2 的永久假 mismatch;F3 的 ERROR 噪声与日志互相顶替。
|
||||
|
||||
---
|
||||
|
||||
## 5. 兼容路径与开关
|
||||
|
||||
- **typed 零时间兼容**:`sourceTime != nil && IsZero()` ⇒ 分配本地时间 + 一条 `legacy-zero`(`bucket-metadata-sys.go:186-190`)。不受 gate 影响,不在源时间排序保证内。✔
|
||||
- **bulk 零时间拒绝**:`PeerBucketMetadataUpdateHandler:1610-1612` ⇒ `errInvalidArgument`,与现状一致;import hook 恒带非零 `commitAt`。✔
|
||||
- **省略 vs 显式 null / 空串**:`madmin.SRBucketMeta` 全字段 `omitempty`(`madmin-go v3.0.110 cluster-commands.go:501-538`),因此
|
||||
- `json.RawMessage`(Policy/Quota):省略 ⇒ nil;显式 `null` ⇒ `[]byte("null")`,走 `len(...)!=0` 判为"已提供",再交既有解析器(Policy `null` ⇒ 语义空 ⇒ 删除;Quota `null` ⇒ 零值文档 ⇒ live)。✔ 与计划 §1 完全一致。
|
||||
- `*string`:省略/`null` ⇒ nil ⇒ 不动;空串 ⇒ 已提供且内容为空 ⇒ 四类删除、两类 no-op。✔
|
||||
- 这些由 `TestPeerBucketMetadataWireAtomicity` 通过**真实 admin 路由 + 真实 JSON 编解码**覆盖(`applySRBucketMetaViaAdmin` 走 `registerAdminRouter`),不是结构体直传。这是这批测试里质量最高的一块。
|
||||
- **direct hook 与 heal 的删除传播**:普通 DELETE 事件在 gate off 下照常复制;heal 依赖导出可见性 ⇒ 见 F7。
|
||||
- **初次同步**:保留原五类范围(不含 Versioning),gate 控制是否发送真实墓碑。✔
|
||||
- **gate 覆盖点是否遗漏**:我检查了所有导出/初次发送点 —— `SiteReplicationMetaInfo` 的 Tags/Quota/SSE 三处(`:3960/:3980/:3988`)+ `initialBucketConfigReplicationEvent:51`。Policy 墓碑**故意**不受 gate 控制(原本已导出);Versioning/Object Lock 无墓碑概念。**未发现遗漏的开关覆盖点。** ✔
|
||||
- **滚动升级/降级**:off 时新增墓碑信息不导出,旧节点不会收到它无法正确处理的 Quota heal 墓碑(文档点名了"旧版会留下已解析 quota 残留"这一实证依据)。✔ 但 off **不等于**与旧版同构:修复端的**接收**行为已经变了(锁内排序、空 baseline 不删除、重复不写),这对旧版发来的事件是更安全的方向,混版冒烟也验证了普通 PUT/DELETE 互通。降级路径文档要求先关开关再滚降;唯一可补的一句是"墓碑时间字段本来就在 schema 内,旧版只是不导出,降级不会产生解析错误"。
|
||||
|
||||
---
|
||||
|
||||
## 6. 三个独立判断
|
||||
|
||||
**最小?—— 基本是,有一处可争议的扩张。**
|
||||
生产 Go 代码净增约 37 行,六份重复的 heal / peer apply 被一套 helper 替代;没有新 schema、没有新 wire 字段、没有新锁、没有能力协商、没有迁移系统。唯一超出必要的是 **Policy 规范编码器接入写/读/导出路径**(F5)——它不是收敛所必需的,并且直接导致 F2。其余(`bucketMetadataUpdate` 提交快照、`ensureBucketMetadataCreated`、`rebaseBucketConfigDefaults`、gate)我都能各自对应到一个已证实的缺陷或本次修复直接触及的路径,**没有**可以无损删除的部分。
|
||||
|
||||
**充分?—— 对"已声明的范围"是;对"缺陷标题"不是。**
|
||||
在同一桶世代、Created 已知、gate=on 的前提下,六类配置的源时间、锁范围、删除状态参与 heal、等时冲突裁决、重复/乱序抑制都成立。但两个口子要明说:(1) 默认 gate=off ⇒ Tags/SSE/Quota 的漏发删除不收敛(F7,计划内取舍);(2) Created 未知的桶从"能写"变成"不能写"(F1,计划外回归)。
|
||||
|
||||
**必要?—— 是。**
|
||||
每一项改动都能对应到 `baseline.log` 里的实测失败(SOURCE_TIME / NEWER_DELETE / STALE_RESURRECTION / BULK_STALE_OVERWRITE / CHECK_OUTSIDE_LOCK)或计划中论证过的路径。`rebaseBucketConfigDefaults` 这种看起来最"多余"的小分支,实际是 `Created` 一旦参与 baseline/tombstone 判定后的必然补丁(不加则接管时默认值会变成假墓碑)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 新增设计记录应准确保留的内容
|
||||
|
||||
**关键决策**
|
||||
1. baseline / live / tombstone 三态由 `(Created, 字段时间, 载荷空否)` 推导,**不新增 schema 字段**;`at == Created` 定义为 baseline。代价:必须知道 Created(⇒ F1 的根因)。
|
||||
2. 全序:`valid > real > at > 同时间墓碑胜 > 稳定内容键`。deployment ID **不**参与比较,也不落盘。
|
||||
3. 比较键是"已解析配置的纯函数":Policy 走递归排序的规范 JSON,Quota 走解析后 `json.Marshal`,XML 走有效文档字节,Versioning 先按本站点 Object Lock 归一。
|
||||
4. Versioning / Object Lock 为 update-only:空事件恒为 no-op,空值不是候选。
|
||||
5. 读-比较-写整体在**既有** `metadata.lock` 内;`saveMetadata` 收指针以便调用方拿到提交后快照,保证"落盘状态 / 出站事件 / 源时间"三元组一致。
|
||||
6. 新增删除信息的**导出**用启动开关 gate,默认 off;开关不探测远端能力,启用条件是全站点全节点已升级且旧请求排空。
|
||||
7. 专用 peer 事件的零时间保留兼容例外(分配本地时间 + 限频 `legacy-zero`);bulk 零时间仍按现状拒绝。
|
||||
|
||||
**被拒绝的方案**(记录以免复议)
|
||||
- 新增 HLC / 向量时钟 / 新 wire 字段 / 能力协商 / 通用复制框架 / 新锁或重试系统。
|
||||
- 用 `len(payload)==0 ⇒ 删除` 统一处理 bulk(会把"省略"误判为删除)。
|
||||
- 把 `BucketPolicy.Equals`(忽略 Sid、对 Statement 顺序敏感)叠加为第二套判等规则。
|
||||
- 把混版长期测试作为提交门槛。
|
||||
- 把未修改上游 MinIO 的兼容性当作必需门禁(AGENTS.md:正式支持 PGSTY 栈,上游兼容为尽力而为)。
|
||||
|
||||
**限制**
|
||||
- gate=off 期间 Tags/SSE/Quota 墓碑不可见,不承诺删除收敛,且存在被拒绝的周期性 RPC。
|
||||
- 旧版到达时间污染、legacy-zero 产生的新本地时间、桶创建世代分歧**无法自动反推**,需运维在权威站点重新提交。
|
||||
- `Created` 未知的桶不在收敛承诺内(并且按当前实现直接不可写,见 F1)。
|
||||
- "比较键相同但落盘字节不同"(Quota 原始 JSON、Policy 旧编码)是允许的稳定状态,公开 mismatch 统计必须与比较键口径一致(见 F2)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 建议处置顺序
|
||||
|
||||
1. 判定 F1 的条件(支持矩阵里有没有 `Created == 0` / 无 `.metadata.bin` 的桶)。有 ⇒ 先修再合。
|
||||
2. 修 F2(推荐:不排序顶层 `Statement`,或让 `isBktPolicyReplicated` 改用规范键)。
|
||||
3. 修 F3(ERROR→Warning、缺桶不报、reason 拆分)。
|
||||
4. 补 F4 的三条文档。
|
||||
5. 补测试:F10 的接管用例、F8-1 的 legacy 策略字节双站点用例、F8-2 去 stub 的 Created 用例、F8-4 的二进制身份记录。
|
||||
6. F5 / F6 由作者判断,可留作后续。
|
||||
@@ -0,0 +1 @@
|
||||
ok github.com/minio/minio/cmd 6.864s
|
||||
@@ -0,0 +1 @@
|
||||
ok github.com/minio/minio/cmd 20.694s
|
||||
@@ -0,0 +1,57 @@
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery (0.57s)
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/object-lock.xml/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/versioning.xml/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/policy.json/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/tagging.xml/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/bucket-encryption.xml/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/quota.json/missing=false (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/object-lock.xml/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/versioning.xml/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/policy.json/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/tagging.xml/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/bucket-encryption.xml/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/quota.json/missing=true (0.00s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/object-lock.xml/missing=false (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/versioning.xml/missing=false (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/policy.json/missing=false (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/tagging.xml/missing=false (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/bucket-encryption.xml/missing=false (0.06s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/quota.json/missing=false (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/object-lock.xml/missing=true (0.04s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/versioning.xml/missing=true (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/policy.json/missing=true (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/tagging.xml/missing=true (0.01s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/bucket-encryption.xml/missing=true (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/quota.json/missing=true (0.02s)
|
||||
site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
|
||||
--- FAIL: TestBucketPolicyReplicationStatusLegacyOrder (0.31s)
|
||||
--- FAIL: TestBucketPolicyReplicationStatusLegacyOrder/ErasureSD (0.01s)
|
||||
site-replication-metadata_test.go:348: equivalent legacy and received policy reported as permanently mismatched
|
||||
--- FAIL: TestBucketPolicyReplicationStatusLegacyOrder/Erasure (0.06s)
|
||||
site-replication-metadata_test.go:348: equivalent legacy and received policy reported as permanently mismatched
|
||||
FAIL
|
||||
FAIL github.com/minio/minio/cmd 2.940s
|
||||
FAIL
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
--- FAIL: TestHealBucketConfigDiagnostics (0.29s)
|
||||
--- FAIL: TestHealBucketConfigDiagnostics/ErasureSD (0.00s)
|
||||
site-replication-metadata-heal_test.go:80: empty baselines produced diagnostics
|
||||
--- FAIL: TestHealBucketConfigDiagnostics/Erasure (0.01s)
|
||||
site-replication-metadata-heal_test.go:80: empty baselines produced diagnostics
|
||||
FAIL
|
||||
FAIL github.com/minio/minio/cmd 2.721s
|
||||
FAIL
|
||||
@@ -0,0 +1,8 @@
|
||||
--- FAIL: TestBucketMetadataInitialSyncPhysicalCreated (0.47s)
|
||||
--- FAIL: TestBucketMetadataInitialSyncPhysicalCreated/ErasureSD (0.04s)
|
||||
site-replication-metadata-gate_test.go:289: peer creation time "0001-01-01T00:00:00Z", want physical time 2026-09-12 06:03:09 +0000 UTC
|
||||
--- FAIL: TestBucketMetadataInitialSyncPhysicalCreated/Erasure (0.08s)
|
||||
site-replication-metadata-gate_test.go:289: peer creation time "0001-01-01T00:00:00Z", want physical time 2026-09-12 06:03:09 +0000 UTC
|
||||
FAIL
|
||||
FAIL github.com/minio/minio/cmd 2.576s
|
||||
FAIL
|
||||
@@ -0,0 +1,18 @@
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics (0.36s)
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD (0.00s)
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD/unknown-created (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD/malformed (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD/before-created (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure (0.01s)
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure/unknown-created (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure/malformed (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure/before-created (0.00s)
|
||||
site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
|
||||
FAIL
|
||||
FAIL github.com/minio/minio/cmd 2.430s
|
||||
FAIL
|
||||
@@ -0,0 +1,174 @@
|
||||
# SILO #77 修正提交独立复审(固定 `62cf066ff529c7d281703daa365f555cebba717a`)
|
||||
|
||||
审查方式:只读。以 worktree 生产源码为准(不只读 diff),交叉核对 `silo-pkg v3.13.4-0.20260910091716` 依赖源码、证据目录与设计记录。上一轮报告只作为待验证的命题,不作为已确认结论。
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论
|
||||
|
||||
**GO_WITH_NONBLOCKING_NOTES**
|
||||
|
||||
- **阻断项:0**(无条件 0,条件性 0)
|
||||
- 上一轮的条件性阻断 F1 与 S2 级 F2 均已在生产路径上真正关闭,并有"最终测试 + 旧生产代码覆盖"的复现证据(`round-2/confirmed-before.log`)。
|
||||
- 新增 8 项非阻断发现(NB-1 ~ NB-8),其中 3 项是行为残留(S3/S4),5 项是证据与文档表述准确性问题。
|
||||
- 一处需要纠正上一轮的**判断**(不是纠正实现):F5 中"Policy 规范编码器接入 GET/export/peer 属于超出必要的扩张"这一说法不成立——这三个调用点持有的是**已解析策略**,必须有编码器,而旧编码器对解析器已支持的负集合策略**必然报错**。真正"可选"的只有 PUT/import 两处,且因 `DisallowUnknownFields` 而无损。
|
||||
|
||||
---
|
||||
|
||||
## 1. F1–F10 关闭状态
|
||||
|
||||
| 项 | 上轮判定 | 本轮核实 | 状态 |
|
||||
|---|---|---|---|
|
||||
| F1 零 Created 桶六类配置写入硬失败、初次同步静默漏发 | S1 条件阻断 | 生产路径已修复并有真实 ObjectLayer + 旧码覆盖复现 | **关闭**(残留见 NB-1) |
|
||||
| F2 Policy 状态口径与收敛键冲突导致永久假 mismatch | S2 | `isBktPolicyReplicated` 改用同一 canonical 键,per-site presence 不变,无 SID/ID/Condition/未知值回归 | **关闭** |
|
||||
| F3 heal 诊断 ERROR 级、key 互相顶替、正常瞬态误报 | S3 | ERROR→Warning、四类原因各自持 key、缺桶不报 = 已关闭;"全无有效来源即完全静默"与"按桶×字段扇出"未处理 | **部分关闭**(NB-2、NB-3) |
|
||||
| F4 三处语义变更未文档化 | S3 | 上轮前两项系误读(原提交 README 已有,见 diff 中为 context 行);第三项已补入 `docs/site-replication/README.md:129-134` | **关闭 + 原误读已纠正** |
|
||||
| F5 Policy 规范编码器接入面 | 最小性争议 | 判断更新:GET/export/peer 非可选;PUT/import 可选但无损。建议保留现状 | **关闭(判断修正)** |
|
||||
| F6 死代码与过时注释 | nit | 注释已修(`bucket-metadata-replication.go:35-37`);`isBucketMetadataEqual` 仍为测试专用 | **关闭(保留 nit,见 NB-5)** |
|
||||
| F7 gate=off 不承诺四类删除自愈 | 范围说明 | README:75-89、设计记录 §rollout/§limits 均如实声明,未说成已自动修复 | **保持(如实)** |
|
||||
| F8 证据缺口(stub / 旧顺序策略 / 二进制身份) | 证据不足 | stub 已去除、旧顺序用例已补、二进制身份已闭环(`--version` 含 commit-id + 对照基线二进制) | **关闭**(但 NB-4:两份旧日志不可用作证据) |
|
||||
| F9 桶世代冲突 | 范围外 | 未被声称已修;设计记录 §limits-3 明确 | **保持(如实)** |
|
||||
| F10 接管 Created 越过真实字段时间 | 覆盖缺口 | 补 `shift=3h` 子用例,钉住"旧世代状态不得升级为有效来源" | **关闭(半边,见 NB-7)** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮重点逐项核实
|
||||
|
||||
### F1 —— 真正修好了,机制比上轮建议更完整
|
||||
|
||||
**`NoMetadata=true` 返回物理探测**(`cmd/erasure-server-pool.go:2279-2283`)。我枚举了全仓库 `NoMetadata` 的全部生产调用点,只有三处:
|
||||
|
||||
- `cmd/bucket-metadata-sys.go:272`(`saveMetadata` 存在性重查,只看 error)
|
||||
- `cmd/bucket-metadata.go:306`(迁移锁内存在性重查,只看 error)
|
||||
- `cmd/bucket-metadata-replication.go:281`(新增的 Created 恢复,正是要物理值)
|
||||
|
||||
其余全部是 `BucketOptions{}`(`NoMetadata=false`),行为不变。被跳过的 `globalBucketMetadataSys.Get()`(`bucket-metadata-sys.go:410-424`)是**纯缓存读、无副作用、不触发加载**,因此跳过它不会改变任何缓存预热或懒加载行为。`Versioning`/`ObjectLocking` 两个字段也只在非 NoMetadata 分支填,与 `ListBuckets`(`:2402/2422` 只在 `!NoMetadata` 时覆盖 Created)语义一致。**无调用者回归。**
|
||||
|
||||
**世代一致性链路完整**(这一点比上轮提出的方案更强,值得记录):
|
||||
`syncToAllPeers`(`site-replication.go:2145-2153`)恢复 `meta.Created` → 传入 `MakeBucketOptions.CreatedAt` → `MakeBucketHook`(`:821-826`)在缓存值为零时回落到 `opts.CreatedAt`(此时本地磁盘仍是 0,所以这个回落是必需的)→ **本地**分支 `PeerBucketMakeWithVersioningHandler`(`:943-966`)在 `metadata.lock` 内 `SetCreatedAt(opts.CreatedAt)` + `rebaseBucketConfigDefaults` + `saveMetadata`,**远端**分支通过 `optsMap["createdAt"]` 收到同一个值。也就是说本地并非"只在内存里补一下",而是与全部对端落盘同一个世代值。这条链路 diff 上看不出来,必须读 `MakeBucketHook` 的 `concDo` 第一个闭包才能确认。
|
||||
|
||||
四个恢复点的锁位置也确认无误:写路径(`bucket-metadata-sys.go:186`,锁内)、bulk(`site-replication.go:1674`,锁内)、import(`admin-bucket-handlers.go:1092`,锁内)、初次同步(锁外探测但不落盘,落盘由上面的钩子在锁内完成)。
|
||||
|
||||
**"无法恢复时显式错误"是否正确:正确。** 静默发明 `UTCNow()` 会造出一个不可撤销、且会向全站点传播的假世代;而显式失败保留原数据、可重试、并留一条有界 `indeterminate`。`syncToAllPeers` 因此让整个 `AddPeerClusters` 失败——这在真实文件系统上基本不可达(`StatVol` 用目录 `ModTime`,存在即非零),且 `AddPeerClusters` 是可重试的交互式管理操作,代价可接受。
|
||||
|
||||
**真实用例是否覆盖生产路径:是。** `TestBucketMetadataPhysicalCreatedRecovery`(`site-replication-metadata-gate_test.go:206-237`)通过 `globalBucketMetadataSys.save` 让**缓存持有 Created=0**,再走 `Update` → `updateAndParseMetadata` → `ensureBucketMetadataCreated` → 真实 `erasureServerPools.GetBucketInfo`;`ExecObjectLayerAPITest`(`test-utils_test.go:1766/1795`)两个后端都是真实 `erasureServerPools`,因此缓存覆盖这一段是真跑到的。`setPhysicalBucketCreated` 直接 `os.Chtimes` 每块盘的桶目录,正是 `xl-storage.go:1022-1028` 取值的来源。六类配置 × `missing=true/false` 全覆盖。
|
||||
|
||||
### F2 —— 关闭,且口径现在六类全部自洽
|
||||
|
||||
`site-replication.go:3782-3809`。状态侧的 `policies[i]` 来自 `:3436` 对**各站点原始字节的解析**,与 heal 的 `bucketConfigPayload`→`canonicalBucketPolicy`(`bucket-metadata-replication.go:191`)是同一个纯函数,因此 peer 重排 Statement 后的永久假 mismatch 被彻底关闭。`numPolicies != total` 的 per-site presence 计数原样保留(`:3783-3792`),并有断言覆盖。
|
||||
|
||||
回归面逐条查过:
|
||||
|
||||
- **SID**:旧 `BPStatement.Equals`(silo-pkg `bucket-policy-statement.go:148-171`)**不比较 SID**,新键包含 `Sid`。这是方向正确的收紧——heal 的收敛键同样含 SID,所以状态报出的 mismatch 是**真实且会被 heal 自动消除**的,不是永久噪声。用例已钉住。
|
||||
- **ID / Version**:旧 `Equals` 比较二者,新键在 `ID != ""` 时包含、`Version` 始终包含,等价。
|
||||
- **Condition**:两侧都走同一 `condition.Functions` 编码再递归排序,顺序无关。
|
||||
- **未知值**:`ParseBucketPolicyConfig` 用 `decoder.DisallowUnknownFields()`(`bucket-policy.go:173`),未知字段**根本无法通过解析**,因此不存在"键把未建模字段静默吃掉"的风险。
|
||||
- `isIAMPolicyReplicated`(`:3665-3681`)仍用 `Equals`,那是 IAM 策略、不同子系统,**不应**一并改动。正确地没动。
|
||||
|
||||
新旧行为是"收紧而非放宽",与收敛契约一致,判断合理。
|
||||
|
||||
### F3 —— 关闭了会吃日志的部分,但静默面仍偏大
|
||||
|
||||
已关闭:四种原因各自持 key(`site-replication-metadata.go:145/158/164/189`),真实 heal RPC 失败不再被"缺 peer"顶掉;`logger.WarningKind`(`:42`);对端"尚未拥有该桶"(`CreatedAt=0` 且载荷空、时间零)不再报。`siteReplicationStatus` 对不可达 peer 填 `DeploymentID=""`(`:3029`)落进 `BucketStats[bucket][""]`,而循环 1 按 `info.Sites` 的真实 ID 判缺失、循环 2 跳过 `id==""`,两条路径正交,语义正确。README:112-119 与实现完全一致。
|
||||
|
||||
未关闭的两点见 NB-2、NB-3。另外,"仅报告确实存在、但仍无法排序的状态"(`:157`)这个条件写得准确:`!current.valid && (len(data)!=0 || !at.IsZero())` 恰好把 F9/F10 的世代冲突暴露出来,这是意外的正收益。
|
||||
|
||||
### F5 —— 上一轮的最小性判断需要修正
|
||||
|
||||
`canonicalBucketPolicy` 的六个生产接入点应分成三类,而不是笼统的"超出必要":
|
||||
|
||||
| 接入点 | 性质 |
|
||||
|---|---|
|
||||
| `bucket-metadata-replication.go:191`(比较键) | **必需**,#77 的核心 |
|
||||
| `site-replication.go:3802`(状态键) | **必需**,F2 的修复本体 |
|
||||
| `bucket-policy-handlers.go:202`(GET)、`admin-bucket-handlers.go:438`(export)、`site-replication.go:1723`(peer apply) | **非可选**:这三处拿到的都是 `*policy.BucketPolicy`(`globalPolicySys.Get` / `GetBucketPolicy` / `admin-handlers-site-replication.go:228` 解析后传入),必须有编码器;而旧的 `json.Marshal` 对 `NotAction`/`NotResource` 语句**必然失败**——`BPStatement.Actions` 无 `omitempty`(`bucket-policy-statement.go:31`)+ `ActionSet.MarshalJSON` 空集报错(`actionset.go:144-148`)。bulk/import/peer 都能把这类策略落盘,所以缩回任何一处都会重新制造"写得进、读不出/复制不出" |
|
||||
| `bucket-policy-handlers.go:102`(PUT)、`admin-bucket-handlers.go:922`(import) | **可选的规范化**:比较键不依赖落盘字节。但因为解析器 `DisallowUnknownFields`,规范化**无信息丢失**(唯一差别是重复语句在盘上也被去重,语义等价),而所有读路径都已重新编码,用户观察不到"原样字节"。删掉它只换来与客户端提交字节的一致性,且会让本地写与 peer 写产生两种盘上形态 |
|
||||
|
||||
**结论:不建议缩回任何一处。** 真正可删的只有 PUT/import 两处,收益为零、改动风险非零。上一轮把这部分列为"范围外扩张"的说法应当被本轮判断取代;它是一个被同一改动暴露出来的真实读回缺陷的修复,且已在 README:129-134 声明。
|
||||
|
||||
---
|
||||
|
||||
## 3. 新发现(全部非阻断)
|
||||
|
||||
### NB-1(S3)恢复出的物理 Created 不确定,且可能晚于对端事件的来源时间
|
||||
|
||||
- **位置**:`cmd/bucket-metadata-replication.go:277-290`;取值链 `cmd/peer-s3-client.go:324-328`(取第一个不报错的 peer)→ `cmd/peer-s3-server.go:207-258`(`cloneDrives` 遍历 **Go map**,取第一个不报错的盘)→ `cmd/xl-storage.go:1022-1028`(`Created = st.ModTime()`)。
|
||||
- **最短触发**:桶无 `.metadata.bin`;其顶层目录 mtime 因近期对象写入被推进到接近 now;对端发来源时间早于该 mtime 的事件 → `bucket-metadata-sys.go:198-201` 判 `before-created` → **返回 nil、不写、不报错**,仅一条每小时去重的 warning,直到有人在源站重新提交。
|
||||
- **影响**:(a) 恢复值不是桶的稳定属性(目录 mtime 随顶层条目增删前进,且逐盘不同);(b) 两个站点各自独立恢复时会落进不同世代。二者都落在已声明的 F9 限制内,且相对基线(基线给每个站点各自 `UTCNow()`)是改善;本地侧因 `metadata.lock` 串行化 + 首写落盘,站内不会分裂。真正新的只有"首个跨站事件可能被静默跳过"。
|
||||
- **最小修复(可选)**:让 `ensureBucketMetadataCreated` 返回"本次是否为恢复值",当 `sourceTime` 非零且早于恢复值时取 `min(recovered, sourceTime)`——"在时刻 T 已持有配置的桶必然在 T 已存在",约 4 行,不新增字段。或退一步:只在 README 里把恢复值明确为"物理近似值,可能晚于真实创建"。
|
||||
- **需补证据**:一个用例——缓存 `Created=0`、`os.Chtimes` 把桶目录设为 now、通过 admin 路由投递 `UpdatedAt = now-1h` 的 peer 事件,断言期望行为(写入或明确跳过),把当前语义钉死。
|
||||
|
||||
### NB-2(S3)`!found` 时对畸形/不可排序状态完全静默
|
||||
|
||||
- **位置**:`cmd/site-replication-metadata.go:134-139`。
|
||||
- **最短触发**:全部站点的同一字段都无法成为候选(例如各站点都存着一份 base64 可解但内容非法的 XML,或全部世代未知)。`latestBucketConfig`(`:117-120`)对 `err != nil` 静默 `continue`,`healBucketConfig` 直接 `return nil`——**没有任何诊断**。而状态导出侧的 `logInvalid`(`site-replication.go:3375-3378`)只覆盖 base64 失败和 policy/quota/replication 的解析失败,XML 类内容非法不在其中,于是这种状态在两条路径上都无声。
|
||||
- **影响**:字段永久不可 heal 且无信号。范围窄(要求所有站点同时不可用),但属于"必要诊断"缺口。
|
||||
- **最小修复**:把每目标的 `bucketConfigStateFromInfo` 结果算一遍并缓存(当前每周期每目标实际解码两次:`latestBucketConfig` 一次、主循环一次),把 `currentErr != nil` 的 `indeterminate` 移到 `if !found` 之前。既补诊断又减一次解码,不引入新框架。
|
||||
- **需补证据**:`healBucketConfig` 单测——两个站点都给非法载荷,断言返回 nil 且产生一条 `indeterminate`(可用现有 `srStatusInfo` 夹具,参照 `site-replication-metadata-heal_test.go:136-143`)。
|
||||
|
||||
### NB-3(S4)`unreachable` 诊断按"桶 × 字段"扇出
|
||||
|
||||
- **位置**:`cmd/site-replication-metadata.go:143-147`,被 `cmd/site-replication.go:4750-4760` 每桶调用六次。
|
||||
- **最短触发**:一个站点掉线。每桶产生 6 个不同 key → 10k 桶集群每小时约 6 万条 warning。
|
||||
- **影响**:只是噪声(`logOnceIf` 按小时清理,仍算"有界"),但一个站点级事实被放大成桶×字段级输出。
|
||||
- **最小修复**:把循环 1 上提到 `healBuckets` 里、每桶调用一次(key 去掉 `file` 段),或干脆每周期一次。改动 <15 行。
|
||||
- **需补证据**:不需要新证据,行数/键数可直接核对。
|
||||
|
||||
### NB-4(S4,证据)两份旧日志是草稿测试产物,不能作为产品失败证据;设计记录"三个回归测试"应为两个
|
||||
|
||||
- **位置**:`findings-before.log:3`、`findings-after-1.log:3` 均为 `site-replication-metadata-gate_test.go:230: real physical creation time unavailable`。该消息在最终测试文件中**不存在**,最终断言在 **227** 行、消息是 `bucket without a recorded creation time cannot update %s: %v`。同理 `findings-before.log:52` 失败在 SID 断言(`:353`),而最终文件 SID 断言在 `:355`、`:353` 是 `t.Fatal(err)`。
|
||||
- **判断**:这两份日志跑的是早期草稿;其中 "real physical creation time unavailable" 更像**测试自身的前置检查**失败,不能用来证明产品缺陷。真正成立的是 `round-2/confirmed-before.log`:它用**最终测试**叠加 `before-erasure-server-pool.go` / `before-site-replication.go`(我已核对这两份确实是修正前版本:无 `NoMetadata` 早返回、`MakeBucketHook` 无回落、`syncToAllPeers` 仍用 `bucketInfo.Created`、`isBktPolicyReplicated` 仍是 `prev.Equals`),行号 227/348 与最终文件精确吻合,失败文本是真实产品错误 `bucket metadata creation time is unknown` 与 `equivalent legacy and received policy reported as permanently mismatched`,两后端 × 六类 × missing 双态全覆盖;`confirmed-after.log` 通过。
|
||||
- **另一项**:设计记录第 166 行"三个回归测试在未修复代码上失败、修复后通过"不准确。仓库中只有**两个**这样的测试;F3(heal 诊断)**没有任何自动化回归**(我在全部 `*_test.go` 中检索 `unreachable`/`peer-error`/`unusable peer`/`logBucketConfigReplication`,无命中)。F3 的唯一证据是 `runtime-after-review.log:20` 的驱动自述"40 exceptional events produced exactly one log per reason"。
|
||||
- **最小修复**:把该行改为"两个回归测试 + 一次运行时观察",并在证据索引里把 `findings-before.log` / `findings-after-1.log` 标注为 superseded 草稿。
|
||||
- **需补证据(可选)**:给 F3 补一个日志断言用例(注入 logger target 或计数 hook),否则 F3 的修复在回归套件里是无保护的。
|
||||
|
||||
### NB-5(S4)不要为死代码创造兼容理由
|
||||
|
||||
`isBucketMetadataEqual`(`cmd/site-replication.go:5172-5181`)仅被 `site-replication_test.go:156`、`bucket-cors-site-replication_test.go:484` 引用。代码里没有编造理由(很好),但设计记录第 186 行写"作为上游血缘保留"是一个不成立的说法。**最小修复**:改成"目前仅测试引用,暂不删除以缩小 diff",或连同两处测试一并删除。
|
||||
|
||||
### NB-6(S4)README 对恢复发生位置的描述偏保守
|
||||
|
||||
`docs/site-replication/README.md:106-110` 说"That recovery happens on the write path … 第一次配置写入(本地或复制而来)会记录物理时间"。实际上 `AddPeerClusters` 的初次同步(`site-replication.go:2145` → `MakeBucketHook` → 本地 `PeerBucketMakeWithVersioningHandler`)也会在本地落盘该时间。这是**低估**不是夸大,但会误导运维判断"何时脱离跳过状态"。**最小修复**:补半句"或在把该桶纳入站点复制时"。
|
||||
|
||||
### NB-7(S4)F10 只钉住了"来源"半边
|
||||
|
||||
`cmd/site-replication-metadata_test.go:473-483` 断言接管把 Created 调晚后,越过的真实字段**不能成为来源**(`!state.candidate()`)。但上一轮指出的另一半——该字段作为 **heal 目标**时,`current.valid=false` 使任何合法 incoming 无条件胜出(`site-replication-metadata.go:174` + `bucket-metadata-replication.go:239-245`)——没有钉住。我确认这半边行为存在且在"远端世代胜出"语义下是自洽的,本地用户 PUT 也总能覆盖回来(`localBucketConfigUpdatedAt` 保证 `> Created`),**不是缺陷**,但期望值应当被固定。**最小修复**:同一子用例再加一次 `applyBucketConfig(&got, file, <远端载荷>, created+shift+1m)`,断言返回 `changed=true`。
|
||||
|
||||
### NB-8(S4)恢复失败时的诊断丢掉了事件来源时间
|
||||
|
||||
`cmd/bucket-metadata-sys.go:187` 传 `time.Time{}` 作为 `at`,即使本次是携带 `sourceTime` 的 peer 事件,日志里也显示 `0001-01-01`。**最小修复**:`sourceTime != nil` 时传 `*sourceTime`(一行)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 证据分级
|
||||
|
||||
| 证据 | 评价 |
|
||||
|---|---|
|
||||
| `round-2/confirmed-before.log` + `confirmed-after.log` + `before-*.go` 覆盖文件 | **足够**。最终测试 × 旧生产代码,行号与失败文本可逐条核对,F1/F2 为已证实的产品失败 |
|
||||
| `binary-identity-after-review.json` | **足够**。`silo-final --version` 含 `commit-id=62cf066ff…`,并附基线 `5c5765816` 对照二进制与双方 sha256,上一轮 F8-4 的缺口已闭合 |
|
||||
| `final-cmd-after-review.log`(cmd 全包 622s, EXIT=0)、`ci-lint-after.log`(0 issues, 品牌基线未变)、`ci-verify-after.log`、`ci-gen/internal/s3select/crosscompile-after.log` | **足够**作为"未引入回归"的门禁证据(只截尾行,可接受) |
|
||||
| `findings-before.log`、`findings-after-1.log` | **不可用**。草稿测试产物,行号与失败文本均与最终测试不符(见 NB-4) |
|
||||
| `findings-after.log` | 仅"ok",无信息量,被 `confirmed-after.log` 覆盖 |
|
||||
| `runtime-after-review.log` | **作者自述为主**。日志由作者的双站点驱动产生,断言("six field states equal""zero metadata RPCs""exactly one log per reason")都由驱动自己判定。时间戳 06:01:50 与最终二进制构建时间 06-01-35Z 吻合,身份链成立;但它是端到端观察,不是独立验证。F3 目前**只有**这一条证据 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 设计记录(`bucket-metadata-convergence.zh.md`)的准确性
|
||||
|
||||
总体克制,未发现把限制说成已修复的地方:§rollout(gate=off 不承诺四类删除收敛)、§limits-1/2/3(历史污染、零时间事件、桶世代冲突)都如实写成限制;第 20 行明确"不能用来证明现有下载包或线上实例已具备这些能力";第 161 行主动声明交叉编译 ≠ 六平台运行验收。需要修正的只有三处,均已在上面列出:
|
||||
|
||||
1. 第 166 行"三个回归测试" → 两个回归测试 + 一次运行时观察(NB-4)。
|
||||
2. 第 186 行 `isBucketMetadataEqual`"上游血缘保留"是编造的理由(NB-5)。
|
||||
3. 第 181 行"与 `ListBuckets` 一致"成立(`ListBuckets` 在 `NoMetadata` 时同样不覆盖 Created),但第 185 行把 F5 记为"有意扩张,不是缺陷"仍沿用了上一轮的定性——按本轮判断,GET/export/peer 三处属于**必需**,建议改写,否则会给未来的人留下"这块可以缩回"的错误暗示。
|
||||
|
||||
---
|
||||
|
||||
## 6. 最小性 / 充分性 / 必要性(独立评价)
|
||||
|
||||
**最小性:是。** 本轮修正净增很小,且每一处都能对应到一个已复现的失败或一次被证实的误读:`NoMetadata` 早返回 5 行、`MakeBucketHook` 回落 3 行、`syncToAllPeers` 恢复 4 行、`isBktPolicyReplicated` 换键约 10 行、heal 诊断重排约 15 行、`isReplicatedBucketConfig` 具名化(消除"用一次性零值结构体探测类型"的晦涩写法)、README 两段。没有新 schema、新 wire 字段、新锁、新框架。我找不到可以无损删除的部分——唯一"可删"的 `canonicalBucketPolicy` PUT/import 接入是上一轮遗留,删掉反而制造两种盘上形态。
|
||||
|
||||
**充分性:对已声明契约充分,对两个边缘不充分且已如实声明。** 在"同一桶世代、Created 已知或可恢复、gate=on"范围内,六类配置的来源时间、锁范围、删除参与 heal、等时裁决、重复/乱序抑制、状态口径,我没有找到反例。不充分的两处都写进了文档:gate=off 下 Tags/SSE/Quota 漏发删除不收敛(F7);跨站点桶世代分歧不自动合并(F9,NB-1 属于它的子集)。F3 的静默面(NB-2)是本轮唯一一个"文档声明了、但我认为声明本身偏宽"的地方——"没有任何站点可供传播的字段就不产生诊断"对畸形配置而言不该成立。
|
||||
|
||||
**必要性:是。** F1 与 F2 都有"最终测试 + 修正前生产代码"的复现(`confirmed-before.log`),是确证的产品失败,不是理论推演;F3 的必要性来自 `logOnceIf` 按 key+正文去重这一可验证机制,虽无自动化回归但代码级推理成立;F4/F8/F10 是文档与证据补齐,成本近零。F5 的编码器接入经本轮重新论证后,从"可争议扩张"变为"其中三处必需、两处无损",必要性判断上调。
|
||||
|
||||
**处置建议(均非阻断,可合并后处理)**:NB-4 与 NB-5 是文字修正,建议合并前一并改掉(它们会影响后人对证据强度的判断);NB-2、NB-8 可合成一个约 20 行的提交;NB-1、NB-3、NB-6、NB-7 可进 backlog。
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"head": "62cf066ff529c7d281703daa365f555cebba717a",
|
||||
"prior_review_head": "4089113e3edbd21a29be8e6af74662462bcc22af",
|
||||
"started_at": "2026-09-12T08:46:55.400524+00:00",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-09-12T09:07:16.313535+00:00",
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 973153,
|
||||
"num_turns": 71,
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"used_tools": {
|
||||
"Read": 41,
|
||||
"Grep": 24,
|
||||
"Glob": 5
|
||||
},
|
||||
"stream_sha256": "63046ccfab9fb158178f43b09c75a9809a13ed1ff2da5a626e9e3c3e96c13d37",
|
||||
"review_sha256": "306a856010f7f9bd1cbec39aba83236978f5a811c98f7fc3f8a08217f72de111",
|
||||
"auxiliary_model_note": "CLI model_usage may include auxiliary calls. assistant_models identifies the actual review message model. max was explicitly passed in the recorded command.",
|
||||
"command": [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"--print",
|
||||
"--model",
|
||||
"claude-opus-5",
|
||||
"--effort",
|
||||
"max",
|
||||
"--safe-mode",
|
||||
"--permission-mode",
|
||||
"plan",
|
||||
"--tools",
|
||||
"Read,Grep,Glob",
|
||||
"--strict-mcp-config",
|
||||
"--no-session-persistence",
|
||||
"--add-dir",
|
||||
"$LOCAL_EVIDENCE",
|
||||
"--add-dir",
|
||||
"$DOCS_WORKTREE/content/blog/design",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose"
|
||||
],
|
||||
"command_note": "Same explicit CLI model/effort/read-only options as recorded in the tool invocation; round-2 had its own pinned worktree and prompt.",
|
||||
"session_id": "8743aaa3-5c06-43db-8eba-08302d504c4e",
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"source_clean": true,
|
||||
"built_at": "2026-09-12T09:12:38.105913+00:00",
|
||||
"binaries": {
|
||||
"silo-reviewed": {
|
||||
"sha256": "4825a801ce0ac48d636d9428ce4cd5c17a20b6cfec0b569de70a124c9c005049",
|
||||
"version": "silo-reviewed version DEVELOPMENT.2026-09-12T09-09-34Z (commit-id=fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025)\nRuntime: go1.27.1 darwin/arm64\nLicense: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html\nCopyright: 2015-2025 MinIO, Inc.\nModifications: Copyright 2025-2026 PGSTY\nSource compatibility: based on MinIO technology",
|
||||
"build_info_sha256": "91778f5f2844ada1234ed6a97d97fce7d87dc6334fdd9ef839fbeb7ebdb995e3",
|
||||
"build_info_identity_lines": [
|
||||
"\tpath\tgithub.com/minio/minio",
|
||||
"\tmod\tgithub.com/minio/minio\tv0.0.0-20260912090934-fcbb93e89572\t",
|
||||
"\tbuild\t-buildmode=exe",
|
||||
"\tbuild\t-compiler=gc",
|
||||
"\tbuild\t-trimpath=true",
|
||||
"\tbuild\tCGO_ENABLED=0",
|
||||
"\tbuild\tGOARCH=arm64",
|
||||
"\tbuild\tGOOS=darwin",
|
||||
"\tbuild\tGOARM64=v8.0",
|
||||
"\tbuild\tvcs=git",
|
||||
"\tbuild\tvcs.revision=fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"\tbuild\tvcs.time=2026-09-12T09:09:34Z",
|
||||
"\tbuild\tvcs.modified=false"
|
||||
]
|
||||
},
|
||||
"silo-before": {
|
||||
"sha256": "3b07b9604f2ca9f68f33b69f7807f57e0cc9b1efb0310bbf0aa7b3997e4483b9",
|
||||
"version": "silo-before version DEVELOPMENT.2026-09-11T18-00-57Z (commit-id=5c576581631561c446f30ae5b566f0aa793adc1c)\nRuntime: go1.27.1 darwin/arm64\nLicense: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html\nCopyright: 2015-2025 MinIO, Inc.\nModifications: Copyright 2025-2026 PGSTY\nSource compatibility: based on MinIO technology",
|
||||
"build_info_sha256": "84fb4540758d7dd56367069e3843a97d1e2c3528905db11d490b9654a2d6205d",
|
||||
"build_info_identity_lines": [
|
||||
"\tpath\tgithub.com/minio/minio",
|
||||
"\tmod\tgithub.com/minio/minio\tv0.0.0-20260911180057-5c5765816315\t",
|
||||
"\tbuild\t-buildmode=exe",
|
||||
"\tbuild\t-compiler=gc",
|
||||
"\tbuild\t-tags=kqueue",
|
||||
"\tbuild\t-trimpath=true",
|
||||
"\tbuild\tCGO_ENABLED=0",
|
||||
"\tbuild\tGOARCH=arm64",
|
||||
"\tbuild\tGOOS=darwin",
|
||||
"\tbuild\tGOARM64=v8.0",
|
||||
"\tbuild\tvcs=git",
|
||||
"\tbuild\tvcs.revision=5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"\tbuild\tvcs.time=2026-09-11T18:00:57Z",
|
||||
"\tbuild\tvcs.modified=false"
|
||||
]
|
||||
}
|
||||
},
|
||||
"archive_note": "Dependency listing and machine paths omitted. Actual version, module identity, build settings and original build-info hash retained. Binaries are not included."
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"command": [
|
||||
"make",
|
||||
"check"
|
||||
],
|
||||
"cwd": "$DOCS_WORKTREE",
|
||||
"exit_code": 0,
|
||||
"checked_content_commit": "9fa6248a0323838df5aed678b42c4679a7a6f8a4",
|
||||
"committed_after_check": true,
|
||||
"recorded_at": "2026-09-12T09:30:37.223220+00:00",
|
||||
"files_sha256": {
|
||||
"content/blog/design/bucket-metadata-convergence.zh.md": "26807bf8db555c3f0feef5c933b3d5a2da48fc7fd5a65da00d2c9b93ce7b4b21",
|
||||
"content/blog/design/bucket-metadata-convergence.md": "36bc5c7f2310d96447b8130db0f34a99e900d8f7fc6f33d90630b89085769eec"
|
||||
},
|
||||
"rendered_pages": {
|
||||
"en": 1207,
|
||||
"zh": 1219
|
||||
},
|
||||
"checks": [
|
||||
"go mod verify",
|
||||
"strict Hugo build",
|
||||
"internal links"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
go mod verify
|
||||
all modules verified
|
||||
hugo build --minify --cleanDestinationDir --printPathWarnings --printI18nWarnings --panicOnWarning
|
||||
Start building sites …
|
||||
hugo v0.165.0+extended+withdeploy darwin/arm64 BuildDate=2026-08-12T14:26:28Z VendorInfo=Homebrew
|
||||
|
||||
|
||||
│ EN │ ZH
|
||||
──────────────────┼──────┼──────
|
||||
Pages │ 1207 │ 1219
|
||||
Paginator pages │ 2 │ 2
|
||||
Non-page files │ 26 │ 0
|
||||
Static files │ 740 │ 740
|
||||
Processed images │ 8 │ 0
|
||||
Aliases │ 45 │ 45
|
||||
Cleaned │ 0 │ 0
|
||||
|
||||
Total in 19619 ms
|
||||
python3 bin/check_internal_links.py public
|
||||
internal link check passed: 485471 rendered internal references across 1429 HTML files
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"build",
|
||||
"./..."
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"started_at": "2026-09-12T09:11:30.915932+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$TMPDIR",
|
||||
"CGO_ENABLED": "0"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 24.193
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"command": ["go", "build", "./..."], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:30.915932+00:00", "environment": {"TMPDIR": "$TMPDIR", "CGO_ENABLED": "0"}}
|
||||
|
||||
EXIT=0
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"./cmd/",
|
||||
"-count=1",
|
||||
"-timeout",
|
||||
"30m"
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"started_at": "2026-09-12T09:11:30.892660+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$LAB_VOLUME/tmp",
|
||||
"CGO_ENABLED": "0",
|
||||
"MINIO_API_REQUESTS_MAX": "10000"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 518.124
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"command": ["go", "test", "./cmd/", "-count=1", "-timeout", "30m"], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:30.892660+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp", "CGO_ENABLED": "0", "MINIO_API_REQUESTS_MAX": "10000"}}
|
||||
ok github.com/minio/minio/cmd 492.776s
|
||||
|
||||
EXIT=0
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"command": [
|
||||
"make",
|
||||
"lint"
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
|
||||
"started_at": "2026-09-12T09:17:19.334284+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$TMPDIR"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 79.064
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{"command": ["make", "lint"], "cwd": "$SILO_WORKTREE", "head": "461e9a721047c63e1a95f54ad4b533a6b89def30", "started_at": "2026-09-12T09:17:19.334284+00:00", "environment": {"TMPDIR": "$TMPDIR"}}
|
||||
Running lint check
|
||||
0 issues.
|
||||
typos binary is not found.. skipping..
|
||||
|
||||
EXIT=0
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"run",
|
||||
".issue77-evidence/twosite/main.go",
|
||||
"$LAB_VOLUME/runtime-reviewed",
|
||||
".issue77-evidence/silo-reviewed",
|
||||
".issue77-evidence/silo-before"
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"started_at": "2026-09-12T09:12:41.365111+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$LAB_VOLUME/tmp"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 190.476
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{"command": ["go", "run", ".issue77-evidence/twosite/main.go", "$LAB_VOLUME/runtime-reviewed", ".issue77-evidence/silo-reviewed", ".issue77-evidence/silo-before"], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:12:41.365111+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp"}}
|
||||
2026-09-12T09:12:42Z site-a ready gate=on
|
||||
2026-09-12T09:12:43Z site-b ready gate=on
|
||||
2026-09-12T09:12:44Z historical bucket prepared: six live fields at Created
|
||||
2026-09-12T09:12:44Z baseline-initial-sync: waiting for ordinary 30-second heal
|
||||
2026-09-12T09:12:44Z baseline-initial-sync: six field states equal
|
||||
2026-09-12T09:12:44Z initial sync preserved six historical Created timestamps
|
||||
2026-09-12T09:12:44Z real local PUT outgoing replication RPC dropped by partition
|
||||
2026-09-12T09:12:45Z live-reconnect: waiting for ordinary 30-second heal
|
||||
2026-09-12T09:13:13Z live-reconnect: six field states equal
|
||||
2026-09-12T09:13:13Z live-steady: observing two heal intervals
|
||||
2026-09-12T09:14:18Z live-steady: zero metadata RPCs across 65 seconds
|
||||
2026-09-12T09:14:18Z six duplicate/out-of-order events rejected without clock changes
|
||||
2026-09-12T09:14:19Z real local DELETE outgoing replication RPC dropped by partition
|
||||
2026-09-12T09:14:20Z site-a ready gate=on
|
||||
2026-09-12T09:14:20Z four tombstones survived process restart
|
||||
2026-09-12T09:14:20Z delete-reconnect: waiting for ordinary 30-second heal
|
||||
2026-09-12T09:14:44Z delete-reconnect: six field states equal
|
||||
2026-09-12T09:14:44Z delete-steady: observing two heal intervals
|
||||
2026-09-12T09:15:49Z delete-steady: zero metadata RPCs across 65 seconds
|
||||
2026-09-12T09:15:49Z 40 exceptional events produced exactly one log per reason
|
||||
2026-09-12T09:15:49Z starting fixed/previous-server rolling-upgrade smoke with gate off
|
||||
2026-09-12T09:15:50Z site-a ready gate=off
|
||||
2026-09-12T09:15:51Z site-b ready gate=off
|
||||
2026-09-12T09:15:51Z mixed-version ordinary PUT/DELETE and off tombstone visibility passed
|
||||
2026-09-12T09:15:51Z PASS: isolated two-site implementation acceptance
|
||||
|
||||
EXIT=0
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-race",
|
||||
"./cmd/",
|
||||
"-run",
|
||||
"^(TestBucketMetadataInitialSyncPhysicalCreated|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics)$",
|
||||
"-count=1",
|
||||
"-timeout",
|
||||
"10m"
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
|
||||
"started_at": "2026-09-12T09:18:02.407600+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$LAB_VOLUME/tmp",
|
||||
"CGO_ENABLED": "1"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 45.681
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"command": ["go", "test", "-race", "./cmd/", "-run", "^(TestBucketMetadataInitialSyncPhysicalCreated|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics)$", "-count=1", "-timeout", "10m"], "cwd": "$SILO_WORKTREE", "head": "461e9a721047c63e1a95f54ad4b533a6b89def30", "started_at": "2026-09-12T09:18:02.407600+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp", "CGO_ENABLED": "1"}}
|
||||
ok github.com/minio/minio/cmd 4.540s
|
||||
|
||||
EXIT=0
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-race",
|
||||
"./cmd/",
|
||||
"-run",
|
||||
"^(TestBucketMetadataInitialSyncPhysicalCreated|TestPeerBucketMetadataPhysicalCreatedBoundary|TestPeerBucketMetadataUnknownCreated|TestPeerBucketAdoptionRebasesOnlyDefaults|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics|TestBucketMetadataPhysicalCreatedRecovery|TestBucketPolicyReplicationStatusLegacyOrder|TestHealBucketConfigSourceAndQuiescence|TestLatestBucketConfigCandidates|Test.*Cors.*|Test.*CORS.*)$",
|
||||
"-count=1",
|
||||
"-timeout",
|
||||
"15m"
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"started_at": "2026-09-12T09:11:30.873027+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$LAB_VOLUME/tmp",
|
||||
"CGO_ENABLED": "1"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 90.202
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"command": ["go", "test", "-race", "./cmd/", "-run", "^(TestBucketMetadataInitialSyncPhysicalCreated|TestPeerBucketMetadataPhysicalCreatedBoundary|TestPeerBucketMetadataUnknownCreated|TestPeerBucketAdoptionRebasesOnlyDefaults|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics|TestBucketMetadataPhysicalCreatedRecovery|TestBucketPolicyReplicationStatusLegacyOrder|TestHealBucketConfigSourceAndQuiescence|TestLatestBucketConfigCandidates|Test.*Cors.*|Test.*CORS.*)$", "-count=1", "-timeout", "15m"], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:30.873027+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp", "CGO_ENABLED": "1"}}
|
||||
ok github.com/minio/minio/cmd 20.903s
|
||||
|
||||
EXIT=0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user