mirror of
https://github.com/pgsty/minio.git
synced 2026-09-17 07:48:27 +03:00
docs: retire migrated working material and clarify documentation ownership
Remove migrated investigation and security documents, repair their incoming links, and align the English/Chinese READMEs with current module and release boundaries. Track AGENTS.md as the shared repository guide and make CLAUDE.md import it; keep working artifacts outside the repository. Publish pgsty/silo.pgsty.com commit c7682185e2832b981629e1c17aef74fc778c6ca1 before publishing this cleanup: the new documentation routes are not live yet. Validation: make rebrand-guard; 92 Markdown files checked for deletion-induced broken relative paths; 199 source-to-site references and anchors resolve in the paired site build; git diff --check. Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
+2
-2
@@ -62,10 +62,10 @@ dist/
|
||||
|
||||
.claude/
|
||||
.codex/
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
_bmad/
|
||||
_bmad-output/
|
||||
# Investigation and editorial working files belong outside this repository.
|
||||
docs/investigations/
|
||||
docs/security/
|
||||
docs/rebranding.md
|
||||
.release-sign/
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# SILO Repository Guide
|
||||
|
||||
## Project map
|
||||
|
||||
- Server: `pgsty/silo` (this checkout, default branch `main`)
|
||||
- Console: `pgsty/silo-console` (usual sibling checkout `../silo-console`)
|
||||
- Client: `pgsty/mc`, shipped as `mcli` (usual sibling checkout `../mc`)
|
||||
- Shared packages: `pgsty/silo-pkg` (usual sibling checkout `../silo-pkg`)
|
||||
- Documentation: `pgsty/silo.pgsty.com` (usual sibling checkout `../silo.pgsty.com`),
|
||||
published at <https://silo.pgsty.com/>
|
||||
|
||||
The server executable, package, systemd service, and container image use `silo`;
|
||||
the public Docker image is `docker.io/pgsty/silo`. The server module remains
|
||||
`github.com/minio/minio`. Consult the current `go.mod` and release configuration
|
||||
for selected component versions and compatibility replacements.
|
||||
|
||||
## Supported stack and compatibility policy
|
||||
|
||||
The maintained, release-gating product graph is the coordinated PGSTY stack:
|
||||
`silo` + `silo-console` + `mc` + `silo-pkg`.
|
||||
|
||||
Keep compatibility with upstream MinIO/MC on a best-effort basis. Preserve
|
||||
inexpensive wire, configuration, CLI, migration, and import compatibility when
|
||||
it helps users, and document known differences. Do not infer from source
|
||||
lineage, MinIO-compatible protocols, retained `MINIO_*`/`MC_*` names, or an
|
||||
inherited upstream test that unmodified upstream MinIO is a supported release
|
||||
target.
|
||||
|
||||
Upstream-only compatibility checks may remain as advisory evidence, but they
|
||||
must not force a downgrade of a maintained SILO component, a fork-only API
|
||||
workaround, or a release block. Make upstream compatibility a hard gate only
|
||||
when the user explicitly requests that scope.
|
||||
|
||||
## Dependency selection
|
||||
|
||||
- When PGSTY maintains a component, import and require it directly under its own
|
||||
module path. In particular, prefer `github.com/pgsty/silo-pkg/v3` over
|
||||
`github.com/minio/pkg/v3` in maintained SILO source.
|
||||
- Do not use an upstream package merely to make unmodified upstream MinIO or MC
|
||||
compile. Unavoidable transitive upstream modules should be documented and
|
||||
kept separate from the maintained product dependency.
|
||||
- `github.com/minio/minio-go/v7` is the explicit exception: use the verified
|
||||
upstream module/commit while it contains the required fixes; do not recreate
|
||||
a SILO fork without a concrete functional divergence.
|
||||
- Check the current `go.mod` before changing versions. Coordinate breaking
|
||||
import-path changes across package, client, Console, and server releases.
|
||||
|
||||
## Documentation and delivery
|
||||
|
||||
The companion site owns product, operations, migration, security-advisory,
|
||||
design, and release documentation. Maintain English and Chinese content in
|
||||
that repository and link to its canonical URLs from this one. The site's
|
||||
`content/docs/` is the documentation entry point; detailed pages live under
|
||||
`content/operations/`, `administration/`, `reference/`, `compatibility/`,
|
||||
`about/`, and `blog/`, following the existing structure.
|
||||
|
||||
Keep repository entry points and contributor instructions here. Retained
|
||||
upstream documents, examples, and test fixtures under `docs/` may be updated
|
||||
when a code or tooling change requires it; do not build a second documentation
|
||||
site in this tree.
|
||||
|
||||
Investigation plans, prompts, AI session transcripts, execution logs, temporary
|
||||
reports, and private security material belong outside the checkout, for example
|
||||
in a task-specific directory under `~/tmp/`. Do not recreate
|
||||
`docs/investigations/`, `docs/security/`, or `docs/rebranding.md`, or move their
|
||||
working material to another repository directory. Extract reusable, verified
|
||||
knowledge into the companion site; keep private evidence outside public Git.
|
||||
|
||||
Compatibility pages may continue to describe similarities with upstream, but
|
||||
must label that compatibility as best effort. The supported and tested server
|
||||
for Console and mcli administration is `pgsty/silo`.
|
||||
|
||||
Before removing or moving documentation, check repository links and scripts,
|
||||
the companion site's links and anchors, and references from this guide. Use
|
||||
fixed commit URLs for historical evidence that should survive deletion from
|
||||
the current tree. Run the site's `make check` for site changes and this
|
||||
repository's `make rebrand-guard` for documentation cleanup that changes the
|
||||
identifier inventory. When new public URLs are involved, publish the site
|
||||
before publishing source changes that depend on those URLs.
|
||||
|
||||
Keep changes in the repository that owns the affected surface. Treat every
|
||||
repository commit, tag, release, image, documentation update, and deployment as
|
||||
a separate deliverable. Follow `CONTRIBUTING.md`, including DCO sign-off
|
||||
(`git commit -s`). `CLAUDE.md` imports this guide so both agents use one policy.
|
||||
+3
-3
@@ -30,7 +30,7 @@ and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-0
|
||||
TLS handshake reads. The wrapper's strict header mode is not applied to HTTP/2.
|
||||
- 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).
|
||||
public Server is affected; the fix is on main. See [the advisory ledger](https://silo.pgsty.com/about/security-advisories/).
|
||||
- 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`
|
||||
@@ -97,7 +97,7 @@ and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-0
|
||||
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 [decision record](https://silo.pgsty.com/compatibility/access-tiering-removal/) 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.
|
||||
@@ -111,7 +111,7 @@ and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-0
|
||||
reflect actual work, 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).
|
||||
coordinated upgrades before tombstones are exported. See [the #77 record](https://silo.pgsty.com/blog/design/bucket-metadata-convergence/).
|
||||
- Include per-bucket CORS in metadata export/import, close metadata publication
|
||||
and logger races, and report effective bucket quotas in metrics.
|
||||
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ for their reports as well; the avatar wall and community total still count each
|
||||
| [@davinkevin](https://github.com/davinkevin) | [pgsty/silo#20](https://github.com/pgsty/silo/issues/20) Proposal: Enable Renovate for automated dependency updates |
|
||||
| [@cbornet](https://github.com/cbornet) | [pgsty/silo#31](https://github.com/pgsty/silo/issues/31) Multipart uploads with FULL_OBJECT CRC32 not working<br>[pgsty/silo#32](https://github.com/pgsty/silo/issues/32) `listObjects` should return `NoSuchBucket` when the bucket doesn't exist and prefix is passed<br>[pgsty/silo#107](https://github.com/pgsty/silo/issues/107) PutObject fails with chunked encoding and checksumType |
|
||||
| [@vampywiz17](https://github.com/vampywiz17) | [pgsty/silo#15](https://github.com/pgsty/silo/issues/15) LDAP TLS regression in RELEASE.2026-03-21T00-00-00Z breaks built-in Console and external Console LDAP login on Kubernetes Tenant<br>[pgsty/silo#108](https://github.com/pgsty/silo/issues/108) Web Console login regression in RELEASE.2026-09-03T13-18-01Z (local and LDAP users fail) |
|
||||
| [@orenyomtov](https://github.com/orenyomtov) | Private security disclosure: a presigned or signed PUT could be turned into a server-side CopyObject read of any object the signing key can reach via an unsigned `x-amz-copy-source` header. Fixed as [`SN-2026-011`](https://github.com/pgsty/silo/blob/main/docs/security/advisories.md) ([pgsty/silo#173](https://github.com/pgsty/silo/pull/173)) |
|
||||
| [@orenyomtov](https://github.com/orenyomtov) | Private security disclosure: a presigned or signed PUT could be turned into a server-side CopyObject read of any object the signing key can reach via an unsigned `x-amz-copy-source` header. Fixed as [`SN-2026-011`](https://silo.pgsty.com/about/security-advisories/#sn-2026-011) ([pgsty/silo#173](https://github.com/pgsty/silo/pull/173)) |
|
||||
| [@mumu-lab](https://github.com/mumu-lab) | [pgsty/silo#106](https://github.com/pgsty/silo/issues/106) 监控指标读取已弃用的 BucketQuota.Quota 字段导致 Quota 指标无值 |
|
||||
| [@jvasile](https://github.com/jvasile) | [pgsty/silo#33](https://github.com/pgsty/silo/issues/33) .deb doesn't create user/group/default files |
|
||||
| [@pmezhuev](https://github.com/pmezhuev) | [pgsty/silo#43](https://github.com/pgsty/silo/issues/43) RPM package for RELEASE.2026-06-18T00-00-00Z is missing GPG signature |
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
## 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
|
||||
As of 2026-09-16, 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.
|
||||
@@ -93,13 +93,16 @@ Every release ships checksums, SPDX SBOMs, Sigstore-signed manifests, and GitHub
|
||||
|
||||
## Compatibility
|
||||
|
||||
The S3 API, `MINIO_*` variables, `minio_*` metrics, `x-minio-*` headers, `/minio/*` routes, the `github.com/minio/*` import paths, and the on-disk format (including `.minio.sys`) are preserved and held in place by a CI compatibility check. Only Silo-owned delivery surfaces change: the `silo` executable, package, service, Helm chart, and container image — no `minio` binary alias is installed.
|
||||
Silo preserves S3 and storage-format compatibility, including existing `MINIO_*` variables, `minio_*` metrics, `x-minio-*` headers, `/minio/*` routes, and `.minio.sys` data. CI guards selected compatibility identifiers; release notes document intentional security and behavior changes. Silo-owned delivery surfaces use the `silo` executable, package, service, Helm chart, and container image; no `minio` server binary alias is installed.
|
||||
|
||||
The supported release stack is `pgsty/silo` + `pgsty/silo-console` + `pgsty/mc` + `pgsty/silo-pkg`; compatibility with unmodified upstream MinIO/MC is best effort. The Server, Console, and client retain their historical module paths where needed, while maintained code imports `github.com/pgsty/silo-pkg/v3` directly. The SDK `github.com/minio/minio-go/v7` is an explicit upstream dependency. See the current [go.mod](go.mod) for versions and replacements.
|
||||
|
||||
Every divergence from upstream is listed in the code-verified [compatibility audit](https://silo.pgsty.com/compatibility/server/). Treat each release as a downstream upgrade: pin versions, read the [release notes](https://silo.pgsty.com/tags/silo/), and keep a rollback path.
|
||||
|
||||
### TLS and Go upgrades
|
||||
|
||||
TLS key exchange follows Go's defaults across the S3 listener, node links,
|
||||
The following TLS repair is on main and is not included in Server 20260903.
|
||||
With that repair, TLS key exchange follows Go's defaults across the S3 listener, node links,
|
||||
replication, identity providers, etcd, and external HTTP services. If an endpoint
|
||||
cannot accept ML-KEM, `GODEBUG=tlsmlkem=0` disables the default hybrid exchanges
|
||||
for the process; certificate verification remains enabled. This option does not
|
||||
@@ -115,7 +118,16 @@ values to restore Keychain trust. Explicit certificates in the configured `CAs`
|
||||
directory remain additive to the selected root pool.
|
||||
Go 1.27 binaries require macOS 13 or later. See the
|
||||
[Go release notes](https://go.dev/doc/go1.27) and the
|
||||
[SILO stack investigation](docs/investigations/go127-stack.md).
|
||||
[Go 1.27 TLS and OIDC discovery guide](https://silo.pgsty.com/blog/design/go127-tls-oidc-discovery/).
|
||||
|
||||
## Documentation ownership
|
||||
|
||||
User documentation is maintained at [silo.pgsty.com](https://silo.pgsty.com/docs/),
|
||||
with source in [pgsty/silo.pgsty.com](https://github.com/pgsty/silo.pgsty.com).
|
||||
The remaining `docs/` tree contains inherited references, examples, and tooling
|
||||
fixtures. Investigation logs, AI work records, and temporary reports are kept
|
||||
outside this repository; reusable findings belong in the companion site.
|
||||
See [AGENTS.md](AGENTS.md) for repository ownership and maintenance rules.
|
||||
|
||||
## Security & Contributing
|
||||
|
||||
|
||||
+17
-2
@@ -38,7 +38,7 @@
|
||||
## 当前发行版与主分支
|
||||
|
||||
最新已发布的 Server 仍为 [20260903](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-09-03T13-18-01Z)。
|
||||
截至 2026-09-13,主分支已合入更新的安全、存储、Console 与共享包改动,但尚未发布新 Server。
|
||||
截至 2026-09-16,主分支已合入更新的安全、存储、Console 与共享包改动,但尚未发布新 Server。
|
||||
准确的已发布/源码边界见 [CHANGELOG.md](CHANGELOG.md) 与[组件版本矩阵](https://silo.pgsty.com/zh/compatibility/versions/),
|
||||
其中包括 SN-2026-011 修复状态与密码权限迁移要求。
|
||||
|
||||
@@ -92,10 +92,25 @@ docker exec silo mcli mb local/demo && docker exec silo mcli ls local
|
||||
|
||||
## 兼容性
|
||||
|
||||
S3 API、`MINIO_*` 环境变量、`minio_*` 指标、`x-minio-*` 头、`/minio/*` 路由、`github.com/minio/*` 导入路径与磁盘格式(含 `.minio.sys`)原样保留,并由 CI 兼容性门禁冻结。只有 Silo 自有交付面改名:`silo` 可执行文件、软件包、服务、Helm Chart 与容器镜像 —— 原生交付物不会安装 `minio` 二进制别名。
|
||||
Silo 保留 S3 与存储格式兼容性,包括既有 `MINIO_*` 环境变量、`minio_*` 指标、`x-minio-*` 头、`/minio/*` 路由与 `.minio.sys` 数据。CI 守卫检查选定的兼容性标识,有意的安全与行为变化在发布说明中记录。Silo 自有交付面使用 `silo` 可执行文件、软件包、服务、Helm Chart 与容器镜像;原生交付物不会安装 `minio` 服务端二进制别名。
|
||||
|
||||
正式支持和发布验收的组合为 `pgsty/silo` + `pgsty/silo-console` + `pgsty/mc` + `pgsty/silo-pkg`,对未修改的上游 MinIO/MC 尽最大努力保持兼容。Server、Console 与客户端按需保留历史模块路径,维护源码直接导入 `github.com/pgsty/silo-pkg/v3`;SDK `github.com/minio/minio-go/v7` 是明确保留的上游依赖。具体版本与 replace 以当前 [go.mod](go.mod) 为准。
|
||||
|
||||
与上游的全部分歧,以逐项核验代码的[兼容性审计](https://silo.pgsty.com/zh/compatibility/server/)形式维护。每个版本仍应视为下游升级:锁定版本,阅读[版本说明](https://silo.pgsty.com/zh/tags/silo/),并保留回滚路径。
|
||||
|
||||
### TLS 与 Go 升级
|
||||
|
||||
Server 恢复 Go 默认密钥交换策略的修复已在 main,尚未包含在 Server 20260903。
|
||||
`GODEBUG=tlsmlkem=0`、`tlssecpmlkem=0` 的适用范围、macOS 根证书来源变化,
|
||||
以及 OIDC discovery 的诊断方法见 [Go 1.27 TLS 与 OIDC 指南](https://silo.pgsty.com/zh/blog/design/go127-tls-oidc-discovery/)。
|
||||
|
||||
## 文档归属
|
||||
|
||||
用户文档统一维护在 [silo.pgsty.com](https://silo.pgsty.com/zh/docs/),源码位于
|
||||
[pgsty/silo.pgsty.com](https://github.com/pgsty/silo.pgsty.com)。本仓库保留的 `docs/`
|
||||
主要是继承的参考材料、示例和工具测试夹具。调查日志、AI 工作记录与临时报告放在仓库外;
|
||||
可复用的结论应整理进伴生文档站。仓库职责与维护规则见 [AGENTS.md](AGENTS.md)。
|
||||
|
||||
## 安全与贡献
|
||||
|
||||
请按照 [`SECURITY.md`](SECURITY.md) 私密报告漏洞;每项修复都会发布公开[安全公告](https://silo.pgsty.com/zh/blog/security/)。本项目不要求签署 CLA:贡献按 AGPL-3.0-or-later(inbound=outbound)接收,只需 DCO 签署(`git commit -s`),详见 [`CONTRIBUTING.md`](CONTRIBUTING.md)。
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ Silo-specific fixes or release notes.
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are tracked on the active development branch and summarized in
|
||||
[docs/security/advisories.md](docs/security/advisories.md). Only the current
|
||||
[the security advisory ledger](https://silo.pgsty.com/about/security-advisories/). Only the current
|
||||
Silo release line is supported unless an advisory says otherwise.
|
||||
|
||||
## Inherited Fix Evidence
|
||||
@@ -26,7 +26,7 @@ separately even when the fork preserves the original commit object and SHA.
|
||||
The inherited [service-account](https://github.com/pgsty/silo/blob/c1a49490c78e9c3ebcad86ba0662319138ace190/cmd/admin-handlers-users_test.go#L211-L212)
|
||||
and [STS](https://github.com/pgsty/silo/blob/c1a49490c78e9c3ebcad86ba0662319138ace190/cmd/sts-handlers_test.go#L45-L46)
|
||||
regression groups remain part of `go test ./cmd`; see the
|
||||
[canonical ledger](docs/security/advisories.md#inherited-upstream-advisory-baseline)
|
||||
[canonical ledger](https://silo.pgsty.com/about/security-advisories/#inherited)
|
||||
for the operator-facing record.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
@@ -42,4 +42,4 @@ For vulnerabilities in this fork:
|
||||
|
||||
## Disclosure Process
|
||||
|
||||
Fork-specific fixes and user-visible upgrade notes are published in [docs/security/advisories.md](docs/security/advisories.md). The fork-specific triage and remediation process is described in [VULNERABILITY_REPORT.md](VULNERABILITY_REPORT.md).
|
||||
Fork-specific fixes and user-visible upgrade notes are published in [the security advisory ledger](https://silo.pgsty.com/about/security-advisories/). The fork-specific triage and remediation process is described in [VULNERABILITY_REPORT.md](VULNERABILITY_REPORT.md).
|
||||
|
||||
@@ -34,4 +34,4 @@ Based on the report, the Silo maintainers investigate:
|
||||
If the vulnerability exists in this fork itself, the maintainers will, when
|
||||
feasible, fix the issue or implement reasonable countermeasures such that the
|
||||
vulnerability can no longer be exploited. Fork-specific upgrade notes and
|
||||
security advisories are published in `docs/security/advisories.md`.
|
||||
security advisories are published in [the security advisory ledger](https://silo.pgsty.com/about/security-advisories/).
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
"MINIO_API_DELETE_CLEANUP_INTERVAL",
|
||||
"MINIO_API_DISABLE_ODIRECT",
|
||||
"MINIO_API_GZIP_OBJECTS",
|
||||
"MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH",
|
||||
"MINIO_API_LIST_QUORUM",
|
||||
"MINIO_API_OBJECT_MAX_VERSIONS",
|
||||
"MINIO_API_ODIRECT",
|
||||
@@ -627,7 +626,6 @@
|
||||
"x-minio-origin-endpoint",
|
||||
"x-minio-prefixes-total",
|
||||
"x-minio-read-quorum",
|
||||
"x-minio-replication",
|
||||
"x-minio-replication-actual-object-size",
|
||||
"x-minio-replication-delete-status",
|
||||
"x-minio-replication-deletemarker-status",
|
||||
|
||||
@@ -115,9 +115,8 @@ func collect(repo string) (manifest, error) {
|
||||
fset := token.NewFileSet()
|
||||
|
||||
for _, rel := range files {
|
||||
// Investigation artifacts contain synthetic routes and archived configurations.
|
||||
// Migration notes and guard fixtures contain archived identifiers.
|
||||
if rel == "SILO_REBRANDING_MIGRATION.md" ||
|
||||
strings.HasPrefix(rel, "docs/investigations/") ||
|
||||
strings.HasPrefix(rel, "buildscripts/rebrand-guard/") ||
|
||||
strings.HasPrefix(rel, "buildscripts/helm-migration-guard/") {
|
||||
continue
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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 [introduction and rollback record](https://silo.pgsty.com/compatibility/access-tiering-removal/) 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.
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
# 访问频率分层:引入、修复与回退记录
|
||||
|
||||
记录日期: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 账本、诊断补丁和完整卷归档。原失败现场、后续对照及恢复后的卷分别标注时点。临时实验资源在归档验证后清理;这些资料与正式发布制品区分管理。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,190 +0,0 @@
|
||||
# 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
|
||||
validation performed before commit. No pushes, issue comments, releases, or
|
||||
production changes were made during the audit.
|
||||
|
||||
## Findings and changes
|
||||
|
||||
| Component | Finding | Change |
|
||||
| --- | --- | --- |
|
||||
| Server, baseline `d1105bbb3d4a0afa33b3a4ac11b821235038ed0e` | Eight TLS configuration sites explicitly use the same ML-KEM-containing curve list, overriding `tlsmlkem=0` in Go 1.27. | Remove the eight assignments and obsolete `TLSCurveIDs` helper. Retain Go defaults in HTTP clients, replication, cloud client-certificate transport, both grid links, etcd, and the S3 listener. Add wire-level regression tests. |
|
||||
| silo-pkg, baseline `a92c54d` | No built-in explicit PQ curve list. Web environment transport uses defaults; LDAP/OIDC accept caller configuration. | Add a real web-environment TLS regression test and document runtime-default selection. Keep the Go 1.26 library floor. |
|
||||
| mcli, baseline `fcd5cad8` | S3/Admin transport and alias/TOFU dialer use default curves. No instance of the Server's override bug found. | Add real S3/Admin transport and alias-dialer handshake tests; update Go/TLS upgrade notes. |
|
||||
| Console, baseline `c103d08ec` | IdP, SILO/STS, Prometheus, and webhook clients share a transport using defaults. The HTTPS listener explicitly uses only P-256. | Add real IdP/SILO-client handshake tests; update Go/TLS upgrade notes. Retain the existing listener policy. |
|
||||
|
||||
The Server's general external HTTP transport is also used by OpenID discovery
|
||||
and JWKS, identity plugins, notification/lambda checks, audit/log webhooks, and
|
||||
S3 cloud backends. Fixing only the two OIDC callers would leave those other paths
|
||||
affected. The broader correction supersedes the earlier OIDC-only candidate.
|
||||
|
||||
The retained defaults follow Go's implementation rather than duplicating its
|
||||
`GODEBUG` parser. Go 1.27 explicitly changed the interaction between manually
|
||||
selected curves and the `tlsmlkem`/`tlssecpmlkem` default controls. See the
|
||||
[Go TLS release notes](https://go.dev/doc/go1.27#crypto/tls).
|
||||
|
||||
With no opt-out, default curves additionally include SecP256r1MLKEM768 and
|
||||
SecP384r1MLKEM1024. With `GODEBUG=tlsmlkem=0`, hybrid exchanges are disabled for
|
||||
default-configured TLS throughout the process. This is an intentional change
|
||||
from the old fixed subset. `GODEBUG=tlssecpmlkem=0` disables just the SecP
|
||||
hybrids while retaining X25519MLKEM768. TLS versions, cipher-suite policy,
|
||||
certificate and hostname checks, client certificates, proxies, and HTTP/2 choices are not
|
||||
relaxed by this patch. No automatic fallback after a TLS error is introduced.
|
||||
|
||||
## Reproduction and regression evidence
|
||||
|
||||
Before changing product code, the new Server test failed for all five tested
|
||||
outbound constructors with `tlsmlkem=0`: general external HTTP, internode HTTP,
|
||||
replication, cloud client certificates, and etcd. The server observed
|
||||
`[X25519MLKEM768 X25519 P256 P384 P521]` in every case. Both TLS 1.2 and TLS 1.3
|
||||
peers reproduced the problem. The inbound Server also accepted a PQ-only client
|
||||
despite the same opt-out.
|
||||
|
||||
After the change, those tests pass on darwin/arm64 and linux/arm64. They exercise
|
||||
20 outbound combinations (five constructors, two peer TLS versions, two debug
|
||||
settings), plus inbound classical/PQ-only peers with the opt-out enabled and
|
||||
disabled. The inbound opt-out case rejects a PQ-only peer while continuing to
|
||||
accept P-256. The etcd test exercises its actual TLS configuration, not an etcd
|
||||
cluster; the client-certificate test exercises construction/loading, not a full
|
||||
mutual-authentication service.
|
||||
|
||||
The mc, Console, and silo-pkg handshake tests pass without product code changes.
|
||||
All use a trusted synthetic certificate and inspect a real ClientHello. They
|
||||
check both disabling and retaining ML-KEM. Console also retains its existing
|
||||
unknown-CA, hostname, and endpoint-scoping regression checks.
|
||||
|
||||
A freshly source-built complete Linux Server then passed three isolated
|
||||
integration scenarios using the existing synthetic IdP fixture:
|
||||
|
||||
| Scenario | Result |
|
||||
| --- | --- |
|
||||
| ML-KEM-intolerant IdP + `tlsmlkem=0` | Discovery/JWKS, IAM, Console, and authenticated Admin calls succeed; login 204 and bucket list 200. |
|
||||
| Normal TLS 1.3 IdP, no opt-out | Same successful login, token exchange, STS, session, and bucket-list chain. |
|
||||
| Add OIDC to a running Server | Actual Admin API accepts the provider under the compatibility setting. |
|
||||
|
||||
Both login scenarios reject modified JWT signatures and a wrong audience: no
|
||||
session cookie is issued and authenticated bucket access returns 403. Login
|
||||
currently reports these authentication failures as 500; that existing error
|
||||
mapping is outside this TLS change. Curl in the same namespace returns HTTP/2
|
||||
200. The containers use a pre-existing generic Debian image, `--network none`,
|
||||
and no published ports; no Server or Console image was downloaded or used.
|
||||
|
||||
The fixture drives real Console HTTP APIs, not rendered browser interaction.
|
||||
This is a conditional interoperability reproduction, not proof of the actual
|
||||
customer ingress behavior. Grid's existing tests pass, but a production-style
|
||||
distributed TLS cluster, external cloud providers, and real etcd/LDAP/Keycloak
|
||||
deployments were not exercised. Disabling ML-KEM does not disable new ML-DSA
|
||||
signature offers and does not repair an ingress that rejects those offers.
|
||||
|
||||
## Other Go changes checked
|
||||
|
||||
### macOS root certificates: a confirmed upgrade-visible change
|
||||
|
||||
Using the same public synthetic CA and `certs.GetRootCAs`, fresh-process probes
|
||||
produce the following results when `SSL_CERT_FILE` points at that CA and
|
||||
`SSL_CERT_DIR` points at an empty directory:
|
||||
|
||||
| Compiler / main module | CA from environment trusted? | Explicit CA argument trusted? |
|
||||
| --- | --- | --- |
|
||||
| Go 1.26.5 / `go 1.26.0` | No | Yes |
|
||||
| Go 1.27.1 / `go 1.26.0` | No | Yes |
|
||||
| Go 1.27.1 / `go 1.27.1` | Yes | Yes |
|
||||
|
||||
The Go 1.26 module built by Go 1.27 carries
|
||||
`DefaultGODEBUG=...x509sslcertoverrideplatform=0`; explicitly setting that option
|
||||
to `1` enables the new behavior. A Go 1.27 application can explicitly set it to
|
||||
`0` to recover the previous platform behavior. The diagnostic source is
|
||||
[cert-roots.go](issue-154/cert-roots.go). The consuming application's defaults
|
||||
apply to library calls as well, so silo-pkg's older `go` directive does not
|
||||
prevent the behavior in Server, mc, or Console.
|
||||
|
||||
This is expected standard-library behavior, not a reason to silently discard
|
||||
configured CA variables or skip verification. Setting either variable replaces
|
||||
Keychain trust with on-disk roots and Go's verifier; stale or incomplete paths
|
||||
can break previously trusted connections. Unset inherited variables to restore
|
||||
Keychain trust; explicit additional CAs still work. The three application
|
||||
READMEs and the package README now document this. The package's Windows loader enumerates
|
||||
the native ROOT store directly and does not call `SystemCertPool`; it does not
|
||||
inherit this particular new setting. Windows behavior was reviewed in source,
|
||||
not runtime-tested.
|
||||
|
||||
### JSON, HTTP, timers, and other compatibility controls
|
||||
|
||||
- **JSON:** checked owned JSON error handling and exercised policy/condition,
|
||||
config, and authentication tests. `quick` uses typed `SyntaxError` and
|
||||
`UnmarshalTypeError`; no owned decision depending on changed standard JSON
|
||||
error text was found. silo-pkg's full suite passes with both Go compilers;
|
||||
mc's command and Console's API/auth suites pass under Go 1.27. No broad
|
||||
`nojsonv2` opt-out or serialization rewrite is justified by these results.
|
||||
- **HTTP response closing:** Go 1.27's standard-library drain is bounded at
|
||||
256 KiB and 50 ms. mc's actual early-close/cancel regression passes, including
|
||||
the compressed S3 Select stream. Existing owned drain helpers can still have
|
||||
independent timeout concerns; those are not newly caused by this Go change.
|
||||
- **ALPN and custom connections:** mc's TLS dialer returns a real `*tls.Conn`;
|
||||
its deadline wrapper is on the TCP dial path. No new accidental HTTP/2 opt-in
|
||||
from the expanded `ConnectionState` interface support was identified in
|
||||
these transports. Console keeps its existing transport policy.
|
||||
- **Removed switches:** no maintained runtime/config reliance on `tlsrsakex`,
|
||||
`tls3des`, `tls10server`, `x509keypairleaf`, or `asynctimerchan` was found.
|
||||
Existing explicit cipher policies continue to be explicit. Timer uses in
|
||||
package certificate reload and license refresh do not depend on buffered
|
||||
timer-channel length/capacity. Unix EOF error changes do not expose a matching
|
||||
owned error-type assumption on the reviewed Unix-socket paths.
|
||||
- **Platform floor:** application READMEs now identify macOS 13 as the minimum
|
||||
for Go 1.27 binaries. No Windows, old macOS, or PowerPC runtime claim is made.
|
||||
|
||||
## Validation and delivery
|
||||
|
||||
- Server: focused TLS tests on macOS and Linux; macOS race run; crypto, HTTP,
|
||||
grid, and all `internal/config/...` tests; full Linux Server build and the
|
||||
three integration scenarios above.
|
||||
- silo-pkg: `make test` (lint and full race suite) on Go 1.27.1; full suite on
|
||||
Go 1.26.5, preserving the library floor.
|
||||
- mc: complete `./cmd` suite, including the new TLS test and existing S3 Select
|
||||
early-cancel test.
|
||||
- Console: complete `./api/... ./pkg/...` suites, including identity and STS
|
||||
validation and the new handshake tests.
|
||||
|
||||
Go lint reports zero issues in all four repositories. Server's optional spelling
|
||||
check is skipped because `typos` is not installed. Its lint target used the
|
||||
already-installed matching golangci-lint v2.13.1 after a redundant download was
|
||||
stopped; mc's lint was rerun serially after the global linter lock prevented the
|
||||
first concurrent attempt. These tooling retries did not require source changes.
|
||||
|
||||
An independent Fable 5.1 Max review reproduced the old-code failures and ran the
|
||||
new TLS tests with the race detector. Its shuffled mc and Console suites passed.
|
||||
One shuffled silo-pkg run failed the untouched `certs.TestValidPairAfterWrite`;
|
||||
that test passed three plain reruns, the same shuffle seed, and the full certs
|
||||
package rerun. The certs package runs in a separate test binary from the changed
|
||||
env tests; this was classified as an existing timing flake.
|
||||
|
||||
The review also found that committing the diagnostic fixture made rebrand CI
|
||||
count synthetic IdP paths as product routes. The guard now excludes
|
||||
`docs/investigations/`; the product compatibility baseline is unchanged.
|
||||
The full proposed file set passes the guard, and a negative control adding a
|
||||
product route still fails. After the review corrections, a fresh Linux Server
|
||||
build passed all three integration scenarios again; the new binary identity and
|
||||
rerun results are recorded in the evidence file's `post_review_validation`.
|
||||
|
||||
All source and dependency choices remain those of the maintained PGSTY stack.
|
||||
`go.mod` and `go.sum` are unchanged in every repository. The Server uses its
|
||||
existing pinned Console/pkg/mc modules; the companion changes are tests and
|
||||
documentation, so no replacement graph or unpublished dependency version is
|
||||
needed to build the runtime fix.
|
||||
|
||||
Validation used separate worktrees for Server, silo-pkg, mc, and Console, leaving
|
||||
the original repositories and their `main` branches untouched. Build identities,
|
||||
fixture results, and command outcomes are recorded in
|
||||
[go127-stack-evidence.json](go127-stack-evidence.json). Absolute paths and branch
|
||||
names in the captured evidence identify the environment at recording time.
|
||||
The recorded module graph identifies the dependencies selected for those
|
||||
builds; `go.sum` also retains checksums for unselected versions. Local image IDs
|
||||
and temporary paths are historical evidence, not portable setup instructions.
|
||||
@@ -1,534 +0,0 @@
|
||||
{
|
||||
"date": "2026-09-11",
|
||||
"runs": [
|
||||
{
|
||||
"runid": "silo-v1-0806-9c53f14c",
|
||||
"version": "0806",
|
||||
"image": "sha256:29a498b24669cae1fed11c1a2fb2b3d73c68829a0a9c0b14e71b386671d38fac",
|
||||
"nodes": 4,
|
||||
"drives": 4,
|
||||
"filesystem": "Linux tmpfs named volumes, held mounted across server restarts",
|
||||
"drive_bytes": 268435456,
|
||||
"status": "PASS",
|
||||
"phases": [
|
||||
{
|
||||
"phase": "startup-admin",
|
||||
"seconds_from_start": 4.157,
|
||||
"first_online_by_coordinator": [
|
||||
4.037,
|
||||
4.078,
|
||||
4.118,
|
||||
3.737
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "startup-canary",
|
||||
"attempts": 1,
|
||||
"puts": 4,
|
||||
"gets": 16,
|
||||
"hard_deadline_seconds": 60,
|
||||
"gate_seconds": 0.239,
|
||||
"seconds_from_start": 4.396,
|
||||
"first_put_seconds_after_admin_by_coordinator": [
|
||||
0.096,
|
||||
0.116,
|
||||
0.127,
|
||||
0.138
|
||||
],
|
||||
"first_four_reads_seconds_after_admin_by_coordinator": [
|
||||
0.162,
|
||||
0.194,
|
||||
0.217,
|
||||
0.239
|
||||
],
|
||||
"transient_error_count": 0,
|
||||
"first_transient_errors": []
|
||||
},
|
||||
{
|
||||
"phase": "full-restart-admin",
|
||||
"seconds_from_start": 2.084,
|
||||
"first_online_by_coordinator": [
|
||||
1.937,
|
||||
1.976,
|
||||
2.016,
|
||||
1.643
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "full-restart-canary",
|
||||
"attempts": 45,
|
||||
"puts": 4,
|
||||
"gets": 16,
|
||||
"hard_deadline_seconds": 60,
|
||||
"gate_seconds": 14.449,
|
||||
"seconds_from_start": 16.534,
|
||||
"first_put_seconds_after_admin_by_coordinator": [
|
||||
14.315,
|
||||
13.573,
|
||||
13.953,
|
||||
0.05
|
||||
],
|
||||
"first_four_reads_seconds_after_admin_by_coordinator": [
|
||||
14.373,
|
||||
14.396,
|
||||
14.426,
|
||||
14.449
|
||||
],
|
||||
"transient_error_count": 129,
|
||||
"first_transient_errors": [
|
||||
{
|
||||
"attempt": 1,
|
||||
"operation": "put",
|
||||
"node": 0,
|
||||
"error": "An error occurred (SlowDownWrite) when calling the PutObject operation (reached max retries: 0): Resource requested is unwritable, please reduce your request rate"
|
||||
},
|
||||
{
|
||||
"attempt": 1,
|
||||
"operation": "put",
|
||||
"node": 1,
|
||||
"error": "An error occurred (SlowDownWrite) when calling the PutObject operation (reached max retries: 0): Resource requested is unwritable, please reduce your request rate"
|
||||
},
|
||||
{
|
||||
"attempt": 1,
|
||||
"operation": "put",
|
||||
"node": 2,
|
||||
"error": "An error occurred (SlowDownWrite) when calling the PutObject operation (reached max retries: 0): Resource requested is unwritable, please reduce your request rate"
|
||||
},
|
||||
{
|
||||
"attempt": 2,
|
||||
"operation": "put",
|
||||
"node": 0,
|
||||
"error": "An error occurred (SlowDownWrite) when calling the PutObject operation (reached max retries: 0): Resource requested is unwritable, please reduce your request rate"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "readback-15s",
|
||||
"seconds_after_canary": 17.596,
|
||||
"objects": 55,
|
||||
"reads": 220,
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"phase": "readback-30s",
|
||||
"seconds_after_canary": 31.357,
|
||||
"objects": 55,
|
||||
"reads": 220,
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"phase": "readback-60s",
|
||||
"seconds_after_canary": 61.566,
|
||||
"objects": 55,
|
||||
"reads": 220,
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"phase": "one-node-outage",
|
||||
"existing_read": true,
|
||||
"put": true,
|
||||
"readers": 3
|
||||
},
|
||||
{
|
||||
"phase": "rejoin-admin",
|
||||
"seconds_from_start": 1.382,
|
||||
"first_online_by_coordinator": [
|
||||
1.27,
|
||||
1.307,
|
||||
1.343,
|
||||
1.382
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "rejoin-canary",
|
||||
"attempts": 1,
|
||||
"puts": 4,
|
||||
"gets": 16,
|
||||
"hard_deadline_seconds": 60,
|
||||
"gate_seconds": 0.158,
|
||||
"seconds_from_start": 1.541,
|
||||
"first_put_seconds_after_admin_by_coordinator": [
|
||||
0.013,
|
||||
0.029,
|
||||
0.039,
|
||||
0.05
|
||||
],
|
||||
"first_four_reads_seconds_after_admin_by_coordinator": [
|
||||
0.077,
|
||||
0.102,
|
||||
0.132,
|
||||
0.158
|
||||
],
|
||||
"transient_error_count": 0,
|
||||
"first_transient_errors": []
|
||||
},
|
||||
{
|
||||
"phase": "final-readback",
|
||||
"seconds_after_canary": 1.71,
|
||||
"objects": 60,
|
||||
"reads": 240,
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"acknowledged_objects": 60,
|
||||
"version_ids_recorded": true,
|
||||
"sha256_recorded": true
|
||||
},
|
||||
{
|
||||
"runid": "silo-v1-0903-a457573c",
|
||||
"version": "0903",
|
||||
"image": "sha256:b616a0cf8cb281e7e6bb3c9b1fb53875b4016a2878223925541c18f82d6c5ca3",
|
||||
"nodes": 4,
|
||||
"drives": 4,
|
||||
"filesystem": "Linux tmpfs named volumes, held mounted across server restarts",
|
||||
"drive_bytes": 268435456,
|
||||
"status": "PASS",
|
||||
"phases": [
|
||||
{
|
||||
"phase": "startup-admin",
|
||||
"seconds_from_start": 4.271,
|
||||
"first_online_by_coordinator": [
|
||||
4.158,
|
||||
4.194,
|
||||
3.827,
|
||||
3.867
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "startup-canary",
|
||||
"attempts": 1,
|
||||
"puts": 4,
|
||||
"gets": 16,
|
||||
"hard_deadline_seconds": 60,
|
||||
"gate_seconds": 0.2,
|
||||
"seconds_from_start": 4.471,
|
||||
"first_put_seconds_after_admin_by_coordinator": [
|
||||
0.054,
|
||||
0.068,
|
||||
0.081,
|
||||
0.092
|
||||
],
|
||||
"first_four_reads_seconds_after_admin_by_coordinator": [
|
||||
0.118,
|
||||
0.146,
|
||||
0.167,
|
||||
0.2
|
||||
],
|
||||
"transient_error_count": 0,
|
||||
"first_transient_errors": []
|
||||
},
|
||||
{
|
||||
"phase": "full-restart-admin",
|
||||
"seconds_from_start": 2.284,
|
||||
"first_online_by_coordinator": [
|
||||
2.143,
|
||||
2.193,
|
||||
0.849,
|
||||
0.902
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "full-restart-canary",
|
||||
"attempts": 1,
|
||||
"puts": 4,
|
||||
"gets": 16,
|
||||
"hard_deadline_seconds": 60,
|
||||
"gate_seconds": 0.191,
|
||||
"seconds_from_start": 2.476,
|
||||
"first_put_seconds_after_admin_by_coordinator": [
|
||||
0.016,
|
||||
0.029,
|
||||
0.044,
|
||||
0.057
|
||||
],
|
||||
"first_four_reads_seconds_after_admin_by_coordinator": [
|
||||
0.093,
|
||||
0.124,
|
||||
0.158,
|
||||
0.192
|
||||
],
|
||||
"transient_error_count": 0,
|
||||
"first_transient_errors": []
|
||||
},
|
||||
{
|
||||
"phase": "readback-15s",
|
||||
"seconds_after_canary": 15.235,
|
||||
"objects": 8,
|
||||
"reads": 32,
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"phase": "readback-30s",
|
||||
"seconds_after_canary": 30.356,
|
||||
"objects": 8,
|
||||
"reads": 32,
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"phase": "readback-60s",
|
||||
"seconds_after_canary": 60.192,
|
||||
"objects": 8,
|
||||
"reads": 32,
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"phase": "one-node-outage",
|
||||
"existing_read": true,
|
||||
"put": true,
|
||||
"readers": 3
|
||||
},
|
||||
{
|
||||
"phase": "rejoin-admin",
|
||||
"seconds_from_start": 0.903,
|
||||
"first_online_by_coordinator": [
|
||||
0.791,
|
||||
0.829,
|
||||
0.865,
|
||||
0.903
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "rejoin-canary",
|
||||
"attempts": 1,
|
||||
"puts": 4,
|
||||
"gets": 16,
|
||||
"hard_deadline_seconds": 60,
|
||||
"gate_seconds": 0.141,
|
||||
"seconds_from_start": 1.044,
|
||||
"first_put_seconds_after_admin_by_coordinator": [
|
||||
0.012,
|
||||
0.022,
|
||||
0.035,
|
||||
0.045
|
||||
],
|
||||
"first_four_reads_seconds_after_admin_by_coordinator": [
|
||||
0.069,
|
||||
0.096,
|
||||
0.12,
|
||||
0.141
|
||||
],
|
||||
"transient_error_count": 0,
|
||||
"first_transient_errors": []
|
||||
},
|
||||
{
|
||||
"phase": "final-readback",
|
||||
"seconds_after_canary": 0.301,
|
||||
"objects": 13,
|
||||
"reads": 52,
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"acknowledged_objects": 13,
|
||||
"version_ids_recorded": true,
|
||||
"sha256_recorded": true
|
||||
},
|
||||
{
|
||||
"runid": "silo-v1-current-6a14eb18",
|
||||
"version": "current",
|
||||
"image": "pgsty/d12a:build",
|
||||
"image_id": "sha256:307af7711e2e04ab75759cb42a1eef45c43c4404894c0e30dd19f742b107b922",
|
||||
"platform": "linux/arm64",
|
||||
"nodes": 4,
|
||||
"drives": 4,
|
||||
"filesystem": "Linux tmpfs named volumes, held mounted across server restarts",
|
||||
"drive_bytes": 268435456,
|
||||
"status": "PASS",
|
||||
"binary_sha256": "1e4cd7b78ecfa1b0cf28f1961d48a220a712c6be1fd3a01b60ee99aec62f04a4",
|
||||
"source_revision": "b32f2d9dd01a383a9991d34ffe248063463a031d+pool-consistency",
|
||||
"phases": [
|
||||
{
|
||||
"phase": "startup-admin",
|
||||
"seconds_from_start": 7.374,
|
||||
"first_online_by_coordinator": [
|
||||
7.207,
|
||||
6.677,
|
||||
6.74,
|
||||
6.8
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "startup-canary",
|
||||
"attempts": 1,
|
||||
"puts": 4,
|
||||
"gets": 16,
|
||||
"hard_deadline_seconds": 60,
|
||||
"gate_seconds": 0.479,
|
||||
"seconds_from_start": 7.854,
|
||||
"first_put_seconds_after_admin_by_coordinator": [
|
||||
0.266,
|
||||
0.279,
|
||||
0.292,
|
||||
0.305
|
||||
],
|
||||
"first_four_reads_seconds_after_admin_by_coordinator": [
|
||||
0.336,
|
||||
0.378,
|
||||
0.431,
|
||||
0.479
|
||||
],
|
||||
"transient_error_count": 0,
|
||||
"first_transient_errors": []
|
||||
},
|
||||
{
|
||||
"phase": "full-restart-admin",
|
||||
"seconds_from_start": 2.461,
|
||||
"first_online_by_coordinator": [
|
||||
2.264,
|
||||
1.414,
|
||||
2.396,
|
||||
2.461
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "full-restart-canary",
|
||||
"attempts": 36,
|
||||
"puts": 4,
|
||||
"gets": 16,
|
||||
"hard_deadline_seconds": 60,
|
||||
"gate_seconds": 14.489,
|
||||
"seconds_from_start": 16.951,
|
||||
"first_put_seconds_after_admin_by_coordinator": [
|
||||
0.013,
|
||||
0.025,
|
||||
14.301,
|
||||
0.046
|
||||
],
|
||||
"first_four_reads_seconds_after_admin_by_coordinator": [
|
||||
14.358,
|
||||
14.394,
|
||||
14.435,
|
||||
14.489
|
||||
],
|
||||
"transient_error_count": 104,
|
||||
"first_transient_errors": [
|
||||
{
|
||||
"attempt": 1,
|
||||
"operation": "put",
|
||||
"node": 2,
|
||||
"error": "An error occurred (SlowDownWrite) when calling the PutObject operation (reached max retries: 0): Resource requested is unwritable, please reduce your request rate"
|
||||
},
|
||||
{
|
||||
"attempt": 1,
|
||||
"operation": "get",
|
||||
"node": 2,
|
||||
"key": "full-restart-attempt-1-node-0",
|
||||
"error": "An error occurred (SlowDownRead) when calling the GetObject operation (reached max retries: 0): Resource requested is unreadable, please reduce your request rate"
|
||||
},
|
||||
{
|
||||
"attempt": 1,
|
||||
"operation": "get",
|
||||
"node": 2,
|
||||
"key": "full-restart-attempt-1-node-3",
|
||||
"error": "An error occurred (SlowDownRead) when calling the GetObject operation (reached max retries: 0): Resource requested is unreadable, please reduce your request rate"
|
||||
},
|
||||
{
|
||||
"attempt": 2,
|
||||
"operation": "put",
|
||||
"node": 2,
|
||||
"error": "An error occurred (SlowDownWrite) when calling the PutObject operation (reached max retries: 0): Resource requested is unwritable, please reduce your request rate"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "readback-15s",
|
||||
"started_seconds_after_canary": 15.0,
|
||||
"seconds_after_canary": 22.006,
|
||||
"objects": 113,
|
||||
"reads": 452,
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"phase": "readback-30s",
|
||||
"started_seconds_after_canary": 30.003,
|
||||
"seconds_after_canary": 36.571,
|
||||
"objects": 113,
|
||||
"reads": 452,
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"phase": "readback-60s",
|
||||
"started_seconds_after_canary": 60.0,
|
||||
"seconds_after_canary": 65.743,
|
||||
"objects": 113,
|
||||
"reads": 452,
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"phase": "one-node-outage",
|
||||
"existing_read": true,
|
||||
"put": true,
|
||||
"readers": 3
|
||||
},
|
||||
{
|
||||
"phase": "rejoin-admin",
|
||||
"seconds_from_start": 2.49,
|
||||
"first_online_by_coordinator": [
|
||||
1.165,
|
||||
2.417,
|
||||
1.646,
|
||||
2.087
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "rejoin-canary",
|
||||
"attempts": 37,
|
||||
"puts": 4,
|
||||
"gets": 16,
|
||||
"hard_deadline_seconds": 60,
|
||||
"gate_seconds": 13.977,
|
||||
"seconds_from_start": 16.468,
|
||||
"first_put_seconds_after_admin_by_coordinator": [
|
||||
0.013,
|
||||
0.024,
|
||||
0.035,
|
||||
13.889
|
||||
],
|
||||
"first_four_reads_seconds_after_admin_by_coordinator": [
|
||||
13.909,
|
||||
13.927,
|
||||
13.953,
|
||||
13.977
|
||||
],
|
||||
"transient_error_count": 36,
|
||||
"first_transient_errors": [
|
||||
{
|
||||
"attempt": 1,
|
||||
"operation": "put",
|
||||
"node": 3,
|
||||
"error": "An error occurred (SlowDownWrite) when calling the PutObject operation (reached max retries: 0): Resource requested is unwritable, please reduce your request rate"
|
||||
},
|
||||
{
|
||||
"attempt": 2,
|
||||
"operation": "put",
|
||||
"node": 3,
|
||||
"error": "An error occurred (SlowDownWrite) when calling the PutObject operation (reached max retries: 0): Resource requested is unwritable, please reduce your request rate"
|
||||
},
|
||||
{
|
||||
"attempt": 3,
|
||||
"operation": "put",
|
||||
"node": 3,
|
||||
"error": "An error occurred (SlowDownWrite) when calling the PutObject operation (reached max retries: 0): Resource requested is unwritable, please reduce your request rate"
|
||||
},
|
||||
{
|
||||
"attempt": 4,
|
||||
"operation": "put",
|
||||
"node": 3,
|
||||
"error": "An error occurred (SlowDownWrite) when calling the PutObject operation (reached max retries: 0): Resource requested is unwritable, please reduce your request rate"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "final-readback",
|
||||
"started_seconds_after_canary": 0.0,
|
||||
"seconds_after_canary": 6.507,
|
||||
"objects": 226,
|
||||
"reads": 904,
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"acknowledged_objects": 226,
|
||||
"version_ids_recorded": true,
|
||||
"sha256_recorded": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded four-container restart/readback acceptance for pgsty/silo#116."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
ROOT = None
|
||||
MCLI = None
|
||||
CURRENT_BINARY = None
|
||||
SOURCE_REVISION = None
|
||||
IMAGES = {
|
||||
'0806': 'pgsty/silo:RELEASE.2026-08-06T00-00-00Z',
|
||||
'0903': 'pgsty/silo:RELEASE.2026-09-03T13-18-01Z',
|
||||
'current': 'pgsty/d12a:build',
|
||||
}
|
||||
|
||||
|
||||
def docker(*args, timeout=40, check=True):
|
||||
return subprocess.run(['docker', *args], check=check, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
class Deadline(BaseException):
|
||||
pass
|
||||
|
||||
|
||||
def bounded(seconds, operation):
|
||||
def expired(*_):
|
||||
raise Deadline(f'hard deadline of {seconds}s exceeded')
|
||||
old = signal.signal(signal.SIGALRM, expired)
|
||||
signal.setitimer(signal.ITIMER_REAL, seconds)
|
||||
try:
|
||||
return operation(time.monotonic() + seconds)
|
||||
finally:
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
signal.signal(signal.SIGALRM, old)
|
||||
|
||||
|
||||
def remaining(deadline):
|
||||
value = deadline - time.monotonic()
|
||||
if value <= 0:
|
||||
raise Deadline('absolute deadline exceeded')
|
||||
return value
|
||||
|
||||
|
||||
def run(version):
|
||||
image_info = json.loads(docker('image', 'inspect', IMAGES[version]).stdout)[0]
|
||||
runid = f'silo-v1-{version}-{uuid.uuid4().hex[:8]}'
|
||||
out = ROOT / runid
|
||||
out.mkdir(mode=0o700)
|
||||
user, password = 'local116', secrets.token_urlsafe(24)
|
||||
envfile = out / 'credentials.env'
|
||||
envfile.write_text(f'MINIO_ROOT_USER={user}\nMINIO_ROOT_PASSWORD={password}\nMINIO_CI_CD=1\nMINIO_BROWSER=off\nGOMAXPROCS=2\n')
|
||||
envfile.chmod(0o600)
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith(('MINIO_', 'SILO_', 'MC_'))
|
||||
and k.lower() not in {'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'}}
|
||||
nodes = [f'{runid}-n{i}' for i in range(4)]
|
||||
sockets = [socket.socket() for _ in nodes]
|
||||
for sock in sockets:
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
ports = [sock.getsockname()[1] for sock in sockets]
|
||||
for sock in sockets:
|
||||
sock.close()
|
||||
volumes = [n + '-data' for n in nodes]
|
||||
endpoints, ledger = [], []
|
||||
result = {'runid': runid, 'version': version, 'image': IMAGES[version],
|
||||
'image_id': image_info['Id'], 'platform': image_info['Os']+'/'+image_info['Architecture'],
|
||||
'nodes': 4,
|
||||
'drives': 4, 'filesystem': 'Linux tmpfs named volumes, held mounted across server restarts',
|
||||
'drive_bytes': 268435456, 'phases': [], 'status': 'RUNNING'}
|
||||
if version == 'current':
|
||||
result['binary_sha256'] = hashlib.sha256(CURRENT_BINARY.read_bytes()).hexdigest()
|
||||
result['source_revision'] = SOURCE_REVISION
|
||||
bucket = 'canary-' + uuid.uuid4().hex[:10]
|
||||
|
||||
def save(event=None):
|
||||
if event is not None:
|
||||
result['phases'].append(event)
|
||||
compact = {k: (len(v) if k in ('transient_errors', 'errors') else v) for k, v in event.items()}
|
||||
print(json.dumps({'version': version, **compact}), flush=True)
|
||||
(out / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
(out / 'acknowledged.json').write_text(json.dumps(ledger, indent=2) + '\n')
|
||||
|
||||
def parallel(fn, values):
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
return list(pool.map(fn, values))
|
||||
|
||||
def client(i, deadline):
|
||||
timeout = remaining(deadline)
|
||||
return boto3.client('s3', endpoint_url=endpoints[i], aws_access_key_id=user,
|
||||
aws_secret_access_key=password, region_name='us-east-1',
|
||||
config=Config(proxies={}, signature_version='s3v4', s3={'addressing_style': 'path'},
|
||||
retries={'total_max_attempts': 1}, connect_timeout=timeout,
|
||||
read_timeout=timeout, request_checksum_calculation='when_required',
|
||||
response_checksum_validation='when_required'))
|
||||
|
||||
def admin_gate(origin, phase):
|
||||
def check(deadline):
|
||||
first = [None] * 4
|
||||
while True:
|
||||
states = []
|
||||
for i in range(4):
|
||||
try:
|
||||
p = subprocess.run([str(MCLI), '--config-dir', str(out / 'mcli'), '--json',
|
||||
'admin', 'info', f'n{i}'], env=env, capture_output=True,
|
||||
text=True, timeout=min(5, remaining(deadline)))
|
||||
info = json.loads(p.stdout)['info']
|
||||
servers = info['servers']
|
||||
ok = len(servers) == 4 and all(s.get('state') == 'online' and s.get('drives')
|
||||
and all(d.get('state') == 'ok' for d in s['drives'])
|
||||
and all(v == 'online' for v in s.get('network', {}).values()) for s in servers)
|
||||
if ok:
|
||||
(out / f'{phase}-admin-{i}.json').write_text(json.dumps(info, indent=2) + '\n')
|
||||
if first[i] is None:
|
||||
first[i] = round(time.monotonic() - origin, 3)
|
||||
states.append(ok)
|
||||
except (Exception,):
|
||||
states.append(False)
|
||||
if all(states):
|
||||
event = {'phase': phase + '-admin', 'seconds_from_start': round(time.monotonic()-origin, 3),
|
||||
'first_online_by_coordinator': first}
|
||||
save(event)
|
||||
return time.monotonic()
|
||||
time.sleep(min(.25, remaining(deadline)))
|
||||
return bounded(90, check)
|
||||
|
||||
def canary(phase, origin, admin_time, setup=False):
|
||||
def check(deadline):
|
||||
started = time.monotonic()
|
||||
first_put, first_reads = [None] * 4, [None] * 4
|
||||
errors, attempt, setup_done = [], 0, not setup
|
||||
result['active_canary'] = {'phase': phase, 'errors': errors}
|
||||
while True:
|
||||
attempt += 1
|
||||
remaining(deadline)
|
||||
if not setup_done:
|
||||
try:
|
||||
try:
|
||||
client(0, deadline).create_bucket(Bucket=bucket)
|
||||
except Exception as e:
|
||||
if 'BucketAlreadyOwnedByYou' not in str(e):
|
||||
raise
|
||||
client(0, deadline).put_bucket_versioning(Bucket=bucket, VersioningConfiguration={'Status': 'Enabled'})
|
||||
setup_done = True
|
||||
except Exception as e:
|
||||
errors.append({'attempt': attempt, 'operation': 'setup', 'error': str(e)[:250]})
|
||||
time.sleep(min(.25, remaining(deadline)))
|
||||
continue
|
||||
acked = []
|
||||
for i in range(4):
|
||||
key = f'{phase}-attempt-{attempt}-node-{i}'
|
||||
payload = (key + '\n').encode() * 16384
|
||||
try:
|
||||
vid = client(i, deadline).put_object(Bucket=bucket, Key=key, Body=payload)['VersionId']
|
||||
entry = {'phase': phase, 'key': key, 'version': vid, 'bytes': len(payload),
|
||||
'sha256': hashlib.sha256(payload).hexdigest(), 'writer': i,
|
||||
'ack_seconds_from_start': round(time.monotonic()-origin, 3)}
|
||||
ledger.append(entry)
|
||||
save() # Persist every acknowledged write, including failed rounds.
|
||||
acked.append(entry)
|
||||
if first_put[i] is None:
|
||||
first_put[i] = round(time.monotonic()-admin_time, 3)
|
||||
except Exception as e:
|
||||
errors.append({'attempt': attempt, 'operation': 'put', 'node': i, 'error': str(e)[:250]})
|
||||
reads = 0
|
||||
for i in range(4):
|
||||
own_reads = 0
|
||||
for entry in acked:
|
||||
try:
|
||||
verify(i, entry, deadline)
|
||||
reads += 1
|
||||
own_reads += 1
|
||||
except Exception as e:
|
||||
errors.append({'attempt': attempt, 'operation': 'get', 'node': i,
|
||||
'key': entry['key'], 'error': str(e)[:250]})
|
||||
if own_reads == 4 and first_reads[i] is None:
|
||||
first_reads[i] = round(time.monotonic()-admin_time, 3)
|
||||
remaining(deadline)
|
||||
if len(acked) == 4 and reads == 16:
|
||||
result.pop('active_canary', None)
|
||||
save({'phase': phase + '-canary', 'attempts': attempt, 'puts': 4, 'gets': 16,
|
||||
'hard_deadline_seconds': 60, 'gate_seconds': round(time.monotonic()-started, 3),
|
||||
'seconds_from_start': round(time.monotonic()-origin, 3),
|
||||
'first_put_seconds_after_admin_by_coordinator': first_put,
|
||||
'first_four_reads_seconds_after_admin_by_coordinator': first_reads, 'transient_errors': errors})
|
||||
return time.monotonic()
|
||||
(out / f'{phase}-canary-errors.json').write_text(json.dumps(errors, indent=2) + '\n')
|
||||
if attempt == 1:
|
||||
print(json.dumps({'version': version, 'phase': phase, 'first_attempt_errors': errors}), flush=True)
|
||||
time.sleep(min(.25, remaining(deadline)))
|
||||
return bounded(60, check)
|
||||
|
||||
def verify(i, entry, deadline):
|
||||
got = client(i, deadline).get_object(Bucket=bucket, Key=entry['key'], VersionId=entry['version'])
|
||||
try:
|
||||
data = got['Body'].read()
|
||||
finally:
|
||||
got['Body'].close()
|
||||
assert len(data) == entry['bytes'] and hashlib.sha256(data).hexdigest() == entry['sha256'], entry['key']
|
||||
assert got.get('VersionId') == entry['version'], entry['key']
|
||||
remaining(deadline)
|
||||
|
||||
def readback(label, origin):
|
||||
def check(deadline):
|
||||
started = time.monotonic()
|
||||
errors = []
|
||||
for entry in ledger:
|
||||
for i in range(4):
|
||||
try:
|
||||
verify(i, entry, deadline)
|
||||
except Exception as e:
|
||||
errors.append({'key': entry['key'], 'node': i, 'error': str(e)[:250]})
|
||||
save({'phase': label, 'started_seconds_after_canary': round(started-origin, 3),
|
||||
'seconds_after_canary': round(time.monotonic()-origin, 3),
|
||||
'objects': len(ledger), 'reads': len(ledger)*4, 'errors': errors})
|
||||
return errors
|
||||
return bounded(60, check)
|
||||
|
||||
try:
|
||||
docker('network', 'create', runid)
|
||||
for volume in volumes:
|
||||
docker('volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs',
|
||||
'--opt', 'device=tmpfs', '--opt', 'o=size=256m', volume)
|
||||
mounts = [arg for i, v in enumerate(volumes) for arg in ('--mount', f'type=volume,source={v},target=/keep/{i}')]
|
||||
docker('run', '-d', '--pull=never', '--network', 'none', '--name', runid + '-keeper',
|
||||
*mounts, '--entrypoint', 'sleep', 'alpine:3.23', '1800')
|
||||
urls = [f'http://{n}:9000/data' for n in nodes]
|
||||
def create(i):
|
||||
extra = []
|
||||
if version == 'current':
|
||||
extra = ['--mount', f'type=bind,source={CURRENT_BINARY},target=/lab/silo,readonly',
|
||||
'--entrypoint', '/lab/silo']
|
||||
docker('create', '--pull=never', '--name', nodes[i], '--network', runid,
|
||||
'--hostname', nodes[i], '--cpus', '2', '--memory', '3g', '--env-file', str(envfile),
|
||||
'--mount', f'type=volume,source={volumes[i]},target=/data',
|
||||
'-p', f'127.0.0.1:{ports[i]}:9000', *extra, image_info['Id'], 'server', '--address', ':9000',
|
||||
'--console-address', ':9001', *urls)
|
||||
parallel(create, range(4))
|
||||
start = time.monotonic()
|
||||
parallel(lambda n: docker('start', n), nodes)
|
||||
for i, n in enumerate(nodes):
|
||||
endpoint = 'http://' + docker('port', n, '9000/tcp').stdout.strip()
|
||||
endpoints.append(endpoint)
|
||||
env[f'MC_HOST_n{i}'] = endpoint.replace('http://', f'http://{user}:{password}@')
|
||||
admin = admin_gate(start, 'startup')
|
||||
canary('startup', start, admin, setup=True)
|
||||
parallel(lambda n: docker('stop', '-t', '10', n), nodes)
|
||||
start = time.monotonic()
|
||||
parallel(lambda n: docker('start', n), nodes)
|
||||
admin = admin_gate(start, 'full-restart')
|
||||
gate = canary('full-restart', start, admin)
|
||||
read_errors = []
|
||||
for after in (15, 30, 60):
|
||||
time.sleep(max(0, gate + after - time.monotonic()))
|
||||
read_errors.extend(readback(f'readback-{after}s', gate))
|
||||
assert not read_errors, 'acknowledged object readback failure; see result.json'
|
||||
docker('stop', '-t', '10', nodes[3])
|
||||
def outage(deadline):
|
||||
verify(0, ledger[0], deadline)
|
||||
payload = b'acknowledged with one Linux node offline' * 16384
|
||||
key = 'one-node-outage'
|
||||
vid = client(0, deadline).put_object(Bucket=bucket, Key=key, Body=payload)['VersionId']
|
||||
entry = {'phase': 'outage', 'key': key, 'version': vid, 'bytes': len(payload),
|
||||
'sha256': hashlib.sha256(payload).hexdigest(), 'writer': 0}
|
||||
ledger.append(entry)
|
||||
save()
|
||||
for i in range(3):
|
||||
verify(i, entry, deadline)
|
||||
save({'phase': 'one-node-outage', 'existing_read': True, 'put': True, 'readers': 3})
|
||||
bounded(60, outage)
|
||||
start = time.monotonic()
|
||||
docker('start', nodes[3])
|
||||
admin = admin_gate(start, 'rejoin')
|
||||
gate = canary('rejoin', start, admin)
|
||||
assert not readback('final-readback', gate)
|
||||
result['status'] = 'PASS'
|
||||
except BaseException as e:
|
||||
result['status'] = 'FAIL'
|
||||
result['error'] = f'{type(e).__name__}: {e}'
|
||||
raise
|
||||
finally:
|
||||
save()
|
||||
for n in nodes:
|
||||
log = docker('logs', n, check=False)
|
||||
(out / (n + '.log')).write_text(log.stdout + log.stderr)
|
||||
docker('rm', '-f', n, check=False)
|
||||
docker('rm', '-f', runid + '-keeper', check=False)
|
||||
for v in volumes:
|
||||
docker('volume', 'rm', v, check=False)
|
||||
docker('network', 'rm', runid, check=False)
|
||||
envfile.unlink(missing_ok=True)
|
||||
print(json.dumps({'version': version, 'status': result['status'], 'evidence': str(out)}), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('versions', nargs='+', choices=list(IMAGES))
|
||||
parser.add_argument('--output', type=Path, required=True, help='directory for retained evidence')
|
||||
parser.add_argument('--mcli', default=shutil.which('mcli'), help='native mcli executable')
|
||||
parser.add_argument('--current-binary', type=Path, help='Linux binary matching the Docker architecture')
|
||||
parser.add_argument('--source-revision', help='Git revision of --current-binary')
|
||||
parser.add_argument('--current-image', default=IMAGES['current'], help='cached Linux base image for current binary')
|
||||
args = parser.parse_args()
|
||||
if not args.mcli or not Path(args.mcli).is_file():
|
||||
parser.error('--mcli must point to an executable file')
|
||||
if 'current' in args.versions and (not args.current_binary or not args.current_binary.is_file()):
|
||||
parser.error('current requires --current-binary')
|
||||
ROOT = args.output.resolve()
|
||||
ROOT.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
MCLI = Path(args.mcli).resolve()
|
||||
CURRENT_BINARY = args.current_binary.resolve() if args.current_binary else None
|
||||
SOURCE_REVISION = args.source_revision
|
||||
IMAGES['current'] = args.current_image
|
||||
for version in args.versions:
|
||||
run(version)
|
||||
@@ -1,409 +0,0 @@
|
||||
# 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 局部候选;“未修改产品”和“不要扩大范围”等表述仅适用于当时的调查阶段,局部候选已被后续全路径修复取代。**
|
||||
|
||||
## 判断与处理顺序
|
||||
|
||||
**目前可以确认是 Server 发出的 discovery GET 失败,随后 IAM 初始化等待,Console 初始化也被延后;还不能确认真实连接由谁、在哪个协议阶段重置。优先调查 SILO/Go 客户端与 IdP 前置 TLS 终止器、代理或 WAF 的互操作。** 现有证据不支持将其定性为证书错误、Keycloak 配置错误、JWT 校验错误或 `coreos/go-oidc` 回归。
|
||||
|
||||
有两项与版本相关、可在本地验证的 TLS 差异:
|
||||
|
||||
1. **Go 1.27 改变了 `GODEBUG=tlsmlkem=0` 与显式 `CurvePreferences` 的关系。** SILO 两个版本都显式包含 X25519MLKEM768。旧版 Go 1.26.5 会根据该环境选项移除它;Go 1.27.1 保留显式配置。在模拟拒绝 ML-KEM 的入口上,能重现“相同选项下旧 Server 启动成功,新 Server 持续 reset”。**客户是否设置过这个选项尚未知,不能把条件性复现当成客户根因。**
|
||||
2. **Go 1.27 的 ClientHello 新增 ML-DSA 签名算法。** 没有上述环境选项时,新旧版本也会发送不同的握手。人为拒绝新算法编号的入口同样能产生旧成功、新失败。真实入口是否存在这种行为尚未知。
|
||||
|
||||
另外,HTTP User-Agent 从 `MinIO` 变成了 `Silo`;若连接在 TLS 完成、GET 发出之后被重置,应优先查 WAF、User-Agent 规则和 HTTP 路由,而不是继续调整 TLS。
|
||||
|
||||
最短路径:**先在故障进程所在环境拿到实际 Go 版本、是否设置 `tlsmlkem=0`、目标 IP 和 TLS 完成与否;再针对已证实的分支处理。** 优先修正入口兼容性或错误路由。若确认是上述环境选项失效,只为 OpenID 出站请求恢复 Go 默认曲线选择,是目前最小的代码候选。现阶段不宜做全局 TLS 改动或整体依赖回退。
|
||||
|
||||
## 第二轮结论:已隔离 Go 因素,局部候选修复通过 Linux 验证
|
||||
|
||||
**可以复现一种由 Go 1.26.5 → 1.27.1 单独触发的兼容性回归。建议保留 Go、针对已确认分支修复;整体回退只用于临时恢复服务。** 这里的“已确认”指本地实验机制,仍不等于已经确认客户入口的根因。
|
||||
|
||||
四个完整 Server 都从本地源码编译为 `linux/arm64`,在现成通用 Debian 12 基础镜像的 `--network none` 容器中运行;Server、合成 IdP、Console 和 curl 共用同一 loopback 网络,没有暴露端口。没有下载或运行 Server/Console 镜像。旧源码为 `d88f46ccee345a9c2fabe2d221d9a9e56bc11aec`,当前源码为 `d1105bbb3d4a0afa33b3a4ac11b821235038ed0e`。
|
||||
|
||||
旧源码两次构建使用完全相同的 `go.mod`、`go.sum` 和实际链接模块版本,仅替换编译器;当前源码与候选补丁构建的依赖图也完全相同。实际二进制 SHA-256、`go version -m` 依赖和构建选项保存在 [Linux 证据](issue-154/linux-evidence.json)。
|
||||
|
||||
下表的入口**人为设置为见到 X25519MLKEM768 就发 TCP RST**,Server 都设置 `GODEBUG=tlsmlkem=0`:
|
||||
|
||||
| 源码与编译器 | Server 初始 ClientHello | 完整 Server 结果 | 同容器 curl |
|
||||
| --- | --- | --- | --- |
|
||||
| 同一份旧源码 + Go 1.26.5 | 275 字节,无 ML-KEM | discovery、JWKS、IAM、Console 正常;cluster 200 | HTTP/2 200 |
|
||||
| 同一份旧源码 + Go 1.27.1 | 1509 字节,仍包含 ML-KEM | `connection reset by peer`;IAM 等待;cluster 503;Console 未启动 | HTTP/2 200 |
|
||||
| 当前源码 + Go 1.27.1 | 1509 字节,仍包含 ML-KEM | 同样失败 | HTTP/2 200 |
|
||||
| 当前源码 + 局部候选补丁 + Go 1.27.1 | 287 字节,无 ML-KEM | 完整启动及合成 OIDC 登录成功 | HTTP/2 200 |
|
||||
|
||||
这将该条件下的回归定位到工具链行为,而非 Console、pkg、mc 或 `coreos/go-oidc` 升级。Go 1.27 的发布说明明确将此作为有意改变:`tlsmlkem` / `tlssecpmlkem` 只控制默认曲线集合,显式指定的集合可以继续启用这些算法。SILO 现有曲线列表显式包含该算法,因而原来的兼容开关在这条路径失效。[Go 1.27 crypto/tls 说明](https://go.dev/doc/go1.27#crypto/tls)。
|
||||
|
||||
共完成 **12 个 Linux 场景**,其中失败用例按预期失败:
|
||||
|
||||
- 正常 TLS 1.2 / P-256 / RSA / AES-256-GCM 的 IdP,旧源码用两个 Go 版本编译均正常,证明不是 Go 1.27 普遍无法连接这套 TLS。
|
||||
- 上表四个对照均符合预期;候选补丁如果不设置 `tlsmlkem=0`,仍被 ML-KEM 拒绝规则拦截。补丁恢复显式兼容选项的作用,不会自行关闭后量子算法。
|
||||
- 候选补丁在上述 TLS 1.2 兼容场景、以及无 GODEBUG 的正常 TLS 1.3 场景,都完成 Console 登录信息获取 → IdP authorization redirect → callback → token exchange → STS 凭据 → Console 会话 → 桶列表读取。登录 API 为 204,桶列表为 200。
|
||||
- 两个登录场景分别提交错误签名与错误 audience 的 JWT,登录返回 500、桶列表返回 403,没有生成会话 cookie。这里只确认认证未放行,没有将现有 500 状态码行为改为另一项修复。
|
||||
- 不信任 CA 时,候选 Server 仍因 `x509: certificate signed by unknown authority` 停在 IAM 初始化。没有关闭证书校验。
|
||||
- 不配置 OIDC 启动后,经实际 Admin API 添加同一合成 provider:当前源码失败且返回 reset;候选补丁成功。
|
||||
- 入口改为拒绝 ML-DSA 签名编号,候选补丁加 `tlsmlkem=0` 仍失败。这是另一条机制,当前补丁没有解决它。
|
||||
|
||||
认证流程由 Python 驱动真实 Console HTTP API;IdP 使用一次性合成授权码和自行签发的实验 JWT,没有客户账户。没有执行浏览器页面交互、登出、真实 Keycloak 或生产数据升级测试。TLS 1.3 对照实际协商 TLS 1.3/P-256,不是所有新增后量子曲线的互操作覆盖。
|
||||
|
||||
### 修复与回退的取舍
|
||||
|
||||
[候选补丁](issue-154/openid-default-curves.patch) 只有三个文件:增加 OpenID 专用 transport helper,将其 `TLSClientConfig.CurvePreferences` 设为 `nil`,再替换 IAM 初始化和 OpenID 配置校验两个调用点。测试时仅应用于隔离源码副本,当前产品工作区未应用;Go 版本与所有依赖保持不变。它让 Go 默认策略和现有兼容开关接管外部 IdP 的密钥交换,其他 TLS 参数继续来自现有构造函数。
|
||||
|
||||
如果客户证据确认此分支,建议采用该局部修复,并在客户实际入口复验。如果客户没有设置 `tlsmlkem=0`,它不能单独解释旧版成功:旧版默认也发送 ML-KEM。此时应继续区分 ML-DSA、其他握手变化、HTTP/WAF 规则与网络路径;不把此补丁直接宣称为 #154 的完整修复。
|
||||
|
||||
**不建议将当前产品直接改回 Go 1.26。** 实测以 Go 1.26.7 和 `GOTOOLCHAIN=local` 读取当前源码即被 `go.mod requires go >= 1.27.1` 拒绝;所固定的 Server、Console、mc 均声明 Go 1.27.1。回退需要进一步调整这些模块及可能的传递依赖,不是只换一个编译器版本。这个报错证明当前依赖图不能原样回编,并不证明经过额外适配后绝对无法回编。报告中已恢复服务的旧版可作为临时运行状态,不能把这种处置等同于完成兼容修复。
|
||||
|
||||
第二轮的运行脚本是 [run-linux.py](issue-154/run-linux.py),结果是 [linux-evidence.json](issue-154/linux-evidence.json),具体构建和运行步骤见文末。没有 issue 评论、提交、推送、合并或发布。
|
||||
|
||||
候选副本另通过 Go 1.27.1、darwin/arm64、CGO 关闭的 `go test -mod=readonly -count=1 ./internal/http ./internal/config/identity/openid`;补丁可以干净应用到调查基线。这些包级测试与上述 Linux 集成验证分别记录,不将其当成 Linux 单元测试结果。
|
||||
|
||||
## 范围、版本与公开证据
|
||||
|
||||
- 初始工作区干净,处于 detached HEAD;调查分支为 `codex/investigate-oidc-154`,基线与远端 main 均为 `d1105bbb3d4a0afa33b3a4ac11b821235038ed0e`。
|
||||
- 已读取 `/Users/vonng/pgsty/silo/AGENTS.md` 及工作区适用说明。维护范围为 PGSTY 的 Server、Console、mc、silo-pkg;上游 MinIO 仅作参考。
|
||||
- 第一轮 Server 与 transport 探针为本地源码构建的 darwin/arm64 程序,第二轮完整 Server 交叉构建为 linux/arm64;均使用 `CGO_ENABLED=0 GOWORK=off`。fixture 与 Admin 辅助程序也由本机 Go 构建。历史源码使用 `git archive` 导入隔离临时目录。没有下载或运行 Server/Console Docker 镜像,没有访问客户端点,没有改动真实服务或数据,没有评论 issue、推送、合并或发布。
|
||||
- 产品源码、`go.mod`、`go.sum` 未修改。本目录中的 Go 文件是显式运行的调查工具,带 `//go:build ignore`,不进入正常构建。
|
||||
|
||||
| 项目 | 旧版:2026-08-04 | 报告故障版:2026-09-03 | 调查时 main |
|
||||
| --- | --- | --- | --- |
|
||||
| 完整 tag | `RELEASE.2026-08-04T00-00-00Z` | `RELEASE.2026-09-03T13-18-01Z` | 无新 release 声明 |
|
||||
| 源码 commit | `d88f46ccee345a9c2fabe2d221d9a9e56bc11aec` | `9b11dc9469e650815b775cb47b039610644f5da4` | `d1105bbb3d4a0afa33b3a4ac11b821235038ed0e` |
|
||||
| go.mod / Docker 构建定义 | Go 1.26.5 | Go 1.27.1 | Go 1.27.1 |
|
||||
| 本地 Server / 探针实际编译器 | Go 1.26.5 | Go 1.27.1 | Go 1.27.1 |
|
||||
| Console replacement | `v0.0.0-20260804042150-b952a1202869` | `v0.0.0-20260903111932-464a59d73ada` | `v0.0.0-20260908142700-c103d08ec36a` |
|
||||
| mc replacement | `v0.0.0-20260801042411-ad10a2a10b76` | `v0.0.0-20260903063637-a2ef95c035d9` | `v0.0.0-20260909015522-fcd5cad8247f` |
|
||||
| PGSTY silo-pkg | v3.11.0,替换历史 minio/pkg 路径 | v3.13.2,直接依赖 | v3.13.3,直接依赖 |
|
||||
| coreos/go-oidc/v3 | v3.17.0 | v3.21.0 | v3.21.0 |
|
||||
| x/crypto | v0.54.0 | v0.56.0 | v0.56.0 |
|
||||
| x/net | v0.57.0 | v0.58.0 | v0.58.0 |
|
||||
| x/oauth2 | v0.36.0 | v0.36.0 | v0.36.0 |
|
||||
|
||||
版本依据:[旧版 go.mod](https://github.com/pgsty/silo/blob/d88f46ccee345a9c2fabe2d221d9a9e56bc11aec/go.mod)、[故障版 go.mod](https://github.com/pgsty/silo/blob/9b11dc9469e650815b775cb47b039610644f5da4/go.mod)、[本次 main go.mod](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/go.mod)。还核对了各 tag 的 `Dockerfile.goreleaser` 和 release workflow。它们声明相应 Go 构建版本、禁用 CGO;历史归档、本次探针和 Server 均用 `go version -m` 核实实际编译器与 replacement。**这些不是客户实际镜像 digest 或其内二进制的取证,后者仍需 `--version` / build info 确认。**
|
||||
|
||||
[Issue #154](https://github.com/pgsty/silo/issues/154) 当前 OPEN,最后更新时间 `2026-09-08T05:30:42Z`,评论数为 0。报告包含升级后 Server 初始化失败、旧版回退恢复、测试实例添加 OIDC 失败,以及 curl 成功的输出。
|
||||
|
||||
curl 那次连接验证了所收到的证书链,并选择 TLS 1.2、`ECDHE-RSA-AES256-GCM-SHA384`、P-256 和 HTTP/2;解析得到两个 IPv4,记录中实际访问了其中一个。**公开命令使用 `docker run --rm` 新建容器,并非 `docker exec` 进入原故障容器;同镜像不能证明同网络命名空间、环境变量、CA 挂载、DNS 结果或出口。** 域名、realm、IP 已脱敏,本次不推测其真实值。
|
||||
|
||||
## 实际请求链
|
||||
|
||||
### IAM 与配置校验
|
||||
|
||||
```text
|
||||
Server startup
|
||||
IAMSys.Init
|
||||
openid.LookupConfig
|
||||
parseDiscoveryDoc: GET .well-known/openid-configuration
|
||||
PopulatePublicKey: GET discovery 中的 jwks_uri
|
||||
IAM store 初始化
|
||||
Console 初始化
|
||||
|
||||
Console 添加 OIDC
|
||||
AdminClient.AddOrUpdateIDPConfig
|
||||
Server addOrUpdateIDPHandler
|
||||
validateConfig(identity_openid)
|
||||
同一个 openid.LookupConfig / NewHTTPTransport
|
||||
```
|
||||
|
||||
`parseDiscoveryDoc` 是 SILO 自己的实现,用标准库 `http.Client` 发 GET,收到成功响应后才解码 JSON;这次日志中的 `Get ... read tcp ... reset` 发生在该调用返回响应之前,不能进一步区分 TLS 与 HTTP。请求本身不需要客户 client secret、token 或私钥。[IAM 调用点](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/iam.go#L278-L288)、[discovery 实现](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/internal/config/identity/openid/jwt.go#L261-L285)、[JWKS 实现](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/internal/config/identity/openid/jwt.go#L89-L110)。
|
||||
|
||||
Console 的添加表单通过 Admin API 触发 Server 配置校验。校验成功才写入配置;本地已重现该 API 返回同样的 reset,恢复 IdP 后再次创建成功并要求重启。[Console 调用](https://github.com/pgsty/silo-console/blob/c103d08ec36a/api/admin_idp.go#L79-L114)、[Admin 客户端](https://github.com/pgsty/silo-console/blob/c103d08ec36a/api/client-admin.go#L593-L595)、[Server 校验和保存边界](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/admin-handlers-idp-config.go#L127-L150)、[OpenID 配置校验](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/config-current.go#L353-L359)。
|
||||
|
||||
`coreos/go-oidc.NewProvider` 在 Server 中的使用是 `MockOpenIDTestUserInteraction` 测试辅助函数,不是这次 IAM discovery 调用链。升级此依赖不能单独解释或修复该 GET。`crypto/tls` 和这里的 `net/http` 属于 Go 标准库,不能用 go.mod 中 `x/crypto`、`x/net` 的版本代替它们的实际行为。[辅助函数](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/utils.go#L994-L1012)。
|
||||
|
||||
### transport 参数与环境
|
||||
|
||||
| 项目 | 实际行为及意义 |
|
||||
| --- | --- |
|
||||
| 构造 | `NewHTTPTransport()` → `NewHTTPTransportWithTimeout(time.Minute)` → `xhttp.ConnSettings`。每次 IAM 初始化重试都会重新构造。 |
|
||||
| TLS 版本 | 未显式设置 Min/Max;所比较 Go 工具链的正常默认范围是 TLS 1.2–1.3。证书、主机名验证开启。 |
|
||||
| 密码套件 | 显式 `TLSCiphersBackwardCompatible()`,包含 curl 成功使用的 ECDHE-RSA/AES-256-GCM。没有理由为本 issue 添加旧 RSA、3DES 或 SHA-1 例外。 |
|
||||
| 曲线 | 显式 `{X25519MLKEM768, P256, X25519, P384, P521}`,两个 release 和当前 main 相同。实际上线顺序还受 Go 实现控制,见实验。 |
|
||||
| ALPN / HTTP | `EnableHTTP2=false`,同时设置自定义 TLS config 和 DialContext;实测 ClientHello 没有 ALPN,GET 使用 HTTP/1.1。三个版本一致。`GODEBUG=http2client=0` 对此基线路径无修复价值。 |
|
||||
| 代理 | `http.ProxyFromEnvironment`;HTTPS URL 使用 `HTTPS_PROXY` / `https_proxy` 与 `NO_PROXY` / `no_proxy`。这两版标准库均优先非空大写值,不使用 `ALL_PROXY`;设置在进程内缓存。不能根据 curl 的路由推断它。注意 go.mod 的 x/net v0.58.0 代码不是这里所用的标准库 vendored 实现。 |
|
||||
| DNS | `globalDNSCache.LookupHost`,dnscache v0.1.1;默认刷新窗口在容器/Kubernetes 为 30 秒,其他环境为 10 分钟,可配置。按返回地址逐个尝试 TCP,首次 TCP 成功就返回;随后 TLS/HTTP 失败不会回到这个循环尝试另一 IP。代码注释称随机选择,但该循环没有 shuffle。 |
|
||||
| Linux TCP 参数 | 使用 SILO 的自定义 dialer,包含 TCP fast open/keepalive 等设置,部分参数来自 Server CLI,包括 interface、buffer、user timeout。第二轮执行了完整 Linux Server 的正常 CLI 初始化,但只验证 loopback;不能排除实际出口设备、路由或非默认 CLI 参数的交互。 |
|
||||
| CA | `silo-pkg/certs.GetRootCAs` 加载系统根、Kubernetes CA 目录和 `certs/CAs`;Server 还加入自己的公开服务证书。相关 pkg CA 加载实现未在这次版本比较中变化;实际文件和路径仍可能因部署变化不同。 |
|
||||
| 超时 | TCP 拨号 5 秒,TLS 握手 10 秒,响应头 1 分钟;discovery/JWKS 的 Client 没有总超时,discovery 使用的 Request 也没有调用方 context。响应体停滞不受响应头超时保护。 |
|
||||
| 复用 | keep-alive 开启,idle 15 秒,TLS session cache 100。一次初始化内 discovery 与 JWKS 可复用连接;初始化重试的新 transport 没有旧连接或 session。持续首次握手失败不能用清理空闲连接解释。 |
|
||||
| 请求标识 | UA 包含产品、OS、架构、模式和构建信息,产品名从 `MinIO` 变为 `Silo`;传输层禁用自动压缩。UA 规则只能在 HTTPS 被终止、HTTP 请求可见之后起作用。 |
|
||||
|
||||
源码:[构造与超时](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/utils.go#L651-L670)、[HTTP/TLS 参数](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/internal/http/transports.go#L44-L94)、[密码套件与曲线](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/internal/crypto/crypto.go#L52-L78)、[DNS 拨号循环](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/internal/http/dial_dnscache.go#L42-L84)、[缓存刷新](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/common-main.go#L551-L578)、[CA 加载](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/server-main.go#L383-L395)、[UA](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/update.go#L228-L266)。
|
||||
|
||||
比较旧 tag 与故障 tag,OpenID discovery、transport、曲线实现的变动仅为 pkg 导入路径调整;IAM 另有品牌日志变更。比较故障 tag 与本次 main,这些关键实现没有变动。因此不能把当前依赖推进当成 #154 已解决的证据。
|
||||
|
||||
**Console 登录阶段是另一条出站路径。** 当前及故障版 Console 通过 `GetConsoleHTTPClient` / `GlobalTransport` 再获取 discovery、交换令牌,TLS config 未指定 CurvePreferences,仍做完整证书验证;不能把“添加配置走 Server”推广为所有 Console OIDC 请求都走 Server transport。任何修复最终都必须验证完整登录。`CONSOLE_MINIO_SERVER_TLS_SKIP_VERIFY` 只针对 SILO 端点,不能作为 IdP 修复。[Console transport](https://github.com/pgsty/silo-console/blob/c103d08ec36a/api/config.go#L68-L121)、[IdP 客户端](https://github.com/pgsty/silo-console/blob/c103d08ec36a/api/tls.go#L59-L70)、[Console discovery](https://github.com/pgsty/silo-console/blob/c103d08ec36a/pkg/auth/idp/oauth2/provider.go#L428-L449)。
|
||||
|
||||
## 本地实验与能排除的假设
|
||||
|
||||
工具与原始结果放在 [issue-154/](issue-154/):
|
||||
|
||||
- `probe.go` 直接调用 `cmd.NewHTTPTransport()`,用同版本 `certs.GetRootCAs` 装入实验 CA;记录 TCP、TLS、HTTP 阶段。诊断参数仅修改被比较的一项。
|
||||
- `fixture.go` 仅监听 IPv4 loopback;使用即时生成的 RSA 测试证书与专用 CA,提供 discovery 和有效 JWKS。正常基线限制 TLS 1.2、P-256、`TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`,支持 HTTP/1.1 和 HTTP/2。
|
||||
- `admin-check.go` 仅允许 loopback Server,验证 Console 使用的 Admin API;使用本地虚构配置和临时凭据。
|
||||
- `evidence.json` 保存版本、握手参数、分阶段 trace 和完整 Server 实验结果;无客户信息、私钥或 token。
|
||||
- 第二轮扩展 fixture 的合成授权码、token 和 JWKS 流程;`run-linux.py` 驱动 Linux 完整 Server 及 Console API,`linux-evidence.json` 保存十二组场景和构建身份。敏感运行值不写入结果。
|
||||
|
||||
### 正常服务及握手差异
|
||||
|
||||
三个源码版本的实际 transport 都能通过完整证书验证,以 TLS 1.2 / AES-256-GCM / HTTP/1.1 获取测试文档。**Go 1.27 本身并非不能连接 TLS 1.2、RSA 证书或这套密码套件。** 正常服务也接受诊断性的 HTTP/2。
|
||||
|
||||
| 构建与选项 | fixture 读到的初始握手字节数 | 支持的 group ID | 新增 ML-DSA 签名编号 |
|
||||
| --- | ---: | --- | --- |
|
||||
| 旧源码 + Go 1.26.5,默认 | 1497 | 4588, 29, 23, 24, 25 | 无 |
|
||||
| 故障源码 + Go 1.27.1,默认 | 1509 | 同上 | 0x0904, 0x0905, 0x0906 |
|
||||
| 当前源码 + Go 1.27.1,默认 | 1509 | 同上 | 同上 |
|
||||
| 旧源码 + Go 1.26.5,`tlsmlkem=0` | 275 | 29, 23, 24, 25 | 无 |
|
||||
| 故障/当前源码 + Go 1.27.1,`tlsmlkem=0` | 1509 | 4588, 29, 23, 24, 25 | 有 |
|
||||
| 旧源码、旧依赖,只改为 Go 1.27.1 | 1509 | 4588, 29, 23, 24, 25 | 有;`tlsmlkem=0` 也不再移除 4588 |
|
||||
|
||||
4588 是 X25519MLKEM768,29 是 X25519,23/24/25 是 P-256/384/521。这些字节数包含本地 fixture 收到的 TLS record,测试 URL 是 IP,没有 DNS SNI;不能直接当成客户网络中的包长或 MTU 证据。默认新旧差异约 12 字节,没有证据支持“本次才突然出现巨大 ML-KEM 握手”的说法。设置上述环境选项时的差异则显著不同。
|
||||
|
||||
旧源码保留旧依赖、仅更换 Go 编译器后,行为随编译器变化,隔离了本次发现与 silo-pkg/Console 版本更新之间的关系。Go 1.27 官方说明也明确记录显式曲线配置不再受这些默认值开关限制,并新增 ML-DSA 支持。[Go 1.27 发布说明](https://go.dev/doc/go1.27)。本地进一步核对了两版 `crypto/tls/defaults.go`、`common.go` 的 `curvePreferences`/`supportsCurve` 和 `handshake_client.go`。
|
||||
|
||||
### 主动拒绝与对照结果
|
||||
|
||||
下列拒绝规则是人为设置的模型,**只证明机制可以产生相同症状,不证明真实 Keycloak 或其入口使用这些规则**。
|
||||
|
||||
| fixture 规则 | 对照结果 | 能支持的结论 |
|
||||
| --- | --- | --- |
|
||||
| ClientHello 带 ML-KEM 就发送 TCP RST | 旧版默认也失败;旧版 + `tlsmlkem=0` 成功;故障版 + 同选项失败;故障版显式 classical 曲线成功 | 需要旧环境选项或其他变化,才能用该机制解释升级回归。仅说 ML-KEM 不兼容不充分。 |
|
||||
| ClientHello 带 ML-DSA 编号就 RST | 旧版默认成功;故障版默认/仅 classical 都失败;故障版 TLS 1.2-only 成功 | 新的签名算法列表是另一种可区分机制。ML-DSA 与 ML-KEM 不是同一项。TLS 1.2-only 会同时改变多项 ClientHello,成功不等于唯一定位 ML-DSA。 |
|
||||
| 必须提供 h2 ALPN | 旧、新 Server transport 默认都失败;`-h2` 成功 | 可以解释 curl 与 Server 的不同,单独不能解释新旧版本差异。 |
|
||||
| TLS 完成后,对 `Silo` UA 的 GET 发送 RST | 同一个新 transport,`MinIO` UA 成功、`Silo` UA 失败 | 报告的外层 `Get ... reset` 错误也可能来自 HTTP 层,必须先判断 TLS 是否完成。 |
|
||||
| 不信任测试 CA | 返回 `*tls.CertificateVerificationError` / x509 类错误 | 与人为 RST 的错误不同;没有证据要求跳过证书验证。仍须比较客户实际连接收到的链。 |
|
||||
| 正常连接复用 | 第二个请求 `reused=true`;关闭 idle 连接后重新建 TCP 可恢复 TLS session | keep-alive 与 TLS session 复用是两件事。首次新 transport 就失败时,此分支优先级低。 |
|
||||
|
||||
### 完整 Server、IAM 和恢复
|
||||
|
||||
使用三个本地编译的完整 Server,独立空数据/配置目录、独立 CA 和临时凭据;未登录真实 IdP。
|
||||
|
||||
| 场景 | 实际结果 |
|
||||
| --- | --- |
|
||||
| 旧版、故障版连接正常 IdP | discovery + JWKS 成功;一条 TLS 连接;cluster health 200;Console HTTP 200;Admin ListUsers 成功。 |
|
||||
| 当前版连接持续 reset 的 IdP | IAM 持续等待;cluster health 503,`X-Minio-Server-Status: iam-offline`;Console 端口尚未提供服务;5 秒限时的 Admin ListUsers 未完成。 |
|
||||
| 上述场景恢复 IdP,不重启 Server | 约 0.43 秒后 cluster/Console 200,ListUsers 成功。该时间是单次本地样本,不是恢复 SLA。 |
|
||||
| 旧版 + `tlsmlkem=0`,IdP 拒绝 ML-KEM | 完整启动成功。 |
|
||||
| 故障版 + `tlsmlkem=0`,相同拒绝规则 | IAM 等待、cluster 503;撤掉规则后约 1.38 秒内完整恢复,无需重启。 |
|
||||
| 当前版 discovery 成功、JWKS 返回 503 | 同样阻塞 IAM;JWKS 恢复后约 0.33 秒内恢复。只验证 discovery 200 不够。 |
|
||||
| 当前版先不配置 OIDC,再经 Admin API 添加 | IdP reset 时返回同型 `Get ... read tcp ... connection reset by peer`;恢复后同名创建成功,`restart=true`。失败校验没有保存该 provider。 |
|
||||
|
||||
在本地 IAM 阻塞的场景中,`/minio/health/live` 和 `/minio/health/ready` **仍为 200**。判断这次恢复应使用 `/minio/health/cluster` 并验证受认证操作和 Console,不能只看 ready。源码上 cluster 的 `checkHealth` 检查 IAM,而 ready 没有此检查;这是已有行为,本文不扩展为一次健康检查重构。[health 检查](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/healthcheck-handler.go#L32-L65)、[ready 检查](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/healthcheck-handler.go#L129-L186)。
|
||||
|
||||
现有 IAM 重试间隔随机为 0–3 秒,GET 本身还会占用网络等待时间;初始化成功前不会继续创建 IAM store,Console 又在 IAM 调用返回后启动。外部连接恢复可由现有重试自行恢复;这不意味着靠增加重试能修复持续不兼容。缺少整体请求期限、请求取消的部分是另外一个可单独修复的启动健壮性问题。[重试逻辑](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/iam.go#L342-L365)、[Console 启动顺序](https://github.com/pgsty/silo/blob/d1105bbb3d4a0afa33b3a4ac11b821235038ed0e/cmd/server-main.go#L1007-L1027)。
|
||||
|
||||
已通过:`CGO_ENABLED=0 GOWORK=off go test -mod=readonly -count=1 ./internal/http ./internal/config/identity/openid`。这些测试没有替代真实 Keycloak 登录或 Linux 故障网络的验证。
|
||||
|
||||
## 最少补充证据与诊断命令
|
||||
|
||||
先收集第一轮,按结果才展开后续。所有请求只取公开 discovery,不需要客户端密钥、密码、token 或私钥。不要求环境变量全量导出、配置导出或证书私钥。
|
||||
|
||||
### 第一轮:运行身份、网络一致性、协议阶段
|
||||
|
||||
在**已有故障容器/Pod 的实际网络环境**中运行;旧版也做同样检查。不要以新建默认网络容器替代。如果实际进程经启动脚本修改过环境,探针也应使用修改后的相关环境和同一 CA 挂载。
|
||||
|
||||
```sh
|
||||
# 选择实际 Server 可执行文件;记录输出中的版本、Go、OS/架构。
|
||||
silo --version
|
||||
# 旧镜像的程序名可能是 minio。
|
||||
|
||||
# Linux:PID 设为实际 Server 进程 PID,不预设一定是 1。
|
||||
# 只输出 Go 调试选项以及相关环境项是否存在,不输出代理凭据/地址。
|
||||
tr '\0' '\n' < "/proc/$PID/environ" | awk '
|
||||
/^GODEBUG=/ { print; next }
|
||||
/^(HTTP_PROXY|HTTPS_PROXY|NO_PROXY|ALL_PROXY|http_proxy|https_proxy|no_proxy|all_proxy|SSL_CERT_FILE|SSL_CERT_DIR)=/ {
|
||||
split($0, a, "="); print a[1] "=<set>"
|
||||
}'
|
||||
|
||||
# OIDC_URL 仅在本地设为原 config_url;URL 不应带凭据或令牌。
|
||||
export OIDC_URL
|
||||
curl --http1.1 --connect-timeout 5 --max-time 20 -sS -o /dev/null \
|
||||
-w 'ip=%{remote_ip} http=%{http_version} status=%{http_code} verify=%{ssl_verify_result}\n' "$OIDC_URL"
|
||||
curl --http2 --connect-timeout 5 --max-time 20 -sS -o /dev/null \
|
||||
-w 'ip=%{remote_ip} http=%{http_version} status=%{http_code} verify=%{ssl_verify_result}\n' "$OIDC_URL"
|
||||
|
||||
# 由维护者从对应源码构建的探针;OIDC_CA 是与 Server 相同的 CAs 目录。
|
||||
./oidc-probe -ca "$OIDC_CA"
|
||||
```
|
||||
|
||||
探针从 `OIDC_URL` 读取 URL。URL、响应体、请求头不会打印;输出的 IP 可按一致映射替换为 IP-A/IP-B。它使用真实 Server transport 构造函数,但添加 20 秒总期限、不跟随重定向、限制响应体读取为 1 MiB,并使用 `issue-154-probe` UA;因此是定位连接阶段的工具,不是完整 OIDC 流程。它没有执行 Server 的 CLI 初始化,也不继承该进程的 `--interface`、socket buffer、TCP user timeout 或存量 DNS 缓存;若使用这些非默认参数,必须对齐后才能归因。若 Server 将自己的公开服务证书也作为根信任,需要把相同公开证书加入探针的临时 CA 目录。
|
||||
|
||||
读结果的方法:
|
||||
|
||||
- `tls_start` 后 `tls_done ... err=reset`:先查 TLS ClientHello、代理 CONNECT 或 TLS 终止设备;尚不能从客户端确定 RST 由终端还是中间设备发出。
|
||||
- `tls_done ... err=none`、`wrote_request` 后 reset:检查 HTTP/WAF/UA/入口路由。此时更换证书信任或密钥交换没有针对性。
|
||||
- 出现 x509 类错误:比较该进程实际收到的证书链、SNI、系统 CA 和自定义 CA;保持验证开启。
|
||||
- 两次 curl 的 IP 不同,或探针目标与 curl 不同:先做同 IP 对照。curl HTTP/1.1 成功也不代表 Go ClientHello 相同。
|
||||
- 仅 curl h2 成功:确认实际协商的 `http_version` 是 2,再检查入口 HTTP/1.1 支持;不要先全局启用 Server HTTP/2。
|
||||
|
||||
### 只对命中的分支做 A/B
|
||||
|
||||
```sh
|
||||
# TLS 阶段失败:仅移除 hybrid key exchange,仍支持 TLS 1.3 和验证证书。
|
||||
./oidc-probe -ca "$OIDC_CA" -classical
|
||||
|
||||
# 如果旧进程确实使用 tlsmlkem=0,验证最小候选是否恢复其效果。
|
||||
# 应保留原 GODEBUG 的其他相关选项;以下假定没有需要保留的其他值。
|
||||
GODEBUG=tlsmlkem=0 ./oidc-probe -ca "$OIDC_CA" -default-curves
|
||||
|
||||
# 只有 classical 仍失败时,作为鉴别实验测试 TLS 1.2-only。
|
||||
./oidc-probe -ca "$OIDC_CA" -tls12
|
||||
|
||||
# 只有 HTTP/ALPN 对照指向此分支时才测试。
|
||||
./oidc-probe -ca "$OIDC_CA" -h2
|
||||
|
||||
# HTTP 阶段才失败:UA_OLD / UA_NEW 是两版实际请求的完整 UA,非密钥。
|
||||
./oidc-probe -ca "$OIDC_CA" -ua "$UA_OLD"
|
||||
./oidc-probe -ca "$OIDC_CA" -ua "$UA_NEW"
|
||||
```
|
||||
|
||||
若 first-hop 使用代理,先比较两进程的代理选择和 `NO_PROXY`,不公开含密码的代理 URL。只在明确允许直连的部署中使用 `-direct`。确认直连后,可针对每个 DNS 地址运行以下两项,保留原 URL 主机名与 SNI,不能直接把 HTTPS URL 改成 IP:
|
||||
|
||||
```sh
|
||||
curl --noproxy '*' --resolve "$OIDC_HOST:443:$IP_A" --http1.1 \
|
||||
--connect-timeout 5 --max-time 20 -sS -o /dev/null \
|
||||
-w 'ip=%{remote_ip} status=%{http_code} verify=%{ssl_verify_result}\n' "$OIDC_URL"
|
||||
./oidc-probe -ca "$OIDC_CA" -direct -ip "$IP_A"
|
||||
# 对 IP-B 重复;端口不是 443 时据实修改 --resolve。
|
||||
```
|
||||
|
||||
仅第二次请求失败时才补 `-n 2` 与 `-n 2 -fresh`;后者重建 TCP,但同一 transport 的 TLS session cache 仍保留。若仍无法区分,下一步要的是入口侧同一时间窗口的握手失败原因/命中规则,或由客户自行脱敏后的 ClientHello 参数和 RST 阶段,不是完整认证流量包。
|
||||
|
||||
## 条件性最小修复方案
|
||||
|
||||
### 1. 路由、代理或入口策略差异已证实
|
||||
|
||||
优先统一有问题的 IdP 入口、修正具体域名的代理/NO_PROXY 或后端节点配置;更新错误拒绝合法 ClientHello 的 TLS 终止器/WAF。证书仍按原 hostname 和有效 CA 验证,OIDC issuer 不随意改名。若是 `Silo` UA 被规则拒绝,调整该规则;不把全产品 UA 改回 MinIO。
|
||||
|
||||
这是配置层处理,可以不改 SILO。工作量取决于入口归属;成功标准是原版故障二进制在原网络环境中完成 discovery、JWKS 和完整登录,而非仅 curl 200。
|
||||
|
||||
### 2. 证实旧环境依赖 `tlsmlkem=0`,且 classical / default-curves 对照成功
|
||||
|
||||
最小代码候选是为外部 OpenID 请求使用 Go 的默认曲线集合,恢复已有 GODEBUG 选项的效果;保留当前 trust pool、SNI、密码套件、超时、代理及 HTTP/1.1 行为。候选代码如下,**已在隔离副本完成上述验证,尚未应用到产品工作区**:
|
||||
|
||||
```go
|
||||
func NewOpenIDHTTPTransport() *http.Transport {
|
||||
tr := NewHTTPTransport()
|
||||
tr.TLSClientConfig.CurvePreferences = nil
|
||||
return tr
|
||||
}
|
||||
```
|
||||
|
||||
当时的候选只将 `cmd/iam.go` 和 `cmd/config-current.go` 两处 OpenID 调用改用该 helper,通过现有 UA wrapper 传入 `openid.LookupConfig`。后续排查确认节点互联、复制、远端存储等连接也存在同样的问题,此局部方案已被 [SILO 跨组件修复](go127-stack.md) 取代;请勿再应用下面归档的 OIDC-only 补丁。
|
||||
|
||||
候选已在真实 transport 探针及第二轮完整 Linux Server 中验证:`CurvePreferences=nil` + Go 1.27.1 + `tlsmlkem=0` 会移除 ML-KEM,并通过 `reject-mlkem` fixture、配置添加和合成登录。它仍不能通过 `reject-mldsa` fixture,说明此方案针对的是一个明确分支。
|
||||
|
||||
影响需要明确:
|
||||
|
||||
- 不设置 GODEBUG 时会采用完整 Go 默认集合,实测额外提供 SecP256r1MLKEM768 和 SecP384r1MLKEM1024;这也是一项兼容性变化。第二轮默认 TLS 1.3 登录通过,仍不能称为完全无行为变化或所有曲线均已覆盖。
|
||||
- `GODEBUG` 是进程级设置,其他使用 Go 默认曲线的客户端也可能受到影响;本次 helper 的改动范围是 OpenID,但该环境选项本身不是逐 provider 开关。
|
||||
- 禁用 hybrid 后仍有标准 ECDHE/TLS 和证书验证,失去的是相应后量子密钥交换保护。优先修正入口,临时兼容设置应有撤销条件;不能自动遇到 reset 就降级重试。
|
||||
- 当前 Console IdP transport 本来使用默认曲线,同一进程中的该 GODEBUG 策略可以与之保持一致;第二轮合成登录链路已经验证,真实 IdP 和浏览器验收仍待进行。
|
||||
- 隔离源码副本中的候选实现及上述本地验证已完成;真实端点 A/B 证据、生产适用性和发布是尚未完成的步骤。
|
||||
|
||||
### 3. 未设置上述选项,或 classical 仍失败
|
||||
|
||||
不要套用方案 2。若同 IP、同代理、同 UA 下仅 Go 1.27 失败,优先收集入口对新签名算法/扩展的处理证据。`-tls12` 成功只能缩小到 ClientHello/TLS 特征集合;它同时移除 hybrid key share 和 TLS 1.3 特有签名编号,不能唯一证明 ML-DSA。
|
||||
|
||||
优先更新入口或获得 Go 上游可复现用例。只有真实证据证明 TLS 1.2 兼容模式是必要且有效、入口又短期无法处理时,才评估**明确限定于该 IdP**的临时 TLS 1.2 选项,并保留现代 ECDHE/AEAD 和证书验证。不要把 `MaxVersion=TLS12` 全局写死,不引入自定义 ClientHello/TLS 栈或未受支持的“关闭 ML-DSA”环境选项。该分支尚不足以确定补丁或工期。
|
||||
|
||||
### 4. 启动健壮性可单独改进
|
||||
|
||||
如需小幅改善可诊断性,应单独给 discovery/JWKS 加明确阶段标识和有限总请求期限,让 IAM 重试可感知取消;在请求完成前发生 stall 时及时释放资源。日志避免输出 client secret/token,错误保留原始 cause。保留已配置 OIDC 的失败关闭行为及 IdP 恢复后的自动初始化,不自动关闭 OIDC,也不把无限重试包装成根因修复。
|
||||
|
||||
这类改动约 0.5–1 个工程日,可分别回归 stalled body、取消、重试后恢复;它不会使持续 RST 的 TLS 连接成功,不应替代前面鉴别。
|
||||
|
||||
## 临时处置与验收边界
|
||||
|
||||
当前可沿用报告中已经恢复服务的旧版回退状态,尽快完成上述定位。不要将旧版本长期保留视为解决方案,也不要为了它整体降级新版本依赖。本次空数据实验不证明任意生产数据/配置的降级兼容;再次切换版本前应沿既有备份和升级边界操作。
|
||||
|
||||
修复应按以下范围验证,均从 PGSTY 源码本地构建:
|
||||
|
||||
1. 对确认的分支增加最小回归:实际 OpenID transport、匹配的 ClientHello/入口拒绝条件、默认设置与明确 opt-out;确保通用和 internode transport 不变。
|
||||
2. TLS 1.2(报告密码套件)与 TLS 1.3、有效自定义 CA、错误 CA 和 hostname 拒绝;适用时覆盖代理及多地址入口。不放宽证书、JWT 签名或 audience 等认证校验。
|
||||
3. 完整 Server 的 discovery 与 JWKS、IdP 中断后恢复、cluster health、受认证 Admin 操作;Console 添加配置、浏览器重定向、回调、令牌交换、STS 授权和登出。mc 添加/读取配置路径应一致。
|
||||
4. 在实际 Linux 容器/Pod 网络和每个 IdP 入口地址复验。证据应绑定最终 Server/Console/pkg/mc commit 和编译器;发布、镜像及生产可用性属于后续独立验收。
|
||||
|
||||
第二轮完成 Linux loopback 上的合成 OAuth token/STS 登录;本次没有真实 Keycloak、真实 Linux 故障路径、浏览器页面或生产数据升级测试。当前 main 对正常 fixture 成功,并不能据此宣告 #154 修复。下一项最有价值的新增证据是**实际进程的 `tlsmlkem` 设置和一个带 TLS 完成事件的同环境 GET trace**。
|
||||
|
||||
## 复现实验
|
||||
|
||||
从本任务 worktree 根目录执行;工具均使用本地源码,输出目录必须是新建临时目录。
|
||||
|
||||
```sh
|
||||
LAB_DIR=$(mktemp -d)
|
||||
CGO_ENABLED=0 GOWORK=off go build -mod=readonly -tags kqueue \
|
||||
-o "$LAB_DIR/silo" .
|
||||
CGO_ENABLED=0 GOWORK=off go build -mod=readonly -tags kqueue \
|
||||
-o "$LAB_DIR/probe" docs/investigations/issue-154/probe.go
|
||||
go build -o "$LAB_DIR/fixture" docs/investigations/issue-154/fixture.go
|
||||
go build -mod=readonly -o "$LAB_DIR/admin-check" docs/investigations/issue-154/admin-check.go
|
||||
"$LAB_DIR/fixture" -dir "$LAB_DIR/idp" > "$LAB_DIR/hello.jsonl" 2> "$LAB_DIR/fixture.log" &
|
||||
FIXTURE_PID=$!
|
||||
trap 'kill "$FIXTURE_PID" 2>/dev/null || true' EXIT
|
||||
attempt=0
|
||||
while [ ! -s "$LAB_DIR/idp/url" ] && [ "$attempt" -lt 50 ]; do
|
||||
sleep 0.1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
test -s "$LAB_DIR/idp/url" || exit 1
|
||||
# 私钥仅保存在 fixture 内存。
|
||||
OIDC_URL="$(cat "$LAB_DIR/idp/url")/.well-known/openid-configuration"
|
||||
export OIDC_URL
|
||||
"$LAB_DIR/probe" -ca "$LAB_DIR/idp/ca.pem"
|
||||
printf '%s' reject-mlkem > "$LAB_DIR/idp/mode"
|
||||
GODEBUG=tlsmlkem=0 "$LAB_DIR/probe" -ca "$LAB_DIR/idp/ca.pem"
|
||||
GODEBUG=tlsmlkem=0 "$LAB_DIR/probe" -ca "$LAB_DIR/idp/ca.pem" -default-curves
|
||||
printf '%s' reject-mldsa > "$LAB_DIR/idp/mode"
|
||||
"$LAB_DIR/probe" -ca "$LAB_DIR/idp/ca.pem" -classical
|
||||
"$LAB_DIR/probe" -ca "$LAB_DIR/idp/ca.pem" -tls12
|
||||
kill "$FIXTURE_PID"
|
||||
```
|
||||
|
||||
完整 Server 实验使用上述临时目录下的 `certs/CAs` 和数据目录,设置虚构的 `MINIO_IDENTITY_OPENID_CLIENT_ID`、fixture URL、临时 root 凭据,并显式指定 `--config-dir`、`--certs-dir`、loopback API/Console 地址。用 mode 文件切换 `reset`/`bad-jwks` 到 `normal`,同时检查 cluster health 和 Admin ListUsers;结果已保存在 `evidence.json`。`admin-check add` 只操作 `LAB_SERVER=127.0.0.1:port` 的实验实例,读取临时 `LAB_USER`、`LAB_PASSWORD`、`LAB_OIDC_URL`,使用虚构 OIDC secret。
|
||||
|
||||
跨版本构建时从各 tag `git archive` 获取源码,把 `probe.go` 放入该 module 根目录;旧版探针仅将 certs 导入替换为 `github.com/minio/pkg/v3/certs`,由旧版 go.mod 选择 PGSTY v3.11.0。分别强制 `GOTOOLCHAIN=go1.26.5` / `go1.27.1`,使用 `-mod=readonly`,不修改历史 go.mod。另将旧源码原依赖用 Go 1.27.1 构建,隔离工具链因素。若为 Linux 客户端构建探针,按已确认的架构设置 `GOOS=linux GOARCH=amd64` 或 `arm64`,不使用 Server 镜像代替。
|
||||
|
||||
### 第二轮 Linux 完整 Server 对照
|
||||
|
||||
从本任务根目录运行以下 Bash 命令。需要预先准备 Go 1.26.5、1.27.1 及源码依赖缓存,以及带 Python 3、curl/OpenSSL/HTTP2 的通用 Linux 基础镜像。`GOTOOLCHAIN` 明确选定编译器,`-mod=readonly` 保留依赖版本;实际测试构建用已安装工具链的绝对路径配合 `GOTOOLCHAIN=local`,等价地避免自动切换编译器。以下固定 `arm64` 与本次实验一致。
|
||||
|
||||
```bash
|
||||
LAB_DIR=$(mktemp -d)
|
||||
mkdir -p "$LAB_DIR/old" "$LAB_DIR/head" "$LAB_DIR/candidate" "$LAB_DIR/bin" "$LAB_DIR/out"
|
||||
git archive d88f46ccee345a9c2fabe2d221d9a9e56bc11aec | tar -x -C "$LAB_DIR/old"
|
||||
git archive d1105bbb3d4a0afa33b3a4ac11b821235038ed0e | tar -x -C "$LAB_DIR/head"
|
||||
git archive d1105bbb3d4a0afa33b3a4ac11b821235038ed0e | tar -x -C "$LAB_DIR/candidate"
|
||||
git -C "$LAB_DIR/candidate" apply "$PWD/docs/investigations/issue-154/openid-default-curves.patch"
|
||||
|
||||
(cd "$LAB_DIR/old" && CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 \
|
||||
GOTOOLCHAIN=go1.26.5 go build -mod=readonly -tags kqueue -o "$LAB_DIR/bin/old-go126" .)
|
||||
(cd "$LAB_DIR/old" && CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 \
|
||||
GOTOOLCHAIN=go1.27.1 go build -mod=readonly -tags kqueue -o "$LAB_DIR/bin/old-go127" .)
|
||||
(cd "$LAB_DIR/head" && CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 \
|
||||
GOTOOLCHAIN=go1.27.1 go build -mod=readonly -tags kqueue -o "$LAB_DIR/bin/head-go127" .)
|
||||
(cd "$LAB_DIR/candidate" && CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 \
|
||||
GOTOOLCHAIN=go1.27.1 go build -mod=readonly -tags kqueue -o "$LAB_DIR/bin/candidate-go127" .)
|
||||
CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 GOTOOLCHAIN=go1.27.1 \
|
||||
go build -mod=readonly -o "$LAB_DIR/bin/fixture" docs/investigations/issue-154/fixture.go
|
||||
CGO_ENABLED=0 GOWORK=off GOOS=linux GOARCH=arm64 GOTOOLCHAIN=go1.27.1 \
|
||||
go build -mod=readonly -o "$LAB_DIR/bin/admin-check" docs/investigations/issue-154/admin-check.go
|
||||
|
||||
# 本机已存在的通用 Debian 12 arm64 基础镜像;不拉取镜像,不映射端口。
|
||||
LINUX_BASE_IMAGE=sha256:307af7711e2e04ab75759cb42a1eef45c43c4404894c0e30dd19f742b107b922
|
||||
docker run --rm --pull=never --network none \
|
||||
--mount "type=bind,source=$LAB_DIR/bin,target=/lab/bin,readonly" \
|
||||
--mount "type=bind,source=$LAB_DIR/out,target=/lab/out" \
|
||||
--mount "type=bind,source=$PWD/docs/investigations/issue-154/run-linux.py,target=/lab/run-linux.py,readonly" \
|
||||
--entrypoint python3 "$LINUX_BASE_IMAGE" /lab/run-linux.py
|
||||
```
|
||||
|
||||
每组场景输出一行摘要,同时在独立输出目录保存 `result.json`。脚本用 `finally` 终止其 Server/fixture,容器结束自动删除。它没有导出会话 cookie、授权码、JWT、state 或临时密码;原始运行目录只用于该次隔离实验,交付证据只保留握手、结果和构建身份。
|
||||
@@ -1,41 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
// Loopback-only lab client for the Admin API used by Console's OIDC form.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
endpoint := os.Getenv("LAB_SERVER")
|
||||
host, _, err := net.SplitHostPort(endpoint)
|
||||
if err != nil || host != "127.0.0.1" {
|
||||
panic("LAB_SERVER must use IPv4 loopback")
|
||||
}
|
||||
a, err := madmin.New(endpoint, os.Getenv("LAB_USER"), os.Getenv("LAB_PASSWORD"), false)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if len(os.Args) > 1 && os.Args[1] == "add" {
|
||||
restart, err := a.AddOrUpdateIDPConfig(ctx, "openid", "local154", "enable=on client_id=local154 client_secret=local154-placeholder config_url="+os.Getenv("LAB_OIDC_URL"), false)
|
||||
fmt.Printf("add restart=%v err=%v\n", restart, err)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
users, err := a.ListUsers(ctx)
|
||||
fmt.Printf("list_users count=%d err=%v\n", len(users), err)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
// Run only with the public, synthetic CA made by fixture.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/pgsty/silo-pkg/v3/certs"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 3 {
|
||||
panic("usage: cert-roots synthetic-ca.pem explicit-ca-path-or-empty")
|
||||
}
|
||||
data, err := os.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
panic("expected public certificate")
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
roots, err := certs.GetRootCAs(os.Args[2])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_, err = certificate.Verify(x509.VerifyOptions{Roots: roots})
|
||||
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{
|
||||
"go": runtime.Version(), "os": runtime.GOOS,
|
||||
"explicit_ca": os.Args[2] != "", "trusted": err == nil,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,179 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
// Loopback-only synthetic OIDC/TLS fixture. Only disposable lab identities are used.
|
||||
// Rejection modes model hypotheses; they are not evidence about the user's IdP.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type observedConn struct {
|
||||
net.Conn
|
||||
readBytes int
|
||||
}
|
||||
|
||||
func (c *observedConn) Read(p []byte) (int, error) {
|
||||
n, e := c.Conn.Read(p)
|
||||
c.readBytes += n
|
||||
return n, e
|
||||
}
|
||||
func (c *observedConn) reset() { _ = c.Conn.(*net.TCPConn).SetLinger(0); _ = c.Conn.Close() }
|
||||
|
||||
type observedListener struct{ net.Listener }
|
||||
|
||||
func (l observedListener) Accept() (net.Conn, error) {
|
||||
c, e := l.Listener.Accept()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return &observedConn{Conn: c}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
dir := flag.String("dir", "", "isolated output directory for public CA, URL, and mode file")
|
||||
tls13 := flag.Bool("tls13", false, "allow TLS 1.3 in addition to the TLS 1.2 baseline")
|
||||
flag.Parse()
|
||||
if *dir == "" {
|
||||
panic("-dir required")
|
||||
}
|
||||
must(os.MkdirAll(*dir, 0700))
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
must(err)
|
||||
root := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "issue-154 local CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature}
|
||||
rootDER, err := x509.CreateCertificate(rand.Reader, root, root, &key.PublicKey, key)
|
||||
must(err)
|
||||
leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, NotBefore: root.NotBefore, NotAfter: root.NotAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature}
|
||||
leafDER, err := x509.CreateCertificate(rand.Reader, leaf, root, &key.PublicKey, key)
|
||||
must(err)
|
||||
must(os.WriteFile(filepath.Join(*dir, "ca.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER}), 0600))
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
must(err)
|
||||
base := "https://" + ln.Addr().String()
|
||||
must(os.WriteFile(filepath.Join(*dir, "url"), []byte(base), 0600))
|
||||
mode := func() string { b, _ := os.ReadFile(filepath.Join(*dir, "mode")); return strings.TrimSpace(string(b)) }
|
||||
var mu sync.Mutex
|
||||
log := func(v any) { mu.Lock(); defer mu.Unlock(); _ = json.NewEncoder(os.Stdout).Encode(v) }
|
||||
tc := &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{leafDER, rootDER}, PrivateKey: key}}, MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12, CurvePreferences: []tls.CurveID{tls.CurveP256}, CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384}}
|
||||
if *tls13 {
|
||||
tc.MaxVersion = tls.VersionTLS13
|
||||
}
|
||||
peerConfig := tc.Clone()
|
||||
peerConfig.NextProtos = []string{"h2", "http/1.1"}
|
||||
// net/http validates HTTP/2 support before GetConfigForClient; the fixture
|
||||
// then deliberately selects only the reported AES-256 suite.
|
||||
tc.CipherSuites = append(tc.CipherSuites, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256)
|
||||
tc.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
m := mode()
|
||||
c := chi.Conn.(*observedConn)
|
||||
log(map[string]any{"event": "hello", "mode": m, "bytes_read": c.readBytes, "curves": chi.SupportedCurves, "signatures": chi.SignatureSchemes, "alpn": chi.SupportedProtos, "versions": chi.SupportedVersions})
|
||||
reject := m == "reset" || m == "reject-mlkem" && slices.Contains(chi.SupportedCurves, tls.CurveID(4588)) || m == "reject-mldsa" && slices.Contains(chi.SignatureSchemes, tls.SignatureScheme(0x0904)) || m == "require-h2" && !slices.Contains(chi.SupportedProtos, "h2")
|
||||
if reject {
|
||||
c.reset()
|
||||
return nil, errors.New("synthetic ClientHello rejection")
|
||||
}
|
||||
return peerConfig, nil
|
||||
}
|
||||
server := &http.Server{TLSConfig: tc, ReadHeaderTimeout: 5 * time.Second}
|
||||
var codes sync.Map
|
||||
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log(map[string]any{"event": "request", "path": r.URL.Path, "protocol": r.Proto, "tls": r.TLS.Version, "cipher": r.TLS.CipherSuite, "resumed": r.TLS.DidResume, "ua": r.UserAgent()})
|
||||
if mode() == "reject-silo-ua" && strings.HasPrefix(r.UserAgent(), "Silo") {
|
||||
c, _, e := w.(http.Hijacker).Hijack()
|
||||
if e == nil {
|
||||
c.(*tls.Conn).NetConn().(*observedConn).reset()
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/authorize":
|
||||
q := r.URL.Query()
|
||||
redirect, err := url.Parse(q.Get("redirect_uri"))
|
||||
if err != nil || redirect.Scheme != "http" || redirect.Hostname() != "127.0.0.1" || redirect.Path != "/oauth_callback" || q.Get("client_id") != "local154" {
|
||||
http.Error(w, "loopback lab authorization only", 400)
|
||||
return
|
||||
}
|
||||
codeBytes := make([]byte, 18)
|
||||
_, err = rand.Read(codeBytes)
|
||||
must(err)
|
||||
code := base64.RawURLEncoding.EncodeToString(codeBytes)
|
||||
codes.Store(code, q.Get("nonce"))
|
||||
values := redirect.Query()
|
||||
values.Set("code", code)
|
||||
values.Set("state", q.Get("state"))
|
||||
redirect.RawQuery = values.Encode()
|
||||
http.Redirect(w, r, redirect.String(), http.StatusFound)
|
||||
case "/token":
|
||||
if r.Method != http.MethodPost || r.ParseForm() != nil {
|
||||
http.Error(w, "bad token request", 400)
|
||||
return
|
||||
}
|
||||
id, secret, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
id, secret = r.Form.Get("client_id"), r.Form.Get("client_secret")
|
||||
}
|
||||
nonce, found := codes.LoadAndDelete(r.Form.Get("code"))
|
||||
if id != "local154" || secret != "local154-placeholder" || !found || r.Form.Get("grant_type") != "authorization_code" {
|
||||
w.WriteHeader(400)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"})
|
||||
return
|
||||
}
|
||||
audience := id
|
||||
if mode() == "bad-audience" {
|
||||
audience = "different-lab-client"
|
||||
}
|
||||
claims, _ := json.Marshal(map[string]any{"iss": base, "sub": "local154-user", "aud": audience, "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), "policy": "readwrite", "nonce": nonce})
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"local-154","typ":"JWT"}`))
|
||||
payload := header + "." + base64.RawURLEncoding.EncodeToString(claims)
|
||||
hash := sha256.Sum256([]byte(payload))
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:])
|
||||
must(err)
|
||||
if mode() == "bad-signature" {
|
||||
signature[0] ^= 1
|
||||
}
|
||||
token := payload + "." + base64.RawURLEncoding.EncodeToString(signature)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": token, "id_token": token, "token_type": "Bearer", "expires_in": 3600})
|
||||
case "/.well-known/openid-configuration":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": base, "jwks_uri": base + "/jwks", "authorization_endpoint": base + "/authorize", "token_endpoint": base + "/token", "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, "scopes_supported": []string{"openid"}})
|
||||
case "/jwks":
|
||||
if mode() == "bad-jwks" {
|
||||
http.Error(w, "synthetic JWKS outage", 503)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{"kty": "RSA", "kid": "local-154", "use": "sig", "alg": "RS256", "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": "AQAB"}}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
fmt.Fprintln(os.Stderr, base)
|
||||
must(server.ServeTLS(observedListener{ln}, "", ""))
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
# Historical OIDC-only candidate; superseded by ../go127-stack.md. Do not apply on top of the stack fix.
|
||||
--- a/cmd/utils.go
|
||||
+++ b/cmd/utils.go
|
||||
@@ -654,6 +654,14 @@
|
||||
return NewHTTPTransportWithTimeout(1 * time.Minute)
|
||||
}
|
||||
|
||||
+// NewOpenIDHTTPTransport uses Go defaults for external identity-provider key exchange.
|
||||
+// This lets tlsmlkem/tlssecpmlkem configure their documented default sets.
|
||||
+func NewOpenIDHTTPTransport() *http.Transport {
|
||||
+ tr := NewHTTPTransport()
|
||||
+ tr.TLSClientConfig.CurvePreferences = nil
|
||||
+ return tr
|
||||
+}
|
||||
+
|
||||
// Default values for dial timeout
|
||||
const defaultDialTimeout = 5 * time.Second
|
||||
|
||||
--- a/cmd/iam.go
|
||||
+++ b/cmd/iam.go
|
||||
@@ -277,7 +277,7 @@
|
||||
for {
|
||||
if !openidInit {
|
||||
openidConfig, err := openid.LookupConfig(s,
|
||||
- xhttp.WithUserAgent(NewHTTPTransport(), func() string {
|
||||
+ xhttp.WithUserAgent(NewOpenIDHTTPTransport(), func() string {
|
||||
return getUserAgent(getMinioMode())
|
||||
}), xhttp.DrainBody, globalSite.Region())
|
||||
if err != nil {
|
||||
--- a/cmd/config-current.go
|
||||
+++ b/cmd/config-current.go
|
||||
@@ -352,7 +352,7 @@
|
||||
}
|
||||
case config.IdentityOpenIDSubSys:
|
||||
if _, err := openid.LookupConfig(s,
|
||||
- xhttp.WithUserAgent(NewHTTPTransport(), func() string {
|
||||
+ xhttp.WithUserAgent(NewOpenIDHTTPTransport(), func() string {
|
||||
return getUserAgent(getMinioMode())
|
||||
}), xhttp.DrainBody, globalSite.Region()); err != nil {
|
||||
return err
|
||||
@@ -1,165 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
// Diagnostic GET using the Server's actual transport constructor.
|
||||
// Build explicitly from the SILO module root; see ../issue-154.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/cmd"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/pgsty/silo-pkg/v3/certs"
|
||||
)
|
||||
|
||||
func main() {
|
||||
endpoint := flag.String("url", os.Getenv("OIDC_URL"), "discovery URL; no credentials or query string")
|
||||
ca := flag.String("ca", "", "same CA file or certs/CAs directory as Server")
|
||||
h2 := flag.Bool("h2", false, "diagnostic: opt in to HTTP/2")
|
||||
classical := flag.Bool("classical", false, "diagnostic: omit hybrid key exchange only")
|
||||
defaultCurves := flag.Bool("default-curves", false, "diagnostic: let Go choose curves and honor its GODEBUG defaults")
|
||||
tls12 := flag.Bool("tls12", false, "diagnostic: TLS 1.2 only; keeps certificate verification")
|
||||
direct := flag.Bool("direct", false, "diagnostic: bypass environment proxy")
|
||||
ip := flag.String("ip", "", "diagnostic: pin destination IP, preserving Host/SNI; requires -direct")
|
||||
fresh := flag.Bool("fresh", false, "diagnostic: close idle connections between requests")
|
||||
ua := flag.String("ua", "issue-154-probe", "HTTP User-Agent; supply actual Server UA to investigate a WAF")
|
||||
n := flag.Int("n", 1, "number of GETs (1 to 3)")
|
||||
flag.Parse()
|
||||
u, err := url.Parse(*endpoint)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || *n < 1 || *n > 3 {
|
||||
fmt.Fprintln(os.Stderr, "require an HTTPS URL without credentials/query/fragment and -n between 1 and 3")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *ip != "" && (!*direct || net.ParseIP(*ip) == nil) {
|
||||
fmt.Fprintln(os.Stderr, "-ip requires a literal IP and -direct")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *classical && *defaultCurves {
|
||||
fmt.Fprintln(os.Stderr, "choose at most one of -classical and -default-curves")
|
||||
os.Exit(2)
|
||||
}
|
||||
tr := cmd.NewHTTPTransport()
|
||||
tr.TLSClientConfig.RootCAs, err = certs.GetRootCAs(*ca)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "CA loading failed; check the local CA path")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *h2 {
|
||||
tr.ForceAttemptHTTP2 = true
|
||||
}
|
||||
if *classical {
|
||||
tr.TLSClientConfig.CurvePreferences = []tls.CurveID{tls.CurveP256, tls.X25519, tls.CurveP384, tls.CurveP521}
|
||||
}
|
||||
if *defaultCurves {
|
||||
tr.TLSClientConfig.CurvePreferences = nil
|
||||
}
|
||||
if *tls12 {
|
||||
tr.TLSClientConfig.MinVersion = tls.VersionTLS12
|
||||
tr.TLSClientConfig.MaxVersion = tls.VersionTLS12
|
||||
}
|
||||
if *direct {
|
||||
tr.Proxy = nil
|
||||
}
|
||||
if *ip != "" {
|
||||
base := tr.DialContext
|
||||
tr.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, e := net.SplitHostPort(address)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if host == u.Hostname() {
|
||||
address = net.JoinHostPort(*ip, port)
|
||||
}
|
||||
return base(ctx, network, address)
|
||||
}
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
var mu sync.Mutex
|
||||
log := func(format string, args ...any) { mu.Lock(); defer mu.Unlock(); fmt.Printf(format+"\n", args...) }
|
||||
log("go=%s os=%s arch=%s h2=%v classical=%v default_curves=%v tls12=%v", runtime.Version(), runtime.GOOS, runtime.GOARCH, *h2, *classical, *defaultCurves, *tls12)
|
||||
// Deliberately print no URL, headers, body, client ID, secret, or token.
|
||||
req, _ := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
proxy := "direct"
|
||||
if tr.Proxy != nil {
|
||||
p, e := tr.Proxy(req)
|
||||
if e != nil {
|
||||
log("proxy_selection_error=%T", e)
|
||||
os.Exit(2)
|
||||
}
|
||||
if p != nil {
|
||||
proxy = p.Scheme + " proxy (address omitted)"
|
||||
}
|
||||
}
|
||||
log("route=%s curves=%v", proxy, tr.TLSClientConfig.CurvePreferences)
|
||||
client := &http.Client{Transport: xhttp.WithUserAgent(tr, func() string { return *ua }), Timeout: 20 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
failed := false
|
||||
for i := 0; i < *n; i++ {
|
||||
if *fresh {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
log("request=%d", i+1)
|
||||
trace := &httptrace.ClientTrace{
|
||||
DNSDone: func(d httptrace.DNSDoneInfo) { log("dns_addresses=%v err=%s", d.Addrs, errorClass(d.Err)) },
|
||||
ConnectStart: func(network, addr string) { log("connect=%s %s", network, addr) },
|
||||
ConnectDone: func(_, addr string, e error) { log("connected=%s err=%s", addr, errorClass(e)) },
|
||||
TLSHandshakeStart: func() { log("tls_start") },
|
||||
TLSHandshakeDone: func(s tls.ConnectionState, e error) {
|
||||
log("tls_done=0x%x cipher=%s alpn=%q resumed=%v verified_chains=%d err=%s", s.Version, tls.CipherSuiteName(s.CipherSuite), s.NegotiatedProtocol, s.DidResume, len(s.VerifiedChains), errorClass(e))
|
||||
},
|
||||
GotConn: func(c httptrace.GotConnInfo) { log("got_conn=%s reused=%v", c.Conn.RemoteAddr(), c.Reused) },
|
||||
WroteRequest: func(w httptrace.WroteRequestInfo) { log("wrote_request err=%s", errorClass(w.Err)) },
|
||||
GotFirstResponseByte: func() { log("first_response_byte") },
|
||||
}
|
||||
r := req.Clone(httptrace.WithClientTrace(context.Background(), trace))
|
||||
resp, e := client.Do(r)
|
||||
if e != nil {
|
||||
log("get_error=%s", errorClass(e))
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
log("status=%d protocol=%s", resp.StatusCode, resp.Proto)
|
||||
_, e = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
if e != nil || resp.StatusCode != http.StatusOK {
|
||||
failed = true
|
||||
log("body_error=%s", errorClass(e))
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func errorClass(err error) string {
|
||||
if err == nil {
|
||||
return "none"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "deadline"
|
||||
}
|
||||
// Error text may contain a private URL. Emit only category and concrete type.
|
||||
category := "other"
|
||||
s := err.Error()
|
||||
for _, k := range []string{"connection reset by peer", "x509:", "TLS handshake timeout", "connection refused", "EOF"} {
|
||||
if strings.Contains(s, k) {
|
||||
category = k
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s (%T)", category, err)
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded, loopback-only full-Server comparison. See the investigation report.
|
||||
|
||||
Run in an isolated generic Linux container with locally built binaries in
|
||||
/lab/bin and a new disposable /lab/out. No customer identities or endpoints.
|
||||
"""
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path("/lab")
|
||||
OUT = ROOT / "out"
|
||||
BASE = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("MINIO_", "SILO_", "CONSOLE_"))
|
||||
and k.lower() not in {"http_proxy", "https_proxy", "all_proxy", "no_proxy", "godebug"}}
|
||||
|
||||
|
||||
def port():
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def request(url, opener=None, payload=None):
|
||||
headers = {"Origin": f"http://{urllib.parse.urlparse(url).netloc}"}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=None if payload is None else json.dumps(payload).encode(), headers=headers)
|
||||
try:
|
||||
response = (opener.open if opener else urllib.request.urlopen)(req, timeout=2)
|
||||
except urllib.error.HTTPError as err:
|
||||
response = err
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
return 0, {}, b""
|
||||
with response:
|
||||
return response.code, dict(response.headers), response.read(1 << 20)
|
||||
|
||||
|
||||
def stop(proc):
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=4)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2)
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def login(console, ca):
|
||||
jar = http.cookiejar.CookieJar()
|
||||
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
status, _, raw = request(console + "/api/v1/login", client)
|
||||
details = json.loads(raw)
|
||||
rules = details.get("redirectRules", [])
|
||||
assert status == 200 and len(rules) == 1, (status, details)
|
||||
auth_url = rules[0]["redirect"]
|
||||
provider = urllib.request.build_opener(NoRedirect(), urllib.request.HTTPSHandler(context=ssl.create_default_context(cafile=str(ca))))
|
||||
status, headers, _ = request(auth_url, provider)
|
||||
callback = headers.get("Location", headers.get("location", ""))
|
||||
assert status == 302 and callback.startswith(console + "/oauth_callback?"), (status, callback)
|
||||
values = urllib.parse.parse_qs(urllib.parse.urlparse(callback).query)
|
||||
callback_status, _, _ = request(callback, client)
|
||||
status, _, _ = request(console + "/api/v1/login/oauth2/auth", client,
|
||||
{"code": values["code"][0], "state": values["state"][0]})
|
||||
buckets_status, _, _ = request(console + "/api/v1/buckets", client)
|
||||
# Do not record cookies, codes, JWTs, or the state value.
|
||||
return {"callback_status": callback_status, "login_status": status,
|
||||
"buckets_status": buckets_status, "session_cookie": any(c.name == "token" for c in jar)}
|
||||
|
||||
|
||||
def run(name, binary, mode="normal", debug=None, tls13=False,
|
||||
expected=True, trusted=True, oidc=True, oauth=False, add=False):
|
||||
d = OUT / name
|
||||
(d / "certs/CAs").mkdir(parents=True, exist_ok=False)
|
||||
idp = d / "idp"
|
||||
idp.mkdir()
|
||||
(idp / "mode").write_text(mode)
|
||||
with (d / "fixture.jsonl").open("w") as events, (d / "fixture.stderr").open("w") as errors, (d / "server.log").open("w") as logs:
|
||||
fixture = subprocess.Popen([str(ROOT / "bin/fixture"), "-dir", str(idp), *(["-tls13"] if tls13 else [])], env=BASE, stdout=events, stderr=errors)
|
||||
server = None
|
||||
try:
|
||||
until = time.monotonic() + 5
|
||||
while not (idp / "url").is_file() and time.monotonic() < until:
|
||||
time.sleep(.05)
|
||||
assert (idp / "url").is_file(), "fixture did not initialize"
|
||||
url = (idp / "url").read_text() + "/.well-known/openid-configuration"
|
||||
if trusted:
|
||||
shutil.copyfile(idp / "ca.pem", d / "certs/CAs/lab.pem")
|
||||
sport, cport = port(), port()
|
||||
address = f"127.0.0.1:{sport}"
|
||||
api, console = "http://" + address, f"http://127.0.0.1:{cport}"
|
||||
password = secrets.token_urlsafe(24)
|
||||
env = dict(BASE, MINIO_ROOT_USER="local154", MINIO_ROOT_PASSWORD=password, MINIO_BROWSER="on")
|
||||
if debug:
|
||||
env["GODEBUG"] = debug
|
||||
if oidc:
|
||||
env.update(MINIO_IDENTITY_OPENID_CONFIG_URL=url,
|
||||
MINIO_IDENTITY_OPENID_CLIENT_ID="local154",
|
||||
MINIO_IDENTITY_OPENID_CLIENT_SECRET="local154-placeholder",
|
||||
MINIO_IDENTITY_OPENID_REDIRECT_URI=console + "/oauth_callback")
|
||||
server = subprocess.Popen([str(ROOT / "bin" / binary), "--config-dir", str(d / "config"), "--certs-dir", str(d / "certs"), "server", "--address", address, "--console-address", f"127.0.0.1:{cport}", str(d / "data")], env=env, stdout=logs, stderr=subprocess.STDOUT)
|
||||
until = time.monotonic() + 15
|
||||
while time.monotonic() < until:
|
||||
assert server.poll() is None, "Server exited; inspect its local log"
|
||||
status, _, _ = request(api + "/minio/health/cluster")
|
||||
if status == 200 and request(console)[0] == 200:
|
||||
break
|
||||
if not expected and (d / "server.log").read_text().count("Waiting for OpenID") >= 2:
|
||||
break
|
||||
time.sleep(.1)
|
||||
result = {"case": name, "binary": binary, "mode": mode, "godebug": debug, "tls13": tls13,
|
||||
"cluster": request(api + "/minio/health/cluster")[0],
|
||||
"ready": request(api + "/minio/health/ready")[0], "console": request(console)[0]}
|
||||
assert result["cluster"] == (200 if expected else 503), result
|
||||
aenv = dict(BASE, LAB_SERVER=address, LAB_USER="local154", LAB_PASSWORD=password, LAB_OIDC_URL=url)
|
||||
if expected:
|
||||
admin = subprocess.run([str(ROOT / "bin/admin-check")], env=aenv, capture_output=True, text=True, timeout=7)
|
||||
result["admin_list_ok"] = admin.returncode == 0
|
||||
assert result["admin_list_ok"], admin.stdout
|
||||
curl = subprocess.run(["curl", "--cacert", str(idp / "ca.pem"), "--http2", "--max-time", "3", "-sS", "-o", "/dev/null", "-w", "%{http_code} %{http_version}", url], env=BASE, capture_output=True, text=True, timeout=5)
|
||||
result["curl"] = {"exit": curl.returncode, "status_protocol": curl.stdout}
|
||||
if add:
|
||||
attempt = subprocess.run([str(ROOT / "bin/admin-check"), "add"], env=aenv, capture_output=True, text=True, timeout=7)
|
||||
result["add_ok"] = attempt.returncode == 0
|
||||
result["add_reset"] = "connection reset by peer" in attempt.stdout
|
||||
assert result["add_ok"] == binary.startswith("candidate"), result
|
||||
if oauth:
|
||||
result["oauth"] = login(console, idp / "ca.pem")
|
||||
assert result["oauth"]["login_status"] == 204 and result["oauth"]["buckets_status"] == 200, result
|
||||
for bad in ("bad-signature", "bad-audience"):
|
||||
(idp / "mode").write_text(bad)
|
||||
result[bad] = login(console, idp / "ca.pem")
|
||||
assert result[bad]["login_status"] >= 400 and result[bad]["buckets_status"] >= 400, result
|
||||
# Public handshake metadata only; no authorization parameters.
|
||||
result["events"] = [json.loads(line) for line in (d / "fixture.jsonl").read_text().splitlines()]
|
||||
(d / "result.json").write_text(json.dumps(result, indent=2) + "\n")
|
||||
print(json.dumps({k: v for k, v in result.items() if k != "events"}), flush=True)
|
||||
finally:
|
||||
if server is not None:
|
||||
stop(server)
|
||||
stop(fixture)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run("old126-normal", "old-go126")
|
||||
run("old127-normal", "old-go127")
|
||||
run("old126-compat", "old-go126", "reject-mlkem", "tlsmlkem=0")
|
||||
run("old127-compat", "old-go127", "reject-mlkem", "tlsmlkem=0", expected=False)
|
||||
run("head127-compat", "head-go127", "reject-mlkem", "tlsmlkem=0", expected=False)
|
||||
run("candidate127-compat-login", "candidate-go127", "reject-mlkem", "tlsmlkem=0", oauth=True)
|
||||
run("candidate127-no-optout", "candidate-go127", "reject-mlkem", expected=False)
|
||||
run("candidate127-tls13-login", "candidate-go127", tls13=True, oauth=True)
|
||||
run("candidate127-untrusted", "candidate-go127", trusted=False, expected=False)
|
||||
run("candidate127-mldsa", "candidate-go127", "reject-mldsa", "tlsmlkem=0", expected=False)
|
||||
run("head127-add", "head-go127", "reject-mlkem", "tlsmlkem=0", oidc=False, add=True)
|
||||
run("candidate127-add", "candidate-go127", "reject-mlkem", "tlsmlkem=0", oidc=False, add=True)
|
||||
@@ -1,103 +0,0 @@
|
||||
# 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 桶、删除后的桶及混合版本。全站点升级后的实际断线重连/重启验证应保留独立证据。当前结论是“缺陷与实施范围已核实”,不是“修复完成”。
|
||||
@@ -1,123 +0,0 @@
|
||||
# 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 阻断。四轮原文、版本快照、逐项处置与模型调用证据均保留。方案可以进入实现;当前只完成方案和诊断,仍须通过上述实现期验证,不能据此认定问题已修复或可关闭。
|
||||
@@ -1,85 +0,0 @@
|
||||
# #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 字段经过整理;哈希清单区分原始产物与归档副本,不能将整理后的报告哈希冒充原文件哈希。
|
||||
@@ -1,437 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
=== 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
|
||||
@@ -1,7 +0,0 @@
|
||||
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
|
||||
@@ -1,78 +0,0 @@
|
||||
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
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
@@ -1,7 +0,0 @@
|
||||
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
|
||||
@@ -1,2 +0,0 @@
|
||||
Silo delivery and runtime rebrand checks passed
|
||||
docker entrypoint argv compatibility tests passed
|
||||
@@ -1,292 +0,0 @@
|
||||
# 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 由作者判断,可留作后续。
|
||||
@@ -1 +0,0 @@
|
||||
ok github.com/minio/minio/cmd 6.864s
|
||||
@@ -1 +0,0 @@
|
||||
ok github.com/minio/minio/cmd 20.694s
|
||||
@@ -1,57 +0,0 @@
|
||||
--- 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
@@ -1,8 +0,0 @@
|
||||
--- 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
|
||||
@@ -1,8 +0,0 @@
|
||||
--- 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
|
||||
@@ -1,18 +0,0 @@
|
||||
--- 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
|
||||
@@ -1,174 +0,0 @@
|
||||
# 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。
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{"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
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"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
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{"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
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{"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
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"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
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"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
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"reviewed_production_head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"final_head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
|
||||
"changed_files": [
|
||||
"cmd/site-replication-metadata-gate_test.go",
|
||||
"cmd/site-replication-metadata-heal_test.go"
|
||||
],
|
||||
"difference": "Test-only gofumpt formatting and equivalent if-chain to switch; no production or server documentation changes",
|
||||
"patch": "test-style.diff"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"vet",
|
||||
"./..."
|
||||
],
|
||||
"cwd": "$SILO_WORKTREE",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"started_at": "2026-09-12T09:11:55.197305+00:00",
|
||||
"environment": {
|
||||
"TMPDIR": "$TMPDIR",
|
||||
"CGO_ENABLED": "0"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"duration_seconds": 4.024
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{"command": ["go", "vet", "./..."], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:55.197305+00:00", "environment": {"TMPDIR": "$TMPDIR", "CGO_ENABLED": "0"}}
|
||||
|
||||
EXIT=0
|
||||
@@ -1,111 +0,0 @@
|
||||
已完成定向核对。以下为最终报告。
|
||||
|
||||
---
|
||||
|
||||
# SILO #77 第三轮独立对抗性验收(固定 `fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025`)
|
||||
|
||||
**结论:GO_WITH_NONBLOCKING_NOTES — 阻断数 0**
|
||||
|
||||
范围限定本轮差异(`round-3/change.diff`:2 个生产文件共约 12 行、5 个测试文件、1 个 README 段落)。未重复前两轮已完成的全量审查。
|
||||
|
||||
---
|
||||
|
||||
## 一、逐条 NB 判定
|
||||
|
||||
### NB-2 — 关闭(三个边界经代码级核对成立)
|
||||
|
||||
生产改动在 `cmd/site-replication-metadata.go:134-160`。逐边界挑战结果:
|
||||
|
||||
1. **`unreachable` 仅在存在候选时输出 —— 行为字节级不变**。改前 `if !found { return nil }` 就在 unreachable 循环之前,因此该日志从来只在 `found==true` 时产生;把它包进 `if found {}`(`:138-144`)是纯重构,没有新增也没有删除任何一条 unreachable。这一点上一轮报告与提交说明都没讲清,值得记录:**这不是行为修改**。
|
||||
2. **空基线/缺桶安静 —— 由 `newBucketConfigState` 的取值规则保证**(`cmd/bucket-metadata-replication.go:224-227`):`at.IsZero() → at = created`。因此"字段时间为零但 `CreatedAt` 非零"的合法基线必然 `valid=true`,不触发 `:154`;缺桶(全零)`data` 空且 `at` 零,也不触发。只有三类会响:`created==0 且 at/data 非空`、`at < created`、解码/解析报错。与新测试四个子用例一一对应,无第四种漏网情形。
|
||||
3. **无有效来源不发 RPC、不落盘 —— 成立**。`!found` 在 `:159` 即 `continue`,位于 `:177`(本地 `updateAndParseMetadata`)与 `:182`(`SRPeerReplicateBucketMeta`)之前。测试用 `len(events()) != 0` 从对端侧反证(`heal_test.go:162-164`)。
|
||||
4. **去重 —— 成立**。`logOnceIf`(`internal/logger/logonce.go:95-120`)按 `id` **且**错误正文相同才抑制;key 为 `bucket-metadata/<bucket>/<file>/<reason>`,正文只含 reason,时间/peer 全在 ReqInfo 属性里(`site-replication-metadata.go:33-43`)。所以多 peer 同因塌缩为一条,跨因不互相顶替。测试的 `for range 2` 钉住了这一点。
|
||||
|
||||
**必要性证据成立**:`round-2/no-source-before.log` 用同一份最终测试叠加旧生产码,`unknown-created`/`malformed`/`before-created` 三个子用例在 `heal_test.go:155` 报 `got 0 diagnostics, want 1`,`empty-baseline` 两侧都安静。行号与当前文件精确吻合,是真实的前后对照,不是草稿产物。
|
||||
|
||||
**接受的边界(非阻断,已成文)**:在全站 `Created==0` 的遗留桶上(滚动升级的典型形态),每个"有值字段"现在每小时多出一条 `indeterminate`,此前完全静默。这正是 NB-2 要的可行动信号,README:123-125 与设计记录 §limits-5 都写明了。量级与 NB-3 已接受的 `unreachable` 同阶。
|
||||
|
||||
**性能挑战结果:不构成问题**。`!found` 时主循环多做一遍 `bucketConfigStateFromInfo`,但空载荷在 `site-replication-metadata.go:75` 与 `bucket-metadata-replication.go:178-180` 两处短路,连 base64 都不解——而空载荷恰好是 `!found` 的主体人群。上一轮建议的"缓存解码结果"因此确实可以不做。
|
||||
|
||||
### NB-8 — 关闭,nil/zero/local 三分支均无错误时间与 panic
|
||||
|
||||
`cmd/bucket-metadata-sys.go:186-192`:
|
||||
|
||||
- `sourceTime == nil` → `at` 为零值,打印 `0001-01-01`,与改前一致,无解引用;
|
||||
- `sourceTime != nil && IsZero()` → 同上,不会把零值当作有效来源时间;
|
||||
- `sourceTime` 非零且为本地时区 → `logBucketConfigReplication:38` 统一 `at.UTC().Format(...)`,不产生偏移时间;
|
||||
- `meta.Created`:该分支下必为零(`ensureBucketMetadataCreated:278-280` 在非零时提前返回 nil),所以 `created` 属性如实;
|
||||
- 去重不受影响:错误正文未变(见上)。
|
||||
|
||||
**一处同类残留(本轮之前既有,非本轮引入)**:`site-replication-metadata.go:155` 在 `currentErr != nil` 时 `current` 是零值结构体,于是"解析失败"的日志同样丢掉了对端上报的真实 `at`。最小修复需要一个不经解码就取 `(bucket,file) → at` 的取值函数,约 10 行。可选,不必本轮做。
|
||||
|
||||
### NB-4 / NB-5 — 关闭;全仓无漏引用,编译成立;有一项必须如实指出的门禁事实
|
||||
|
||||
- **漏引用**:`isBucketMetadataEqual` 在 Go 代码中零引用,仅 `docs/site-replication/CORS-LWW-DESIGN.md:84` 提及,且位于 "Confirmed Failures in the **Pre-Fix** Candidate" 小节,是对既往缺陷的历史陈述,不是对现有代码的断言,无需改。
|
||||
- **编译**:两个被删用例所在文件的 `encoding/base64` 导入仍在被使用(`site-replication_test.go:21` → `:80/:106`;`bucket-cors-site-replication_test.go:22` → 20 余处),不存在第二次漏删。`round-3/final-build.log`、`final-vet.log` 在本轮固定 SHA 上 `go build ./...` / `go vet ./...` 均 `EXIT=0`。**当前树完整编译成立**。
|
||||
- **真实 CORS 路径保留**:被删的是纯 helper 单测;同一性质(base64 必须按字节严格比较)现由 `TestPeerBucketCorsRejectsNonCanonicalBase64`(`bucket-cors-site-replication_test.go:782-815`)经真实 `PeerBucketCorsConfigHandler` 与 legacy bulk 两条生产路径覆盖,并断言被拒后元数据未变;heal 侧用 `corsReplicationStateFromInfo`/`equalCORSReplicationStates`(`site-replication.go:4903-4904`)而非被删函数。**无覆盖损失。**
|
||||
- **logger 全局状态与 race**:`testlogger.T` 是进程级单例但用 atomic(`testlogger.go:49,78-85`),本身无 race;`logger.DisableLog` 是普通全局 bool(`logger.go:407` 读、`test-utils_test.go:105` 默认置 true),两个新用例都 `defer` 还原。`round-3/final-target-race.log` 在本轮固定 SHA 上对包含全部新用例的集合 `-race` 跑通(20.9s,EXIT=0)。**已验证,非阻断。**
|
||||
- **两项潜在脆弱点(无阻断、目前未触发,仅备案)**:(a) `DisableLog=false` 期间全局 sink 会捕获进程内**任何**系统日志,而 `heal_test.go:97` 对任何非预期行直接 `Fatalf`——若窗口内有后台 goroutine 输出日志即失败。最小加固:分类前先 `strings.Contains(line, bucket)` 过滤外来日志。(b) `logOnce.IDMap` 是进程级、每小时才清(`logonce.go:123-131`),这两个用例之所以安全,是因为 `ExecObjectLayerAPITest` 对两种后端各自 `getRandomBucketName()`(`test-utils_test.go:1556`,分别在 `:1771`/`:1801` 调用)使日志 key 天然唯一——这是隐式依赖,值得在注释里点一句。
|
||||
- **证据精度**:`round-2/confirmed-diagnostics-before.log` 在 `heal_test.go:80`(**第一条**断言)就 Fatal,因此它只证明了 F3 的"空基线必须安静"这一半;WARNING 级别与按因去重(`:99-105`)在旧码上并未被执行到,**没有**对应的失败演示。应如实这么写,不能说成"F3 已被完整回归保护"。
|
||||
|
||||
- **必须指出的门禁事实**:`round-3/pre-style-lint.log` 显示本轮固定提交 `fcbb93e8` 的 `make lint` **失败(EXIT=2,3 issues)**,全部落在本轮新增的两个测试文件:`cmd/site-replication-metadata-heal_test.go:92:5`(gocritic ifElseChain)、`cmd/site-replication-metadata-gate_test.go:282:1` 与 `heal_test.go:138:1`(gofumpt)。我通读了修复补丁 `test-style.diff`:switch 分支顺序与函数体同 if-else 完全一致,另两处仅为结构体字面量换行,**语义等价、仅测试文件**,已在后续提交 `461e9a72` 修掉(该提交的 lint 复跑在证据中仍在进行)。结论:不影响行为验收,但"`fcbb93e8` 本身通过全部仓库门禁"这句话不成立,不应这么写。
|
||||
|
||||
### NB-6 — 关闭为**出站 wire 证明**;不得称作双站点证明(已如实区分)
|
||||
|
||||
`TestBucketMetadataInitialSyncPhysicalCreated`(`gate_test.go:245-303`)确实驱动真实源 ObjectLayer 走完整出站序列:`syncToAllPeers`(`site-replication.go:2141-2153`)→ `ensureBucketMetadataCreated` 从真实盘目录 mtime 恢复 → `MakeBucketHook`(`:821-826` 缓存零值回落)→ wire 上的 `createdAt` 参数;再经 `initialBucketConfigReplicationEvent` 发出 Tags 事件。断言 `createdAt == physical`、Tags 载荷为 base64(XML) 且 `UpdatedAt == physical`(历史字段以 Created 作源时间)。前后对照成立:`round-2/initial-sync-before.log` 在 `gate_test.go:289` 报 `"0001-01-01T00:00:00Z"`,行号与最终文件精确吻合。
|
||||
|
||||
**必须如实标注的范围限制(我独立核出,不在提交说明里)**:该用例的 `c.state.Peers` 只含 `"initial-peer"`(`gate_test.go:282-283`),而 `concDo`(`site-replication.go:2497-2515`)只对 `depID == globalDeploymentID()` 的条目执行 `selfActionFn`。该条目不存在,因此真正把 `Created` 落到**本地盘**的 `PeerBucketMakeWithVersioningHandler`(`site-replication.go:954-966` 的 `SetCreatedAt` + `saveMetadata`)**在本用例中根本没有执行**。也就是说:
|
||||
|
||||
- README:110-114 "Recovery happens … during initial site sync … records the physical time" 在生产路径上成立(经 `concDo` 自身分支),但**不被这个测试覆盖**;
|
||||
- 本用例证明的是出站内容,不是任何一侧的落盘,更不是双站点收敛。
|
||||
|
||||
设计记录 §205 已经把这个区分写对了("它证明出站内容…二者不能混称为同一验收"),README 也没有声称测试覆盖,因此**不构成不实陈述**。若要补齐本地落盘那一半:在该 Peers map 中加入 `globalDeploymentID(): {}` 并在末尾加一次 `readBucketMetadata` 断言 `Created==physical`,约 3 行。
|
||||
|
||||
### NB-7 — 关闭,两半都钉住了
|
||||
|
||||
`site-replication-metadata_test.go:482-490`。沿 `applyBucketConfig`(`bucket-metadata-replication.go:294-316`)+ `compareBucketConfigStates`(`:239-245`)核对:`incoming.valid=true` 对 `local.valid=false` 直接返回 1,因此 `changed=true`,随后断言指针别名上的 `*at == incomingAt`、`*data == incoming`——载荷与时间都断言了。对 `bucketTaggingConfig` 这是"活标签覆盖被保留的旧世代删除记录",语义与注释一致;对 `bucketSSEConfig` 载荷恰好与原值相同,那里真正承载证明的是时间戳断言。两点说明:断言层级是 `applyBucketConfig`(即写路径 `bucket-metadata-sys.go:206` 调用的同一决策函数),属单元级钉桩而非端到端,这是可接受的取舍。
|
||||
|
||||
### NB-1 — 判定为**如实保留边界**,本轮不存在必须修的回归
|
||||
|
||||
本轮对 NB-1 **没有任何生产代码改动**(仅 README:106-114 + 新用例),因此按构造不可能引入回归。我独立复核了被保留的行为与被拒方案:
|
||||
|
||||
- 保留跳过:`bucket-metadata-sys.go:202-205` 在 `updatedAt.Before(meta.Created)` 时记 `before-created` 并 `return nil`(不写、不报错);heal 侧同因在 `site-replication-metadata.go:162-164`。
|
||||
- 拒绝 `min(recovered, sourceTime)` 的理由我认为**成立**:单个事件的时间戳无法区分"本世代的较早事件"与"上一个已删除世代的事件",凭一个事件下调 `Created` 会让删除前的状态复活。这里选安全而非活性是对的,且具备自愈性(下一次 ≥ physical 的写入即确立身份)与可观测性(有界 warning)。
|
||||
- 新用例 `TestPeerBucketMetadataPhysicalCreatedBoundary`(`gate_test.go:305-335`)用真实盘 mtime 同时钉住两侧:`physical-1h` 的 peer 事件 `err==nil` 且 `bucketConfigWriteCounter` 计数为 **0**(是跳过不是失败);随后本地 `Update` 使盘上 `Created==physical` 且 `at > physical`。断言选得准确。
|
||||
- 残留(已成文):唯一持有者的源时间早于恢复值的字段,不经人工介入不会收敛;且跳过路径**不落盘恢复值**(`:204` 早于 `saveMetadata`),每次事件都重新探测 mtime——README:112-113 用 "first **successful** configuration write" 措辞正确地表达了这一点。
|
||||
|
||||
### NB-3 — 接受现状,不要求扩展
|
||||
|
||||
`site-replication-metadata.go:138-144` 仍按桶×字段×原因输出,`healBuckets` 每桶调用六次(`site-replication.go:4753-4760` → `:4851-4935`)。设计记录 §limits-5 已明确声明该量级会随桶与有值字段增长。未构成阻断,也未破坏任何既定契约,按约定不要求做站点级聚合/限流改造。
|
||||
|
||||
**一处文档精度小瑕**:§limits-5 把"日志量随桶与字段增长"这句只挂在"不可达站点"上;本轮之后,遗留零 `Created` 集群里的 `indeterminate` 同样按桶×字段增长。加半句即可,非阻断。
|
||||
|
||||
### F5 — 确认无外溢
|
||||
|
||||
本轮 diff 中没有任何 `canonicalBucketPolicy` 接入点的增删;只有设计记录 F5 一行表述更新(`bucket-metadata-convergence.md:182` / `.zh.md:185`),与"上轮已修正原判断、不再改代码"一致。
|
||||
|
||||
---
|
||||
|
||||
## 二、确证问题清单(均非阻断)
|
||||
|
||||
| # | 位置 | 触发条件 | 最小修复 |
|
||||
|---|---|---|---|
|
||||
| 1 | `cmd/site-replication-metadata-heal_test.go:92`、`:138`;`cmd/site-replication-metadata-gate_test.go:282` | 在固定提交 `fcbb93e8` 上执行 `make lint` | 已在 `461e9a72` 修掉(语义等价、仅测试文件);本轮 SHA 需注明 lint 未过 |
|
||||
| 2 | `cmd/site-replication-metadata.go:155` | heal 遇到解码/解析失败的对端状态 | 日志属性中 `sourceTime` 恒为 `0001-01-01`;需一个免解码的 `(bucket,file)→at` 取值函数,约 10 行。可选 |
|
||||
| 3 | `cmd/site-replication-metadata-heal_test.go:97` | 日志窗口内任一后台 goroutine 输出日志 | 分类前加 `strings.Contains(line, bucket)` 过滤,1 行 |
|
||||
| 4 | `cmd/site-replication-metadata-gate_test.go:282-283` | —(覆盖缺口,非缺陷) | 若要覆盖 README 的"初次同步落盘 Created"一半:Peers 加入 `globalDeploymentID()` 并补一次 `readBucketMetadata` 断言,约 3 行 |
|
||||
| 5 | 设计记录 §limits-5 | — | "随桶与字段增长"补上 `indeterminate` 这一类,半句 |
|
||||
|
||||
**证据状态如实说明**:`round-3/final-cmd.log` 只有头行,**完整 `go test ./cmd/` 尚未产生结果**,不可称作已通过;已完成且可复核的是 `final-build`/`final-vet`(EXIT=0)、`final-target-race`(EXIT=0)、`pre-style-lint`(EXIT=2,见上)。`461e9a72` 的 `make lint` 复跑同样在进行中。
|
||||
|
||||
---
|
||||
|
||||
## 三、最小性 / 充分性 / 必要性(独立评价)
|
||||
|
||||
**最小性:是,且是本系列迄今最克制的一轮。** 生产侧净变化只有两处共约 12 行:`found` 条件位移(未引入解码缓存、未新增结构体、未新增日志原因),以及诊断里一个已有指针的解引用。没有新 schema、新 wire 字段、新锁、新框架。我找不到可以无损删除的部分:删掉 `!found` 分支就退回 NB-2 的静默,删掉 `at` 就退回 NB-8。测试删除项(`isBucketMetadataEqual` 及其两个专属用例)经全仓核对确认无生产调用者、无覆盖损失。
|
||||
|
||||
**充分性:对本轮声称的目标充分;两处覆盖如实低于文字。** 新增三个用例都有真实前后对照或真实后端驱动,`-race` 通过。低于文字的两处均已在上文点名:F3 的级别/去重断言在旧码上未被演示失败;初次同步的本地落盘半边未被测试覆盖(生产路径本身成立)。二者都是证据强度问题,不是行为问题,且设计记录没有把它们说过头。
|
||||
|
||||
**必要性:是。** NB-2 与 NB-6 各有"最终测试 + 旧生产码"的复现日志且行号可逐条核对(`no-source-before.log`、`initial-sync-before.log`);NB-8 是一行可读性缺陷的直接消除;NB-5 是删除无调用者代码;NB-7 是补一侧期望值;NB-1/NB-3 明确不改代码,只做文档与钉桩。没有一项属于"顺手扩张"。
|
||||
|
||||
**放行建议**:以 `461e9a72`(lint 修复后的等价树)为合并基准,并在合入前等 `go test ./cmd/` 与 `make lint` 两条复跑各自出结果;上表 5 项可全部进 backlog。
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
|
||||
"base": "62cf066ff529c7d281703daa365f555cebba717a",
|
||||
"status": "completed",
|
||||
"started_at": "2026-09-12T09:10:48.496423+00:00",
|
||||
"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"
|
||||
],
|
||||
"completed_at": "2026-09-12T09:25:59.617894+00:00",
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 777403,
|
||||
"num_turns": 61,
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"used_tools": {
|
||||
"Read": 33,
|
||||
"Grep": 23,
|
||||
"Glob": 4
|
||||
},
|
||||
"stream_sha256": "a9a8784005abe5f02c1847e6d2a42890a309dbf34604a3756c456f1fc29c612a",
|
||||
"review_sha256": "9106938a7a34cfbc6a11d372dd5ffb71ae7417a77a56d10bbed178f539de8fba",
|
||||
"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.",
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json.",
|
||||
"session_id": "3b2ce46c-226b-4c52-b004-c727303c4a9b",
|
||||
"session_id_source": "system init event in the stream identified by stream_sha256"
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
diff --git a/cmd/site-replication-metadata-gate_test.go b/cmd/site-replication-metadata-gate_test.go
|
||||
index 49818b548..de9a37bb6 100644
|
||||
--- a/cmd/site-replication-metadata-gate_test.go
|
||||
+++ b/cmd/site-replication-metadata-gate_test.go
|
||||
@@ -279,8 +279,10 @@ func TestBucketMetadataInitialSyncPhysicalCreated(t *testing.T) {
|
||||
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}}}}
|
||||
+ 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)
|
||||
}
|
||||
diff --git a/cmd/site-replication-metadata-heal_test.go b/cmd/site-replication-metadata-heal_test.go
|
||||
index bedb3ea5e..6c00fb8c1 100644
|
||||
--- a/cmd/site-replication-metadata-heal_test.go
|
||||
+++ b/cmd/site-replication-metadata-heal_test.go
|
||||
@@ -89,11 +89,12 @@ func TestHealBucketConfigDiagnostics(t *testing.T) {
|
||||
defer capture.mu.Unlock()
|
||||
var unreachable, peerError int
|
||||
for _, line := range capture.lines {
|
||||
- if strings.Contains(line, "bucket metadata replication: unreachable") {
|
||||
+ switch {
|
||||
+ case strings.Contains(line, "bucket metadata replication: unreachable"):
|
||||
unreachable++
|
||||
- } else if strings.Contains(line, "bucket metadata replication: peer-error") {
|
||||
+ case strings.Contains(line, "bucket metadata replication: peer-error"):
|
||||
peerError++
|
||||
- } else {
|
||||
+ default:
|
||||
t.Fatalf("unexpected diagnostic: %s", line)
|
||||
}
|
||||
if !strings.HasPrefix(line, "WARNING:") {
|
||||
@@ -135,11 +136,13 @@ func TestHealBucketConfigWithoutSourceDiagnostics(t *testing.T) {
|
||||
// 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": {}},
|
||||
+ 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)
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"cli_version": "2.1.258",
|
||||
"base": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"head": "4089113e3edbd21a29be8e6af74662462bcc22af",
|
||||
"started_at": "2026-09-12T04:02:26.329601+00:00",
|
||||
"status": "completed",
|
||||
"observed_model": "claude-opus-5",
|
||||
"session_id": "86133939-ee59-4e4f-ad98-61f825c91899",
|
||||
"permission_mode": "plan",
|
||||
"tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"command": "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 --output-format stream-json --verbose",
|
||||
"prompt_sha256": "09cea2c1356a2261e8cb9b0d4f2c947d0b683d00386267ebb340d487bdc66b4d",
|
||||
"diff_sha256": "58703d70978fa25ea6b23eb6735ec94a6d369df3a776e5169a13c847f0d6b0eb",
|
||||
"completed_at": "2026-09-12T04:35:09.936197+00:00",
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 1841224,
|
||||
"num_turns": 93,
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"used_tools": {
|
||||
"Read": 55,
|
||||
"Grep": 31,
|
||||
"Glob": 5,
|
||||
"Write": 1
|
||||
},
|
||||
"stream_sha256": "90a262f5cc189575b2e8b458c8e5cd17274fb493261c35135d199bee4895de24",
|
||||
"review_sha256": "e40dad6e1969898e35c82b7fd24517a5ee714d603a54fe3598b9e69c20922118",
|
||||
"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.",
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json.",
|
||||
"source_tree_equivalent_commit": "1ee64a8d8",
|
||||
"tool_scope_note": "Configured Read/Grep/Glob. The CLI also attempted a Write to its own plan file; no production source was edited by the reviewer."
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
|
||||
func TestIssue77CurrentHealInputs(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: review77HealInputs})
|
||||
}
|
||||
|
||||
func review77HealInputs(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
ctx := t.Context()
|
||||
localID, remoteID := globalDeploymentID(), "issue77-remote"
|
||||
created := UTCNow().Add(-time.Hour)
|
||||
at := created.Add(10 * time.Minute)
|
||||
newerAt := at.Add(time.Minute)
|
||||
enc := func(b []byte) *string { s := base64.StdEncoding.EncodeToString(b); return &s }
|
||||
base := newBucketMetadata(bucket)
|
||||
base.SetCreatedAt(created)
|
||||
base.defaultTimestamps()
|
||||
c := &SiteReplicationSys{enabled: true}
|
||||
status := func(local, remote madmin.SRBucketInfo, mismatch madmin.SRBucketStatsSummary) srStatusInfo {
|
||||
local.Bucket, remote.Bucket = bucket, bucket
|
||||
return srStatusInfo{Sites: map[string]madmin.PeerInfo{localID: {Name: "local"}, remoteID: {Name: "remote"}}, BucketStats: map[string]map[string]srBucketStatsSummary{bucket: {localID: {SRBucketStatsSummary: mismatch, meta: srBucketMetaInfo{SRBucketInfo: local, DeploymentID: localID}}, remoteID: {meta: srBucketMetaInfo{SRBucketInfo: remote, DeploymentID: remoteID}}}}}
|
||||
}
|
||||
t.Run(backend+"/quota-tombstone-cache", func(t *testing.T) {
|
||||
quota, _ := json.Marshal(madmin.BucketQuota{Quota: 1024, Type: madmin.HardQuota})
|
||||
meta := base
|
||||
meta.QuotaConfigJSON, meta.QuotaConfigUpdatedAt = quota, at
|
||||
if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := status(madmin.SRBucketInfo{CreatedAt: created, QuotaConfig: enc(quota), QuotaConfigUpdatedAt: at}, madmin.SRBucketInfo{CreatedAt: created, QuotaConfigUpdatedAt: newerAt}, madmin.SRBucketStatsSummary{QuotaCfgMismatch: true})
|
||||
if err := c.healBucketQuotaConfig(ctx, obj, bucket, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cached, _, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disk, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(disk.QuotaConfigJSON) != 0 {
|
||||
t.Fatal("quota tombstone not written to disk")
|
||||
}
|
||||
if cached != nil && cached.Quota != 0 {
|
||||
t.Errorf("QUOTA_CACHE: disk has no quota, cache still enforces %d", cached.Quota)
|
||||
}
|
||||
})
|
||||
t.Run(backend+"/same-payload-time-barrier", func(t *testing.T) {
|
||||
xml := []byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>same</Value></Tag></TagSet></Tagging>`)
|
||||
meta := base
|
||||
meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = xml, at
|
||||
if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Even forcing mismatch=true cannot make the existing heal advance a
|
||||
// timestamp when the live payload bytes already match.
|
||||
s := status(madmin.SRBucketInfo{CreatedAt: created, Tags: enc(xml), TagConfigUpdatedAt: at}, madmin.SRBucketInfo{CreatedAt: created, Tags: enc(xml), TagConfigUpdatedAt: newerAt}, madmin.SRBucketStatsSummary{TagMismatch: true})
|
||||
if err := c.healTagMetadata(ctx, obj, bucket, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !after.TaggingConfigUpdatedAt.Equal(newerAt) {
|
||||
t.Errorf("BARRIER_NOT_HEALED: same payload remains at %s, latest is %s", after.TaggingConfigUpdatedAt, newerAt)
|
||||
}
|
||||
})
|
||||
t.Run(backend+"/creation-default-selection", func(t *testing.T) {
|
||||
policy := []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket))
|
||||
meta := base
|
||||
meta.PolicyConfigJSON, meta.PolicyConfigUpdatedAt = policy, at
|
||||
baselineAt := newerAt.Add(time.Hour)
|
||||
s := status(madmin.SRBucketInfo{CreatedAt: created, Policy: policy, PolicyUpdatedAt: at}, madmin.SRBucketInfo{CreatedAt: baselineAt, PolicyUpdatedAt: baselineAt}, madmin.SRBucketStatsSummary{PolicyMismatch: true})
|
||||
wrong := 0
|
||||
for i := 0; i < 32; i++ {
|
||||
if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.healBucketPolicies(ctx, obj, bucket, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := loadBucketMetadata(ctx, obj, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after.PolicyConfigJSON) == 0 {
|
||||
wrong++
|
||||
}
|
||||
}
|
||||
if wrong > 0 {
|
||||
t.Errorf("DEFAULT_SELECTED: creation-default erased valid policy in %d/32 heal rounds", wrong)
|
||||
}
|
||||
})
|
||||
t.Run(backend+"/tag-remote-source-time", func(t *testing.T) {
|
||||
var got []madmin.SRBucketMeta
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var item madmin.SRBucketMeta
|
||||
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
|
||||
t.Error(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
got = append(got, item)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer remote.Close()
|
||||
svc, err := auth.CreateCredentials("issue77-heal-service", "issue77-heal-service-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.ParentUser = cred.AccessKey
|
||||
if _, err = globalIAMSys.store.AddServiceAccount(ctx, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer globalIAMSys.DeleteServiceAccount(ctx, svc.AccessKey, false)
|
||||
peers := map[string]madmin.PeerInfo{localID: {Name: "local", DeploymentID: localID}, remoteID: {Name: "remote", DeploymentID: remoteID, Endpoint: remote.URL}}
|
||||
remoteClient := &SiteReplicationSys{enabled: true, state: srState{ServiceAccountAccessKey: svc.AccessKey, Peers: peers}}
|
||||
xml := []byte(`<Tagging><TagSet><Tag><Key>key</Key><Value>same</Value></Tag></TagSet></Tagging>`)
|
||||
s := status(madmin.SRBucketInfo{CreatedAt: created, Tags: enc(xml), TagConfigUpdatedAt: newerAt}, madmin.SRBucketInfo{CreatedAt: created}, madmin.SRBucketStatsSummary{})
|
||||
v := s.BucketStats[bucket][remoteID]
|
||||
v.TagMismatch = true
|
||||
s.BucketStats[bucket][remoteID] = v
|
||||
if err := remoteClient.healTagMetadata(ctx, obj, bucket, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("remote received %d events", len(got))
|
||||
}
|
||||
if !got[0].UpdatedAt.Equal(newerAt) {
|
||||
t.Errorf("TAG_WIRE_TIME: sent %s, want %s", got[0].UpdatedAt, newerAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssue77CurrentPeerCheckBeforeLock(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)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package cmd
|
||||
|
||||
// Review-only tests against the unchanged 2026-09-12 main. Failures are evidence,
|
||||
// not a production regression-test submission.
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
|
||||
func TestIssue77CurrentSourceTimeAndDeletion(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: review77SourceTimeAndDeletion})
|
||||
}
|
||||
|
||||
func review77SourceTimeAndDeletion(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
|
||||
exported func(madmin.SRBucketInfo) 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 }, func(m madmin.SRBucketInfo) time.Time { return m.PolicyUpdatedAt }, 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 }, func(m madmin.SRBucketInfo) time.Time { return m.TagConfigUpdatedAt }, 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 }, func(m madmin.SRBucketInfo) time.Time { return m.SSEConfigUpdatedAt }, 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 }, func(m madmin.SRBucketInfo) 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 }, nil, 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 }, nil, false},
|
||||
}
|
||||
exporter := &SiteReplicationSys{enabled: true}
|
||||
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")
|
||||
}
|
||||
info, err := exporter.SiteReplicationMetaInfo(ctx, obj, madmin.SRStatusOptions{Entity: madmin.SRBucketEntity, EntityValue: bucket})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tc.exported(info.Buckets[bucket]).Equal(tc.stamp(tombstone)) {
|
||||
t.Errorf("TOMBSTONE_EXPORT: got %s, want %s", tc.exported(info.Buckets[bucket]), tc.stamp(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 TestIssue77CurrentBulkApplyOrdering(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)
|
||||
}
|
||||
})
|
||||
}})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package cmd
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
)
|
||||
func TestIssue77PlanAdoptionBaseline(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t:t,objAPITest:func(_ ObjectLayer, instanceType,bucket string,_ http.Handler,_ auth.Credentials,t *testing.T){
|
||||
before,err:=globalBucketMetadataSys.GetConfigFromDisk(t.Context(),bucket);if err!=nil{t.Fatal(err)}
|
||||
if len(before.PolicyConfigJSON)!=0 || !before.PolicyConfigUpdatedAt.Equal(before.Created) {t.Fatalf("unexpected initial state: %#v",before)}
|
||||
olderCreated:=before.Created.Add(-time.Hour)
|
||||
if err:=globalSiteReplicationSys.PeerBucketMakeWithVersioningHandler(t.Context(),bucket,MakeBucketOptions{CreatedAt:olderCreated});err!=nil{t.Fatal(err)}
|
||||
after,err:=globalBucketMetadataSys.GetConfigFromDisk(t.Context(),bucket);if err!=nil{t.Fatal(err)}
|
||||
falseTombstone:=len(after.PolicyConfigJSON)==0 && after.PolicyConfigUpdatedAt.After(after.Created)
|
||||
t.Logf("%s ADOPTION_BASELINE: previous Created=%s field=%s; after Created=%s field=%s; nil with field>Created=%v",instanceType,before.Created.Format(time.RFC3339Nano),before.PolicyConfigUpdatedAt.Format(time.RFC3339Nano),after.Created.Format(time.RFC3339Nano),after.PolicyConfigUpdatedAt.Format(time.RFC3339Nano),falseTombstone)
|
||||
if !falseTombstone {t.Fatal("expected to reproduce default timestamp becoming an apparent tombstone")}
|
||||
}})
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
=== RUN TestIssue77PlanAdoptionBaseline
|
||||
issue77_plan_adoption_test.go:17: ErasureSD ADOPTION_BASELINE: previous Created=2026-09-12T09:49:46.959131+08:00 field=2026-09-12T09:49:46.959131+08:00; after Created=2026-09-12T08:49:46.959131+08:00 field=2026-09-12T09:49:46.959131+08:00; nil with field>Created=true
|
||||
issue77_plan_adoption_test.go:17: Erasure ADOPTION_BASELINE: previous Created=2026-09-12T09:49:47.012956+08:00 field=2026-09-12T09:49:47.012956+08:00; after Created=2026-09-12T08:49:47.012956+08:00 field=2026-09-12T09:49:47.012956+08:00; nil with field>Created=true
|
||||
--- PASS: TestIssue77PlanAdoptionBaseline (0.13s)
|
||||
PASS
|
||||
ok github.com/minio/minio/cmd 1.458s
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"worktree": "$TMPDIR",
|
||||
"source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"scope": "diagnostic of existing code only; no product implementation",
|
||||
"command": "GOWORK=off go test ./cmd -run '^TestIssue77PlanAdoptionBaseline$' -count=1 -v",
|
||||
"exit_code": 0,
|
||||
"outcome": "Both ObjectLayers reproduced a nil default state becoming an apparent tombstone after Created moved earlier; no product fix implemented.",
|
||||
"temporary_worktree_removed": true,
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# 作者补充核验
|
||||
|
||||
S1:`internal/logger/logonce.go:95` 按错误正文而非只按 key 去重。动态时间进入正文会绕过去重。修订要求:稳定的原因错误,动态详情放 logger ReqInfo,沿用现有每小时清理机制;不新增日志/限频框架。
|
||||
|
||||
S2:当前 silo-pkg 的 ActionSet.MarshalJSON 与 ResourceSet.MarshalJSON 直接枚举 map。实测同一个已解析 BucketPolicy 连续 json.Marshal 100 次,得到 4 种字节序列。源代码探针为 policy-encoding-probe.go.txt;使用项目 go.mod 选择的 pgsty/silo-pkg/v3 v3.13.4-0.20260910091716-2d8fd3cbbf07,命令为 GOWORK=off go run <探针临时路径>。
|
||||
|
||||
因此普通 json.Marshal 不能当作稳定排序键。v2 需要一个仅用于 Server 桶策略比较的确定性表示,对已通过现有解析器校验的策略排序集合成员;不能为了这一点修改依赖或持久化 wire 格式。
|
||||
|
||||
以上为作者在外部 v1 评审期间做的独立补查,不冒充 Claude 的评审发现,也不代表已经实现修复。
|
||||
@@ -1,32 +0,0 @@
|
||||
# v1 对抗评审意见与 v2 处置
|
||||
|
||||
评审者:真实 Claude Code 2.1.258,审查消息模型为 `claude-opus-5`,CLI 明确使用 `--effort max`。首轮对 main `5c576581631561c446f30ae5b566f0aa793adc1c` 的冻结 v1 给出 **NO_GO**,耗时约 24 分钟。完整输出见 [首轮评审](opus5-max-v1.md),调用证据见 [元数据](opus5-max-v1.metadata.json)。
|
||||
|
||||
本文件记录作者核对源码后的处置,不能代替评审者的后续结论。v2 尚未实现。
|
||||
|
||||
| ID | 处置 | v2 中的具体变化与理由 |
|
||||
| --- | --- | --- |
|
||||
| R1 / P1 | 接受 | 选择其方案 b:保留合法零 quota 文档,取消普通 quota PUT 的出站 nil 改写。补充落盘/事件状态三元组一致性。Policy 保留既有 peer 空策略=删除语义,本地/导入/bulk 也归一成删除;说明空策略本地 GET 行为变化。导入空 Policy 用现有专用 nil 事件,避免 bulk omitempty 漏发。 |
|
||||
| R2 / P1 | 接受,缩小实现方式 | 比较有效归一状态,复用已有 Object Lock→Versioning 规则;bulk 按最终接受的 Object Lock 处理 Versioning。saveMetadata 用指针让调用方保留 Save 后快照;公共 Update/Delete 签名保持不变,需要发送归一载荷的本地路径使用内部结果。写明同时间 live 键较大者胜,导入 hook 来自提交快照。 |
|
||||
| R3 / P1 | 接受“不得缺省即删除”的要求;纠正部分依据 | RawMessage 的 nil/空切片确实被 omitempty 省略,但显式 JSON null 解码为非 nil 的字节 `null`;非空 JSON 的空策略也可明确表达。实际 madmin-go/Go 编解码探针已验证。因此不采纳“bulk Policy 不可能表达语义删除、只有 Tags/SSE 可以”的绝对结论。v2 列出真实 wire 判定:未提供保留、Policy 提供但语义空则删除、Quota 提供的零值/null 仍为 live,Tags/SSE 显式空字符串删除;T5 经真实 marshal/unmarshal 验证。 |
|
||||
| R4 / P2 | 接受 | 选源和 apply 都使用 <= Created 的 baseline 判定;候选不晚于目标 Created 时逐目标跳过并限频诊断。独立创建世代冲突需运维,不纳入 T9 自动收敛。 |
|
||||
| R5 / P2 | 接受但保留兼容例外 | 三种内部状态明确化,非零 baseline 事件不覆盖真实配置。零时间专用事件依 O1 保留兼容应用,它是明确例外;修复后的正常发送端不得产生该状态。 |
|
||||
| R6 / P2 | 接受 | 比较视图统一把零字段时间回退到 Created,bulk 原始读取与 load/defaultTimestamps 不再得到不同裁决。 |
|
||||
| R7 / P2 | 接受 | 六类 heal 的源/目标循环跳过 unknown/空 deployment ID;单个 peer 失败继续健康目标,覆盖可达站点不被占位项阻断。 |
|
||||
| R8 / P2 | 接受 | 明确四类清空走现有 parse=false 新对象;bulk 原始读取后保存。不改 Quota getter 或无关解析分支;保留磁盘/缓存/重载回归。 |
|
||||
| R9 / P2 | 接受 | 显式保留 Object Lock 载荷在 legacy Tags 字段时的回退与排序测试。 |
|
||||
| R10 / P2 | 接受 | 升级文档点名旧 Tag heal 无 UpdatedAt,说明其到达时间/legacy-zero 污染不能自动修复。 |
|
||||
| O1 / 缩减 | 接受 | 删除 on 模式拒绝零时间的新行为;两种模式统一兼容。开关改名 METADATA_TOMBSTONES,只控制新增墓碑导出/初次同步。 |
|
||||
| O2 / 缩减 | 接受 | 沿用现有 env.Get 的 on/off 写法,不加配置框架。 |
|
||||
| O3 / 缩减 | 接受 | 旧/新真实进程只作一次性升级冒烟;长期回归用真实 wire/SRInfo 模拟。保留修复版双站点实验以验证实际最终收敛。 |
|
||||
| O4 / 缩减 | 接受 | 明确复用现有 lockBucketMetadataAcquireHook / RMW 屏障,不新增产品测试钩子。 |
|
||||
| O5 / 缩减 | 接受 | 删除无行为意义的 deployment ID 日志裁决要求;它不参与持久化排序。 |
|
||||
| O6 / 缩减 | 部分接受 | 删除不必要的 stale-conflict 警告,剩三类。保留 indeterminate 并明确真实触发条件:未知 Created、不可用来源、有实质差异却无合法候选;它不等于 before-created。 |
|
||||
|
||||
## 作者补充核验
|
||||
|
||||
- **S1:日志去重。** `internal/logger/logonce.go:95-123` 同时依赖稳定 key 和错误正文,变化时间放正文会导致每轮记录。v2 使用固定原因错误,动态详情放 ReqInfo,不新增框架。
|
||||
- **S2:Policy JSON 编码。** 当前 silo-pkg 的 ActionSet/ResourceSet MarshalJSON 枚举 map。同一个已解析策略编码 100 次得到 4 种结果,因此 v1 的普通 JSON 比较键不稳定。v2 增加 Server 内的策略比较键,对已校验策略的集合数组/对象键排序,不改变 wire 或依赖。探针见 [policy-encoding-probe.go.txt](policy-encoding-probe.go.txt)。
|
||||
- **S3:R3 的实际 wire 边界。** 使用项目选定的 madmin-go v3.0.110 与 silo-pkg 执行真实编解码,确认显式 null 与 omitted 不同,带 Version 的空 Statement 策略通过现有解析且 IsEmpty=true。源码和输出见 [wire-state-probe.go.txt](wire-state-probe.go.txt)、[wire-state-probe.log](wire-state-probe.log)。`cmd/bucket-quota.go:99-110` 证实 Quota null 由现有解析器接受为零值对象;不把它改成删除。
|
||||
|
||||
以上均为方案核查和小型诊断。没有修改产品源文件,没有把首轮 NO_GO 改写为通过;后续复审针对冻结 v2 独立给出结论。
|
||||
@@ -1,31 +0,0 @@
|
||||
# v2 对抗复审意见与 v3 处置
|
||||
|
||||
第二轮由相同 Claude Code / `claude-opus-5 --effort max` 执行,仅有 Read/Grep/Glob 工具,耗时约 11 分钟。结论 **NO_GO,1 个新增阻断**;明确确认首轮 R1/R2/R3 已全部关闭,并承认首轮 R3 关于 RawMessage 的部分依据错误。见 [第二轮原文](opus5-max-v2.md)、[调用证据](opus5-max-v2.metadata.json)。
|
||||
|
||||
## P1-1:历史桶字段时间等于 Created
|
||||
|
||||
接受阻断。源码核对确认历史配置可合法带 `UpdatedAt == Created`,MakeBucketHook 还会把同一 Created 传给新站点。v2 把等号也拒绝,会阻断初次同步,并因 heal 过滤 baseline 而无法补救。
|
||||
|
||||
对评审中的小处事实作校正:当前 `applyLegacyConfigs` 末尾已经调用 `defaultTimestamps()`,因此“迁移后磁盘字段仍为零”不普遍成立;**字段等于 Created 的反例成立,不影响阻断判断**。
|
||||
|
||||
v3 的最小修订是保留 baseline 的弱初值语义,不引入新 wire 字段或重盖到达时间:
|
||||
|
||||
| 输入/目标 | v3 行为 |
|
||||
| --- | --- |
|
||||
| 历史 live@Created → 空 baseline | 接受初始化,保存相同源时间;初次同步和 heal 一致 |
|
||||
| baseline-live → baseline-live | 稳定比较键较大者胜;全 baseline-live 也能收敛 |
|
||||
| baseline-live → 真实 live/tombstone | 不覆盖;真实状态优先级先于时间比较 |
|
||||
| 空/nil@Created → 任意已有配置 | 不能作为删除,不清空 |
|
||||
| 非零时间严格早于目标 Created | 原有创建保护,成功/no-op 与有界诊断 |
|
||||
| 全为空 baseline | found=false,安静不写入 |
|
||||
|
||||
T7/T8 新增上述用例,特别是历史桶从初次同步到完整一轮 heal 的验收;六类类型约束、删除 parse=false、旧事件不能复活 tombstone 均保留。
|
||||
|
||||
## 四条 P2
|
||||
|
||||
1. **Policy 稳定键:接受目标,不叠加第二种判等。** v3 明确对已解析策略完整 JSON 树做递归排序,覆盖 NotAction/NotResource 与 Condition,保留数字精度,并由两站点独立解析验证同键。未采纳 Equals 优先再用另一种键的建议:当前 `BucketPolicy.Equals` 对 Statement 次序敏感,而 `BPStatement.Equals` 忽略 Sid,两套判等混用可能使同时间冲突的比较不一致。公开统计现用 Equals 不变。当前 BPStatement 不支持 NotPrincipal,不为此新增语法或改 silo-pkg。
|
||||
2. **off 并非禁止删除:接受。** 直写 Policy 原有墓碑在 off 仍参与 heal;普通删除事件一直复制;只有新增 Tags/SSE/Quota 墓碑导出与初次发送受开关控制。
|
||||
3. **off 期间过时 RPC:接受。** 隐藏墓碑使部分旧 RPC 仍被尝试、在接收端被排序拒绝;这是升级阶段明确代价,不要求零 RPC,不加每轮日志。稳定无额外 RPC 的验收要求明确放在完整状态可见之后。
|
||||
4. **空策略 API 行为:接受。** T1 明确带合法 Version 的空策略 PUT 成功,随后 GET NotFound,落盘与专用删除事件三元组一致;不能误改为 4xx。
|
||||
|
||||
三个实现提交没有增加,也没有新增协议、框架、依赖或长期集成门槛。v3 只需对本次 baseline 规则与以上说明做最后针对性复审;已关闭的首轮问题不重开全仓库审查。当前仍是设计文件,没有实现或执行实现期测试。
|
||||
@@ -1,19 +0,0 @@
|
||||
# v3 通过后的作者实测与 v4 最小补充
|
||||
|
||||
Opus 第三轮已经给出 **GO_WITH_NONBLOCKING_NOTES,实施前阻断 0**,确认历史 baseline-live 初始化规则正确。见 [第三轮意见](opus5-max-v3.md)。其两条可执行非阻断说明(历史初值第二轮不再广播、deployment ID 不参与比较/持久化)已明确写入 v4。
|
||||
|
||||
## S4:接管改变 Created 会把默认时间变成假墓碑
|
||||
|
||||
这是作者补查发现,不冒称为 Opus 结论。
|
||||
|
||||
当前 `PeerBucketMakeWithVersioningHandler`(`cmd/site-replication.go:946-961`)先加载并补齐默认字段时间,再调用 SetCreatedAt 改写 Created。若桶被接管到较早的共同 Created,原本 `PolicyUpdatedAt == oldCreated` 且 policy=nil 的缺省状态,变成 `PolicyUpdatedAt > newCreated`,新排序/导出规则便会把它当成真实删除。
|
||||
|
||||
已在固定 main 上的独立 worktree 执行 `GOWORK=off go test ./cmd -run '^TestIssue77PlanAdoptionBaseline$' -count=1 -v`:ErasureSD 与 Erasure(默认多盘)都复现了该条件。Created 前移一小时,nil Policy 的字段时间留在原 Created。测试 PASS 的含义是**旧代码确实出现该现象**,不是修复通过;详见 [测试源码](adoption-baseline-probe.go.txt)、[实际输出](adoption-baseline-probe.log)、[元数据](adoption-baseline-probe.metadata.json)。测试只新增在临时 worktree,没有改产品代码。
|
||||
|
||||
## 最小修订
|
||||
|
||||
在现有接管锁内记住 oldCreated,执行现有 SetCreatedAt 得到 newCreated;仅当 Created 改变时,将本轮六类字段中原本为 0 或 oldCreated 的默认时间调整为 newCreated,再执行既有 bootstrap。所有真实字段时间(原本 > oldCreated)及载荷不动。绝不能仅以 nil 载荷识别默认状态,否则会丢失真正删除时间。
|
||||
|
||||
这不是另做 #78 的配置接管修复,而是让 #77 新比较器需要的 baseline 不变量跨越已有 SetCreatedAt 写入路径仍成立。只在提交 1 加小分支,T8 增加默认时间维持 baseline、真实 PUT/DELETE 时间保留的断言;没有增加实现提交、存储字段、协议、锁或依赖。
|
||||
|
||||
v4 其余行为与已通过的 v3 相同;第四轮只核查这个接管增量。
|
||||
@@ -1,32 +0,0 @@
|
||||
# Issue #77 最终方案评审记录
|
||||
|
||||
2026-09-12,最终计划为 [issue-77-plan.md](../../issue-77-plan.md)。固定代码基准为 main `5c576581631561c446f30ae5b566f0aa793adc1c`;当日重新查询 #77 仍为 OPEN。
|
||||
|
||||
**结论:GO_WITH_NONBLOCKING_NOTES,实施前阻断 0。** 作者已把末轮非阻断说明落实到定稿,可进入实现;本轮没有修改产品代码、提交 PR、关闭 Issue 或发布制品。
|
||||
|
||||
## 实际执行的外部评审
|
||||
|
||||
使用本机 Claude Code 2.1.258,每轮均明确传入 `--model claude-opus-5 --effort max`,在固定 main 的隔离 worktree 中执行,仅提供 Read/Grep/Glob。所有记录到的评审 assistant 消息模型均为 `claude-opus-5`。CLI 的用量信息另含少量辅助模型调用,已如实保留在 metadata;这不是用其它模型代替 Opus 评审。
|
||||
|
||||
| 轮次 | 待审快照 | 结果 | 处置 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1,全面方案审查 | [v1](plan-v1.md) | [NO_GO,3 个 P1](opus5-max-v1.md) | 修正零 quota 保存/发送不一致、Object Lock 归一后的提交快照、bulk wire 删除规则;缩减开关及验收范围 |
|
||||
| 2,修订复审 | [v2](plan-v2.md) | [NO_GO,新增 1 个 P1](opus5-max-v2.md) | 首轮 3 项确认关闭;修正历史 live@Created 的初始化回归 |
|
||||
| 3,历史初值增量 | [v3](plan-v3.md) | [GO_WITH_NONBLOCKING_NOTES,0 阻断](opus5-max-v3.md) | baseline-live 可初始化、不能覆盖真实状态;默认空值不能删除 |
|
||||
| 4,接管增量 | [v4](plan-v4.md) | [GO_WITH_NONBLOCKING_NOTES,0 阻断](opus5-max-v4.md) | 作者实测假墓碑后新增最小时间归一分支,经 Opus 确认必要性与充分性 |
|
||||
|
||||
逐项记录:[首轮处置](decisions-v2.md)、[第二轮处置](decisions-v3.md)、[接管增量](decisions-v4.md)。每轮相邻的 `opus5-max-vN.metadata.json` 保存命令、模型、工具、耗时、源 SHA、待审计划哈希及结果哈希。
|
||||
|
||||
## 末轮非阻断说明的落实
|
||||
|
||||
- T8 已明确 Created 前移、后移与不变三种情况;历史 baseline-live 第一轮补齐,第二轮无额外写入/广播。
|
||||
- 保持只调整 #77 六类字段,在实现注释中说明边界。不采纳扩大到其它配置时间的可选建议:那些时间不是新比较器的输入,没有必要同批改变其行为。
|
||||
- Policy 稳定键与公开计数的判等职责明确;Sid 差异可以触发一次状态同步,但不重做 #91 的既有统计语义。递归规范化和数字精度由 T2 验证。
|
||||
|
||||
定稿相对于第四轮冻结快照只补充上述验收/注释说明和评审状态,没有新增实现范围。
|
||||
|
||||
## 证据边界
|
||||
|
||||
作者实测使用了实际 Go 编解码、当前 silo-pkg 的 Policy 编码,以及 ErasureSD/默认多盘 ObjectLayer 的接管路径。接管诊断 PASS 表示在未修复 main 上成功观察到假墓碑条件。既有问题复现记录见 [当前核验](../../issue-77-current.md)。
|
||||
|
||||
外部 reviewer 做的是源代码与方案核查,没有运行新实现测试;现在不存在本轮产品实现。局部回归、race、完整 CI、修复版双站点收敛、一次性升级冒烟均是后续实现阶段的验收,不得把本次评审结论当成这些验证已经通过。
|
||||
@@ -1,124 +0,0 @@
|
||||
Write 工具在本会话被禁用,无法落盘评审文件,因此评审报告直接输出如下。
|
||||
|
||||
# SILO #77 修复计划 v1 对抗评审
|
||||
|
||||
基准:worktree = main `5c576581631561c446f30ae5b566f0aa793adc1c`。本次只做方案评审:未改产品代码、未运行测试、未写 GitHub、未调用其他代理。
|
||||
|
||||
**证据分级**:**[源码]** 本次在该 SHA 直接确认(给文件:行)/**[复现]** `issue-77/current-tests.log` 已记录/**[推论]** 由源码事实推导、未执行/**[待验]** 需实现期测试。
|
||||
|
||||
---
|
||||
|
||||
## 结论:NO_GO
|
||||
|
||||
方向正确,但 §3 / §5 / §6 三处**契约文字**按字面实现会产生新的不收敛与潜在错删。计划约 85% 的内容经源码核对成立,不需要推翻结构——**修订 R1、R2、R3 后复审即可进入实现**。
|
||||
|
||||
---
|
||||
|
||||
## P1 阻断项
|
||||
|
||||
### R1 零配额在发送端被改写成删除,本地却保留 live 文档 → off(默认)永不收敛,on 主动删除本地合法文档
|
||||
|
||||
**源码位置**
|
||||
- `cmd/admin-bucket-handlers.go:85` 本地写入用的是**原始请求体** `data`;
|
||||
- `cmd/admin-bucket-handlers.go:97-99` `if quotaConfig.Size == 0 && quotaConfig.Quota == 0 { bucketMeta.Quota = nil }` —— 发出的事件被改写成**删除**;
|
||||
- `cmd/site-replication.go:2088-2107` 对端 `quota == nil` 走 `Delete`;
|
||||
- `madmin-go v3.0.110 quota-commands.go:54-60` `Quota == 0` 时 `IsValid()` 恒真(`{}` 是合法 live 文档),且该版本**只有 `SetBucketQuota`,没有删除端点** —— "清除配额"只能走这条零值 PUT;
|
||||
- `cmd/site-replication.go:4090-4094` 导出端仅在 `len(QuotaConfigJSON) > 0` 时带出时间,删除时间被隐藏。**[源码]**
|
||||
|
||||
**计划条目**:已确定契约"合法的空 quota 对象 `{}`…不能仅因配额为零就改写为 nil";§3"同时间真实删除大于 live";提交 3 tombstone 导出;完成条件"四类 DELETE"、T7。
|
||||
|
||||
**触发步骤**
|
||||
1. A 站点执行等价于 `mc quota clear`(零值 SetBucketQuota)。
|
||||
2. A 落盘 live `{...}`@T;发出的事件是 `Quota=nil`@T。
|
||||
3. B 按计划落盘 tombstone@T。
|
||||
4. **off(默认)**:B 的 tombstone 时间不导出 → B 候选被当 baseline 过滤 → 选源永远是 A 的 live@T → 推给 B → B 按"同时间删除优先"拒绝 → **每 30 秒(`site-replication.go:4651`)一次 RPC + 一次拒绝日志,永不收敛**。
|
||||
5. **on**:B 导出 tombstone@T → 同时间删除胜 → heal 反过来**删除 A 上合法的 live 文档**。
|
||||
|
||||
**影响**:这是清除配额的常规路径。主干今天同样不收敛(到达时间更大导致拒绝),所以**不是回归,但计划声称会修而按 v1 修不了**,T1/T7 不可达成。**[源码]+[推论]**
|
||||
|
||||
**同类第二处**:`cmd/bucket-policy-handlers.go:87-109` 本地存 live 的"语义为空"策略,`cmd/admin-handlers-site-replication.go:233-237` 在接收端判为删除。Policy 仍能收敛,只因 `site-replication.go:4069-4070` **无条件**导出 `PolicyUpdatedAt`。可见根因是"发送端语义 ≠ 本地落盘语义",不是导出策略。
|
||||
|
||||
**最小修正(二选一,必须成对,不能只改一侧)**
|
||||
- (a) 解析后 quota 为零值结构时本地也走 `Delete`(parse=false),事件仍为 nil;或
|
||||
- (b) 取消 `Quota = nil` 改写,事件携带真实文档。
|
||||
|
||||
并把"同一次本地操作,落盘状态与发出事件状态必须是同一状态"写成契约不变量;T1 增加三元组 (kind, payload, time) 一致性断言。
|
||||
|
||||
---
|
||||
|
||||
### R2 `saveMetadata` 内部静默归一化:落盘载荷 ≠ 比较载荷 ≠ 事件载荷;§6"无状态变化不保存"按字面不可实现;§3 未定义字节序方向
|
||||
|
||||
**源码位置**
|
||||
- `cmd/bucket-metadata.go:391-399` `parseAllConfigs` 在 `objectLockConfig != nil` 且 versioning 文档解析失败 / `!Enabled()` / `PrefixesExcluded()` 时改写 `VersioningConfigXML` 为常量,**不改 `VersioningConfigUpdatedAt`**;
|
||||
- `cmd/bucket-metadata.go:581-603` `Save` 每次保存前都调 `parseAllConfigs` → 归一化发生在**每一次** `saveMetadata`;
|
||||
- `cmd/bucket-metadata-sys.go:232-243` `saveMetadata(…, meta BucketMetadata)` **按值接收**,归一化只作用于被落盘/入缓存的副本,调用方手里的结构仍未归一化;
|
||||
- `cmd/admin-bucket-handlers.go:1094-1116` 导入把未归一化的 `merged` 赋回 `*meta`,随后 `hook.Versioning = enc(meta.VersioningConfigXML)` —— **发出的载荷与落盘载荷不同,时间戳却相同**;
|
||||
- `cmd/admin-bucket-handlers.go:823-835` 导入只拒绝 `Suspended`,**不拒绝 `PrefixesExcluded()`**(`internal/bucket/versioning/versioning.go:153-155`:`ExcludedPrefixes` 或 `ExcludeFolders` 均算);
|
||||
- `cmd/bucket-versioning-handler.go:68-84` S3 PUT 只在**本地** LockEnabled 时拒绝 prefix-excluded;
|
||||
- 现有回归 `cmd/site-replication-object-lock_test.go:149-167` 已把该机制固化为断言。**[源码][复现-通过]**
|
||||
|
||||
**计划条目**:§3"live/live 按稳定载荷字节序比较""apply 和 heal 使用同一比较函数";§6"无状态变化时不保存、不通知";提交 1 完成条件"导入的持久化时间与实际发出的事件一致"。
|
||||
|
||||
**为什么阻断**
|
||||
1. §6 只能在 `saveMetadata` 之前比较,而真正落盘的是归一化之后的结构——**规则按字面无法实现**,"无变化"的判定可能与磁盘不符。
|
||||
2. 计划把到达时间换成源时间后,**同一时间戳 + 不同载荷成为正常可达状态**(Object Lock 在站点间短暂不对称时必然出现);旧代码靠"到达时间总在前进"意外掩盖了它。
|
||||
3. §3 **没写明大者胜还是小者胜**。以 `…<Status>Enabled</Status><ExcludedPrefixes>` 对 `…<Status>Enabled</Status></VersioningConfiguration>` 为例,分歧字节是 `E`(0x45) 与 `/`(0x2F):方向选错,heal 每轮把未归一化文档推给已归一化站点,对方再归一化 → 重复写 + 全节点 `LoadBucketMetadata` 广播;方向选对则一轮收敛。**让正确性取决于未写明的字节序方向不可接受。**
|
||||
4. 导入路径违反提交 1 完成条件的实质:时间一致、状态不一致。
|
||||
|
||||
**诚实边界**:我**没有**证明这会无限发散。Object Lock 不可删除,`healOLockConfigMetadata`(`5379-5447`)通常 1 轮内补齐,之后两侧归一化结果相同并收敛。实际损害是"窗口期重复写/广播 + 实现定义的裁决"。**[源码]+[推论],永久性未证实。**
|
||||
|
||||
**最小修正**:比较器对 Versioning 先做与 `parseAllConfigs` 相同的归一化再比较;`saveMetadata` 回写/返回已持久化结构,导入与所有 hook 用**落盘后的字节**;§6 改为"以归一化后的落盘表示判定状态变化";§3 写明字节序方向并注明它只用于真并发冲突,不得用来消化序列化差异。
|
||||
|
||||
---
|
||||
|
||||
### R3 §5 的"bulk 中显式提供且为空 = 删除"对 Policy/Quota 在线路上不可表达;按字面实现存在"缺省即删除"的错删路径
|
||||
|
||||
**源码位置**
|
||||
- `madmin-go v3.0.110 cluster-commands.go:501-530`:`Policy`/`Quota` 是 `json.RawMessage` + `omitempty` —— **nil 与空切片都会被省略**,解码端永远拿到 nil;而 `Tags/SSEConfig/Versioning/ObjectLockConfig` 是 `*string`,`ptr("")` 可表达"提供且为空";
|
||||
- `cmd/site-replication.go:1653-1697` bulk 一律按 `!= nil` 判"是否提供";
|
||||
- `cmd/admin-bucket-handlers.go:1103-1131` 导入发出的正是 `Type` 为空的 bulk 事件,且只填本次导入涉及的字段。**[源码]**
|
||||
|
||||
**风险**:对 Policy/Quota,这条规则要么是走不到的死代码,要么诱导实现者写 `len(item.Policy) == 0 ⇒ 删除` —— 那样**每个只导入 tags 的 bulk 事件都会删掉对端 bucket policy**,正是 #77 要消灭的错删类。这是规范缺陷,不是当前代码缺陷。**[源码]+[推论]**
|
||||
|
||||
**最小修正**:§5 改为——bulk 只有 Tags/SSE 能表达删除(显式空 base64 串);Policy/Quota 删除只走专用事件;bulk 缺省字段任何情况下不改变该字段。T5 补两条断言。
|
||||
|
||||
---
|
||||
|
||||
## P2(建议同批修订)
|
||||
|
||||
- **R4 before-created 扩大到六类,但选源端不做同一判定。** `site-replication.go:1646-1651`(bulk 已有)、`2035-2040`(CORS 先例)、`bucket-metadata.go:190-197`(`SetCreatedAt` 无条件覆盖)、`site-replication.go:5486-5512`(`healBucket` 不修复两站点 Created 分歧)。§提交 2 只过滤 `==0` 与 `==CreatedAt`,**不过滤"早于自身 Created"**:分区期间两站点各自建过同名桶后,较老站点的真实配置被永久拒绝,heal 每轮仍选中它并挡住其它候选(今天会收敛)。**最小修正**:选源与 apply 用同一可用性判定(`time <= 自身 CreatedAt` 视为 baseline);对 `目标 CreatedAt > 候选时间` 按目标跳过并记一次 `before-created`;计划中明确这是**终态不收敛、需运维介入**,不计入 T9。
|
||||
- **R5 比较器缺第三种 kind(baseline)。** `site-replication.go:1851-1898` 的 `corsReplicationState` 已有三值模型。§3 只定义"删除 vs live",把"创建默认/未知"放在选源侧 → 任何异常发送端发出的 `nil + 创建默认时间` 在 apply 侧会被当 tombstone,同时间即可删掉对端 live。**最小修正**:六类复用 CORS 三值模型(baseline = 时间为零或不晚于 Created),apply 对 baseline 一律 no-op;顺带获得 `latestCORSConfig`(`5315-5329`)的稳定选择语义。
|
||||
- **R6 两个入口读到的"当前字段时间"来源不同。** `site-replication.go:1641` 用 `readBucketMetadata`(**不调** `defaultTimestamps`),`bucket-metadata.go:230-238` 才补默认值 → 同一记录在两条路径上分别是 `0` 与 `Created`,事件时间恰等于 `Created` 时裁决不同。**最小修正**:比较器统一 `if fieldTime.IsZero() { fieldTime = meta.Created }`。
|
||||
- **R7 heal 中不可达站点占位项会中途终止本轮修复。** `3136-3142` 把不可达站点填 `SRInfo{}`,`3219` 以空 DeploymentID 进入 `BucketStats`;`5006-5009`、`5070-5073`、`5145-5148` 等处 `getAdminClient("")` 失败即 `return`,**放弃该类型剩余站点**,且是否提前退出取决于 map 顺序。与 T9 承诺冲突。**最小修正**:两处循环都跳过 `info.Sites` 中不存在的 dID 并计入 `indeterminate`。
|
||||
- **R8 parse=true 置空字段留下已解析缓存。** `bucket-metadata.go:401-413`(versioning、quota)与 `334-338`(notification)在载荷为空时**无 else 分支**(即 [复现] `QUOTA_CACHE`)。§6 的"删除走 parse=false"规避有效且最小,但应写成显式不变量并在 T7 加断言。**不建议**本轮补 else —— 会改变 `GetQuotaConfig` 返回语义(`bucket-metadata-sys.go:572-581` 不做 nil 判断),超出范围。
|
||||
- **R9 Object Lock 的 legacy `Tags` 载荷回退必须保留。** `site-replication.go:1787-1796` + 回归 `site-replication-object-lock_test.go:132-138`。提交 1 的重构容易丢掉;T3 应显式覆盖"Type=ObjectLockConfig、载荷在 Tags 字段"的排序行为。
|
||||
- **R10 off 模式下 legacy-zero 事件盖上本地新时间后永久胜出。** `site-replication.go:5011-5015` 至今不带 `UpdatedAt`([复现] `TAG_WIRE_TIME`)。计划已有"升级后由操作者重新提交"的兜底,建议在文档条目里点名这个具体来源。
|
||||
|
||||
---
|
||||
|
||||
## 过度设计 / 可缩减
|
||||
|
||||
- **O1 开关承担三件事,只有一件有证据。** 证据(quota 缓存复现)只支持"tombstone 导出需等全站点升级"。"on 模式拒绝零源时间事件"没有对应缺陷,且计划已修复树内唯一的零时间发送端;保留它只会在误开时让 heal 每 30 秒报错。**建议把开关收缩为单一含义**:是否导出/发送真实删除状态(含初次同步);零时间事件两种模式统一按 legacy-zero 兼容应用 + 限频记录。
|
||||
- **O2 开关实现不需要新框架。** `cmd/common-main.go:902` 已有 `env.Get(name, config.EnableOff) == config.EnableOn` 的两行先例,照抄即可。
|
||||
- **O3 T8 的"固定旧版与修复版两个 SILO 进程"不必作为提交门槛。** 旧版对线路的可观测差异只有两点(heal 事件不带 `UpdatedAt`;导出不含 nil 载荷时间),进程内构造 `SRInfo`/`SRBucketMeta` 即可完整覆盖;降级为一次性人工冒烟。
|
||||
- **O4 T4 不需要新建"确定性锁屏障"。** 已有 `lockBucketMetadataAcquireHook`(`bucket-metadata-sys.go:249-255`)与 `bucket-metadata-lock_test.go` 的 `runBucketMetadataRMWConflict` 模式可直接复用。
|
||||
- **O5 §3 的 deployment ID 规则可删。** "仅在状态完全相同时用于日志来源"无可观测行为;复用 `latestCORSConfig`(`5322` 的 `cmp == 0 && dID > latestID`)即自动获得稳定性。
|
||||
- **O6 `indeterminate` 缺少明确产生条件。** 若采纳 R4,`before-created` 与 `indeterminate` 会合并,应减为三类,不要为对称保留空类别。
|
||||
|
||||
---
|
||||
|
||||
## 经核对成立、不应削减
|
||||
|
||||
锁外 getter→Update 竞态([复现] `TestIssue77CurrentPeerCheckBeforeLock` 失败);bulk 绕过逐字段排序(`1653-1697` 无字段时间比较,[复现] 失败);选源前过滤创建默认值([复现] 5/32、2/32 错删 Policy;六处重复写法 `4966-4982`、`5038-5054`、`5104-5120`、`5178-5194`、`5253-5269`、`5393-5409` 抽函数是净减法);Tag 远端 heal 缺 `UpdatedAt`(`5011-5015`);同内容不同时间不同步([复现] `BARRIER_NOT_HEALED`);复用 `metadata.lock` 与 `saveMetadata` 的物理桶检查(`232-243`)、放弃 `peer-require-existing`;导入 `updatedAt := UTCNow()`(`723`)在 ZIP 开始时取一次且可能早于随后创建的桶 `Created`,per-bucket `commitAt` 修正必要。另:`lockBucketMetadataWithTimeout` 不可重入,重入会挂到 `globalOperationTimeout`(非永久死锁但同样不可接受),"不循环调用公开 handler"正确;实现内部 apply 时须沿用 `updateAndParse` 传递 lock-ctx,否则 `loadBucketMetadataParse` 的迁移分支(`bucket-metadata.go:252`、`282`)会再次取锁。
|
||||
|
||||
---
|
||||
|
||||
## 实施前必须修订的条目
|
||||
|
||||
1. **R1** 零值配额的发送端/本地写一致性 + "落盘状态 == 发出状态"不变量。
|
||||
2. **R2** §6 比较基准改为归一化后的落盘表示;`saveMetadata` 回写已持久化结构;导入 hook 用落盘字节;§3 写明同时间载荷序方向及适用边界。
|
||||
3. **R3** §5 重写 bulk 删除语义(仅 Tags/SSE 可表达;缺省永不改变字段)+ 对应断言。
|
||||
|
||||
建议一并吸收 R4、R5 措辞修正与 O1、O3、O4 缩减,再做一次短复审即可进入实现。
|
||||
|
||||
**实现阶段需要运行(本次未运行)**:把两份审计用例改为确定性回归并先记录未修复失败;R1 零配额两侧三元组一致 + off/on 各一轮 heal 无重复 RPC;R2 Object Lock 桶 + prefix-excluded/ExcludeFolders versioning 走导入与 peer 两条路径,断言"发出载荷 == 落盘载荷"且第二轮 heal 无写入;R3 bulk 缺省/空串两类断言;既有回归 `TestPeerBucketObjectLockMetadata*`、`TestPeerBucketAdoption*`、`TestQueuedMetadataUpdateAfterDelete`、`TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig`、CORS 站点复制用例,加目标 race 与完整 Go CI。
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"reviewer": "Claude Code",
|
||||
"cli_version": "2.1.258",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"permission_mode": "plan",
|
||||
"tools": [
|
||||
"Read",
|
||||
"Grep",
|
||||
"Glob"
|
||||
],
|
||||
"round": 1,
|
||||
"plan_snapshot": "plan-v1.md",
|
||||
"command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
|
||||
"status": "completed",
|
||||
"model_configuration_reference": "https://code.claude.com/docs/en/model-config",
|
||||
"observed_model": "claude-opus-5",
|
||||
"session_id": "9fb76061-6bcc-490c-a96b-9a1d4f9daa84",
|
||||
"observed_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"verdict": "NO_GO",
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 1437147,
|
||||
"num_turns": 57,
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"plan_sha256": "0218a2ca59750acd6a24dc5ece1d55a19762a863f1a2de8edc6ffc9b4ab072e0",
|
||||
"auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
|
||||
"collected_at_utc": "2026-09-12T01:57:46.010985+00:00",
|
||||
"stream_sha256": "a145cd75ebf2ab3b722c7e1764036fcac8e3245ed2de3c2390dc0b5826e63d16",
|
||||
"review_sha256": "8a3b750a261ef520acbe658395d3303cea0835b07184b96022781a2a5d0d4dcf",
|
||||
"used_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read",
|
||||
"Write"
|
||||
],
|
||||
"temporary_review_worktree_removed": true,
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# SILO #77 修复计划 v2 对抗复审(Claude Code Opus 5,只读)
|
||||
|
||||
基准 worktree = main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮只针对 v1→v2 的修订差异做源码核验:未改产品代码、未运行测试、未调用其他代理。
|
||||
|
||||
## 首轮三个阻断的关闭判断
|
||||
|
||||
**R1(零配额发送端改写 / 落盘≠出站):已关闭。** v2 取消 `admin-bucket-handlers.go:97-99` 的 `Quota=nil` 改写后,`mc quota clear` 两侧都落 live 零值文档:`bucket-quota.go:99-110` 对 `{}`/`null` 均解析为零值且 `IsValid()` 为真,`isBktQuotaCfgReplicated`(`site-replication.go:3850-3888`)此时 `numquotaCfgs==total`、Type 相同 → 判为已复制,`quotaCfgSet`(`3635`)仍为 false,#91 计数不受影响。接收端 `PeerBucketQuotaConfigHandler:2089` 会重新 `json.Marshal` 导致字节不同,但 v2 §2 规定 Quota 比较键取"解析结果的 JSON 编码",`madmin.BucketQuota` 是定长结构体、编码确定,不会因此空转。Policy 侧改成本地/导入/bulk 统一按 `IsEmpty()` 删除,与既有 `admin-handlers-site-replication.go:233-237` 一致,三元组不变量成立。
|
||||
|
||||
**R2(saveMetadata 静默归一 / §6 不可实现 / 字节序方向未定):已关闭。** `saveMetadata`(`bucket-metadata-sys.go:232`)全部 6 个调用点(`site-replication.go:961/1708/2049`、`bucket-metadata-sys.go:203/222/354`、`admin-bucket-handlers.go:1095`)传入的都是调用方自有值,改指针回写无别名风险;导入 hook 改用提交快照后,`1110-1127` 与落盘文档不再分叉;比较改用 `parseAllConfigs:391-399` 同一条 Object Lock→Enabled 归一规则,方向也已写明"键较大者胜"且禁止用字节序补偿隐式改写。
|
||||
|
||||
**R3(bulk 删除语义):已关闭,且首轮依据确属错误。** 复核确认:`json.RawMessage` 显式 `null` 解码为非 nil 的 `[]byte("null")`(探针 log 第 3-4 行),`policy/bucket-policy.go:78` 允许 `Version==""`,故 `null` 与 `{"Version":...,"Statement":[]}` 都能通过 `ParseBucketPolicyConfig` 且 `IsEmpty()` 为真;`*string` 的 `null` 才退化成 nil。v2 的逐字段规则与 `PeerBucketMetadataUpdateHandler:1653-1697` 的 `!=nil` 约定一致,也堵住了"`len==0 ⇒ 删除"的错删诱导。唯一 bulk 生产者(导入 `1103-1131`)在 `omitempty` 下漏发空 Policy 的问题,v2 用专用 nil 事件补齐,处置正确。
|
||||
|
||||
## P1(新增阻断,1 项)
|
||||
|
||||
**P1-1:`fieldTime <= Created` 一律判 baseline,会让历史桶的配置在初次同步中被静默丢弃且 heal 永不修复。**
|
||||
|
||||
反例(全部为已修复版本、同一桶世代、合法带时间事件,属计划承诺范围):
|
||||
|
||||
1. A 站点有 2020 年前迁移来的桶。`applyLegacyConfigs`(`bucket-metadata.go:482-513`)不写任何 `*UpdatedAt`,`convertLegacyConfigs` 保存后磁盘上这些字段仍是零值;每次 `loadBucketMetadataParse:236-238` → `defaultTimestamps:534-561` 把 `PolicyConfigUpdatedAt` 补成 **正好等于 Created**。
|
||||
2. 运维执行 `AddPeerClusters`。`MakeBucketHook:821-823` 把 `createdAt` 传给对端,`PeerBucketMakeWithVersioningHandler:950` 的 `SetCreatedAt` 使 B 的 `Created` 与 A **完全相等**。
|
||||
3. `syncToAllPeers:2217-2228` 发出 Policy@Created(非零,因此不走 legacy-zero 例外)。
|
||||
4. 按 v2 §3"非零事件**不晚于**目标桶 Created 时成功/no-op,记录 before-created" → B 直接 no-op,policy 落不下去。
|
||||
5. 按 v2 §2/提交 2.1,A 的候选是 baseline → 过滤 → "无候选,显式 found=false" → 永远不 heal,只产出 indeterminate。
|
||||
|
||||
主干今天这条路径是成功的(`PeerBucketPolicyHandler:1718-1746` 无 before-created 检查,直接应用),bulk 侧 `1646` 也只拒绝 **严格早于** Created。因此这是回归,不是遗留缺陷;Tags/SSE/Quota 同理,Object Lock 因 `955-959` 自举侥幸掩盖。T1–T9 没有"字段时间等于 Created 的历史桶初次同步"用例,实现期不会被发现。
|
||||
|
||||
最小修正(两句契约 + 一行验收):①把 apply 侧边界改成**严格早于** Created 才 before-created;`fieldTime == Created` 保留 baseline 的"弱"语义。②baseline 只是排序上低于任何非 baseline 候选,不是"不可用":目标该字段自身也是 baseline/缺失时,baseline-live 仍可确立初值,仅在存在非 baseline 候选时被压制;全 baseline 且内容不同时按既定键排序取胜者,而不是报 indeterminate。③T7/T8 增加一行"全部字段时间等于 Created 的历史桶,初次同步与一轮 heal 后各站点一致"。
|
||||
|
||||
## P2(仍需改动)
|
||||
|
||||
- **Policy 比较键的构造方式。** 递归排序自造规范 JSON 需要覆盖 `Condition` 的 `map[string]map[string]ValueSet`、`NotAction/NotResource/NotPrincipal`,一旦两站点对同一策略算出不同键,同时间平局会各自选出不同胜者 → 每轮互推、不收敛。建议判等直接复用现成的 `BucketPolicy.Equals`(`site-replication.go:3912` 已在用),规范串只用于同时间平局排序,并复用 `ActionSet.String()`(silo-pkg `policy/actionset.go:151-158`,已排序)。同时把"键必须是**已解析策略**的纯函数、跨站点必须一致"写成不变量,T2 补一条"两站点各自字节 → 相同键"断言。
|
||||
- **开关语义与 Policy 的实际不对称需写进文档。** `site-replication.go:4069-4070` 无条件导出 `PolicyUpdatedAt`,因此提交 1/2 落地后,**开关 off 时 Policy 删除照样会经 heal 传播**,只有 Tags/SSE/Quota 被门控。v2 表格里"初次同步的真实删除:off 保留已有行为"容易被读成"off 完全不传播删除",运维会据此做错误的升级判断。
|
||||
- **off 模式下的稳定态 RPC 要写进验收预期。** 持有 Tags/SSE/Quota 墓碑的站点在 off 下导出不带时间 → 每轮仍被选为待修复目标 → 每 30s(`4651`)一次必然被拒的 RPC。提交 2 的"对已归一且相同的目标不写入、不发 RPC"不覆盖该情形。请明确这是 off 期间的预期代价,并禁止为它加每轮日志(会与 §3 的三类有界日志冲突)。
|
||||
- **空策略 PUT 的对外行为补一条断言。** v2 让本地 `PutBucketPolicy` 对 `{"Version":"2012-10-17","Statement":[]}`(`bucket-policy-handlers.go:98-101` 只拒空 Version,该文档合法)改走删除。T1 请断言"PUT 返回成功、GET 返回 NotFound、出站专用事件与落盘一致",避免实现时误改成 4xx。
|
||||
|
||||
## 作者补查核验
|
||||
|
||||
**S1 成立。** `internal/logger/logonce.go:109` 仅在 `prev.Err.Error() == nerr.Error()` 时抑制;正文含变化时间会每轮重新打印(且分支不更新 map,等于完全失效)。固定正文 + ReqInfo 的做法正确。
|
||||
|
||||
**S2 成立。** `policy/actionset.go:144-168` 的 `MarshalJSON → ToSlice` 直接遍历 map 不排序,`json.Marshal(BucketPolicy)` 确实不稳定,v1 的字节比较键不可用;v2 另建比较键的结论正确(构造方式见上 P2)。
|
||||
|
||||
---
|
||||
|
||||
**结论:NO_GO。实施前阻断数:1(P1-1)。** 首轮 R1/R2/R3 三项均已实质关闭,R4–R10、O1–O6 的处置未引入新的明确阻断;余下 4 条 P2 建议与 P1-1 一并修订即可进入实现,不需要再做全量复审。
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"reviewer": "Claude Code",
|
||||
"cli_version": "2.1.258",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"permission_mode": "plan",
|
||||
"tools": [
|
||||
"Read",
|
||||
"Grep",
|
||||
"Glob"
|
||||
],
|
||||
"round": 2,
|
||||
"plan_snapshot": "plan-v2.md",
|
||||
"plan_sha256": "2b2c712b17f757ca1a8501714c6c2472bd442a6be50b29d92f2d87e23c412ba8",
|
||||
"command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
|
||||
"status": "completed",
|
||||
"observed_model": "claude-opus-5",
|
||||
"session_id": "e40ce534-ece8-430f-841d-99add4fab13d",
|
||||
"observed_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 666254,
|
||||
"num_turns": 41,
|
||||
"collected_at_utc": "2026-09-12T01:40:02.942542+00:00",
|
||||
"stream_sha256": "7b3aa8de142e77b4ea1bca468685a04203261021c7978c3a97a27d1febacacab",
|
||||
"review_sha256": "848a476f18ce88208b72d8bf6cb49a2316b2b14dcba64ad66e636fe0a3d70595",
|
||||
"used_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
|
||||
"verdict": "NO_GO",
|
||||
"blocking_findings": 1,
|
||||
"closed_previous_blockers": [
|
||||
"R1",
|
||||
"R2",
|
||||
"R3"
|
||||
],
|
||||
"temporary_review_worktree_removed": true,
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
## P1-1:已关闭
|
||||
|
||||
v2 的阻断链条有三个环节,v3 逐个断开:
|
||||
|
||||
1. **apply 侧边界**:计划 §2 第 39 行改为「非零事件**严格早于**目标桶 Created 才成功/no-op 并记录 before-created」,并明写「等于目标 Created 的 live 事件可更新仍是 baseline 的目标字段,时间仍保存为源 Created」。v2 反例第 4 步(`==` 被判 before-created)不再成立。
|
||||
2. **heal 候选**:第 37 行只把「空 baseline」排除出源,baseline-live 是初始化候选;提交 2.1(第 72 行)过滤条件同样是「严格早于**自身** Created」,等于 Created 不被滤掉。v2 反例第 5 步(found=false 永不修复)不再成立。
|
||||
3. **验收**:T8 第 111 行有「历史字段时间全部等于 Created 的桶经初次同步和一轮 heal 后一致」,T7 第 110 行有 baseline-live 初始化与同级收敛。
|
||||
|
||||
## 四条反例核验
|
||||
|
||||
1. **历史 live@Created → 空字段**:初次同步走 apply 分支(baseline-live 胜空 baseline,时间存原值 Created);漏发时 heal 也能选到该候选。写入后两侧同为 baseline-live@Created、键相等,按第 40 行「键、状态级别和源时间相同为 no-op」+ 提交 2.4 稳定。✅
|
||||
2. **较晚空默认值**:第 40 行排序先比状态级别,「任何真实 live/tombstone 都胜 baseline」,且空 baseline 既不是源也不是删除(37/39 行、decisions 表第 18 行)。时间更晚的空默认值无法压过较早真实修改。✅
|
||||
3. **不复活真实删除 / 同级收敛**:baseline-live「永远不能覆盖真实 live/tombstone」在 heal 选源与 apply 两侧同一套规则(提交 2 标题即「选源与应用同规则」),因此 off 模式下墓碑隐藏时,被选中的 baseline-live 推到持有墓碑的站点仍被接收端拒绝——是过时 RPC,不是复活。全 baseline-live 不同载荷按同一稳定键较大者胜,两侧独立计算同键(第 41 行纯函数不变量),单轮收敛且不回摆。✅
|
||||
4. **四条 P2**:均落到契约或验收行(41 行取消双判等、92 行 off≠禁止删除且不加每轮日志、T1 空策略 PUT 成功/GET NotFound)。剩余风险是 Policy 键的实现细节(递归排序与大整数精度),已转为 T2 断言,属实现期验证,非方案阻断。✅
|
||||
|
||||
## 非阻断注记
|
||||
|
||||
- 比较键含 Sid,而公开统计仍用忽略 Sid 的 `Equals`:仅 Sid 差异时 heal 会写一次、状态接口报已复制;收敛后二者一致,不会持续分歧。
|
||||
- 建议把 T8 历史桶那行的「一轮 heal」补一句第二轮无写入/广播(与第 78 行完成条件同口径),可选。
|
||||
- 第 40 行末「同状态来源不参与持久化冲突裁决」措辞含糊,实现前口头澄清即可。
|
||||
|
||||
**结论:GO_WITH_NONBLOCKING_NOTES。实施前阻断数:0。** 未发现新反例;已关闭范围(R1/R2/R3、六类 handler、导入、计数、调用图)未重审。
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"reviewer": "Claude Code",
|
||||
"cli_version": "2.1.258",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"permission_mode": "plan",
|
||||
"tools": [
|
||||
"Read",
|
||||
"Grep",
|
||||
"Glob"
|
||||
],
|
||||
"round": 3,
|
||||
"plan_snapshot": "plan-v3.md",
|
||||
"plan_sha256": "8c4ab51a397246a07bb1cc967df3377d26ac084b7928191eb8fecacf6e7c3aed",
|
||||
"command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
|
||||
"status": "completed",
|
||||
"observed_model": "claude-opus-5",
|
||||
"session_id": "3ae0c14b-58cf-415d-8d2d-3a388d322534",
|
||||
"observed_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 181195,
|
||||
"num_turns": 4,
|
||||
"collected_at_utc": "2026-09-12T01:50:13.403953+00:00",
|
||||
"stream_sha256": "728542385eb75e02050c17a389b49d2b4dfacdf1c54801fc6792694f91f08cff",
|
||||
"review_sha256": "557ef70288a9a3939842a2f76d63694e2c103a8bfcaa99b7c161ee89e1f58061",
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"used_tools": [
|
||||
"Read"
|
||||
],
|
||||
"auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
|
||||
"verdict": "GO_WITH_NONBLOCKING_NOTES",
|
||||
"blocking_findings": 0,
|
||||
"temporary_review_worktree_removed": true,
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
## 复核结论(第四轮,仅针对接管增量)
|
||||
|
||||
**必要性成立,证据链闭合。** `loadBucketMetadataParse` 内 `defaultTimestamps`(`cmd/bucket-metadata.go:534`)先把字段时间补成 oldCreated,随后 `SetCreatedAt`(`:190-197`)无条件改写 `Created`,两者之间无补偿;probe 日志两个 ObjectLayer 均得 `nil with field>Created=true`,与 v4 新比较器「时间 > Created 即真实状态、nil 载荷即 tombstone」直接相撞,会伪造删除并向外广播。补丁点选在 `cmd/site-replication.go:946-950` 现有 `lockBucketMetadata` 内,位置正确。
|
||||
|
||||
**充分性成立。** 用「为 0 或 == oldCreated」判定默认值,与计划 §39「等于 Created 仍属 baseline」同一口径,重映射不改变任何状态级别;真实(> oldCreated)时间与全部载荷不动,故不覆盖真修改/真删除。不触 payload、不新增写入或广播路径,#78 配置保护不受影响;baseline-live 之间按稳定键而非时间决胜,平移时间不改变胜负。
|
||||
|
||||
另补一条支持性事实:`enablePeerBucketVersioning:901` 与 handler `:957` 的 `IsZero` 守卫在此路径上已被 `defaultTimestamps` 提前失效,bootstrap 分支本身无法把时间修正到 newCreated——归一化是唯一补救,必要性比 decisions-v4 描述的更强。
|
||||
|
||||
**两点非阻断修正:**
|
||||
|
||||
1. `SetCreatedAt` 对方向无约束,newCreated 也可能晚于 oldCreated;此时旧默认时间变成「严格早于自身 Created」,按 §37 被当作无效候选过滤,历史 baseline-live 静默丧失初始化能力。计划正文写的是「如果 Created 改变」(双向,正确),但 decisions-v4 §S4 与 T8 只写「前移」。T8 应补后移方向断言。
|
||||
2. `defaultTimestamps` 覆盖 11 个字段,补丁只归一六类,其余五类接管后遗留 != Created 的伪真实时间。#77 比较器不读它们,不阻断;实现时请在代码注释写明限定理由,或直接对该函数的全集做同样归一(同等成本,不扩大范围)。
|
||||
|
||||
未发现范围外扩、未证实假设或与已关闭项的冲突;独立桶世代合并仍在既定边界外,正确。
|
||||
|
||||
**GO_WITH_NONBLOCKING_NOTES;实施前阻断数 0。**
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"reviewer": "Claude Code",
|
||||
"cli_version": "2.1.258",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
|
||||
"permission_mode": "plan",
|
||||
"tools": [
|
||||
"Read",
|
||||
"Grep",
|
||||
"Glob"
|
||||
],
|
||||
"round": 4,
|
||||
"plan_snapshot": "plan-v4.md",
|
||||
"plan_sha256": "fbfd37498ab32a589d06254f32182869d5d49fc97722416c46c10b41e5f77816",
|
||||
"command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
|
||||
"status": "completed",
|
||||
"observed_model": "claude-opus-5",
|
||||
"session_id": "72c12e7b-d830-4f3a-9066-5fa829de7a4f",
|
||||
"observed_tools": [
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"duration_ms": 112227,
|
||||
"num_turns": 10,
|
||||
"collected_at_utc": "2026-09-12T01:55:02.590226+00:00",
|
||||
"stream_sha256": "d8f3509c695749cb5fae9e2f7142b887e48a950a5d96818f73a05529feab2bea",
|
||||
"review_sha256": "cf10b9d7963ff2b7d4c0fbb1fe744a0e853549eaf7ce675a94cc000f74fe6682",
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"used_tools": [
|
||||
"Grep",
|
||||
"Read"
|
||||
],
|
||||
"auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
|
||||
"verdict": "GO_WITH_NONBLOCKING_NOTES",
|
||||
"blocking_findings": 0,
|
||||
"nonblocking_notes_disposition": "Final plan adds Created earlier/later/unchanged acceptance and a comment limiting timestamp normalization to the six fields. No implementation scope change.",
|
||||
"final_plan_sha256": "2ee214694f16e5949ed58290364770149aee3748faee3614122fee189ef83add",
|
||||
"temporary_review_worktree_removed": true,
|
||||
"archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
# Issue #77 最小充分修复计划
|
||||
|
||||
版本:v1,2026-09-12。状态:作者自审完成,待 Claude Code Opus 5 Max 对抗评审。
|
||||
|
||||
基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本计划只授权设计与评审,不表示已实现、合并或发布。复现材料见 [当前核验](../../issue-77-current.md)。
|
||||
|
||||
## 目标与范围
|
||||
|
||||
让修复版本间的 Policy、Tags、SSE、Quota、Versioning、Object Lock 桶配置,在合法带源时间的 peer 事件被延迟、重复、乱序或漏发后,能按同一规则应用并由 heal 最终收敛;删除状态必须落盘、能传播,不能被较旧配置复活。桶元数据批量导入的复制入口属于同一范围。
|
||||
|
||||
本轮不重做 #91 的计数、#76 的 Object Lock wire 字段、#78 的桶接管、#103/#156 的整记录锁和删除保护。CORS、Lifecycle/expiry、notification、对象数据复制、MRF、resync、IAM 均不改变其语义。支持与验收对象是协调的 PGSTY 栈;不引入为了上游 MinIO 兼容而修改依赖的工作。
|
||||
|
||||
## 已确定的行为契约
|
||||
|
||||
| 类型 | 专用事件 nil 或解码后空内容 | bulk 字段未提供/null | 真实删除状态导出 |
|
||||
| --- | --- | --- | --- |
|
||||
| Policy / Tags / SSE / Quota | 删除;保留源时间,删除走现有 parse=false 路径 | 不修改该字段 | Policy 保留现状;另三类受下述单一开关控制 |
|
||||
| Versioning / Object Lock | no-op,不能通过 heal 删除配置 | 不修改该字段 | 不新增删除语义 |
|
||||
|
||||
合法的空 quota 对象 `{}` 仍按现有 quota 解析语义处理,不能仅因配额为零就改写为 nil。Policy 空策略继续使用现有专用 admin handler 的判空行为。所有既有字段编码、公开 getter 的 NotFound 行为、RPC 返回结构及权限要求保持不变。
|
||||
|
||||
1. **读取原始字段时间。** 排序使用 `BucketMetadata` 的原始 `*UpdatedAt` 字段,不能用会隐藏删除时间的公开 getter,也不能用整记录 `lastUpdate()` 代替字段时间。
|
||||
2. **创建边界与未知值。** `UpdatedAt == 0` 或 `UpdatedAt == CreatedAt` 的记录不能作为 heal 的权威候选。后者沿用既有创建默认值约定,包括无法恢复真实修改时间的旧记录;全部为此类状态时不写入并给出有界诊断。peer 事件早于当前桶 CreatedAt 时不应用。新产生的实际配置写入时间必须严格大于 CreatedAt。
|
||||
3. **带时间事件的确定性次序。** 先比较源时间;相同时间下,真实删除大于 live,live/live 按稳定载荷字节序比较;内容和时间均相同则 no-op。Policy/Quota 使用既有解析后 JSON 编码作为比较表示;XML 使用解码后的实际文档字节。apply 和 heal 使用同一个比较函数。deployment ID 只在完全相同状态下用于选取日志中的来源,不能作为未持久化的冲突元数据。
|
||||
4. **本地新操作不倒退。** 对上述六类的实际本地写入,锁内分配 `max(UTCNow(), CreatedAt+1ns, 当前字段时间+1ns)`。其他配置类型保持原有 UTCNow 行为。保留现有 Object Lock 强制 Enabled versioning 的规则,不改变 suspend、prefix exclusion、retention 的合法性。
|
||||
5. **nil 与删除区分。** 只有可删除类型的专用事件,或 bulk 中明确提供且按既有规则解析为空的可删除字段,表示删除。bulk 的 nil 必须继续表示未提供。Versioning/Object Lock 的 nil/空输入在任何路径均不能清空配置。
|
||||
6. **写入原子性。** 原始读取、比较、修改及保存全部在现有桶 `metadata.lock` 内。更新用现有 parse=true 路径,删除用 parse=false 路径;bulk 保留当前原始读取、最后统一解析保存的方式,避免 Quota 已解析缓存残留。任何字段校验失败不得保存部分 bulk。无状态变化时不保存、不通知;有变化时成功保存一次,解锁后再通知。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
### 提交 1:原子排序与本地事件时间
|
||||
|
||||
改动主要在 `cmd/bucket-metadata-sys.go`、`cmd/site-replication.go` 和 `cmd/admin-bucket-handlers.go`,只增加六类字段的内部访问/比较/更新时间能力,不引入注册表、接口插件或复制框架。
|
||||
|
||||
- 复用现有 `updateAndParse` 的加载、类型设置、保存和通知边界,增加内部 peer 源时间输入及是否应用的结果。公开 `Update/Delete` 的签名保持不变;专用 peer handler 的公开 error-only 签名也不变。
|
||||
- 六个 peer handler 删除锁外 getter 判旧,使用内部源时间更新/删除路径。对非零源时间执行上述比较,持久化原始源时间而非到达时间;重复和被拒绝的旧事件不落盘。
|
||||
- 批量 `PeerBucketMetadataUpdateHandler` 对六类已提供字段在其已有整记录锁内执行同一比较。不能循环调用会再次取得桶锁的公开 handler。保留 bulk 中未提供字段、既有 CORS 分支以及一次保存语义。
|
||||
- 普通本地六类写入用字段内单调时间。`enablePeerBucketVersioning` 在实际修改现有配置时也用该时间函数;仅缺失字段的创建 bootstrap 保留 CreatedAt 默认值,不给默认配置伪造一次新修改。
|
||||
- **导入生成端一并修复。** 在每桶最终提交锁内,对本次导入涉及的六类字段分配一个共同 `commitAt`:严格大于 CreatedAt 和这些字段现存时间,并且不早于锁内当前时刻。覆盖导入暂存对象中这些字段的时间后,再调用现有 `applyImportedBucketMetadata`。发送 bulk hook 的 UpdatedAt 必须是同一个 commitAt,不能再用整个 ZIP 请求开始时的 updatedAt。未导入字段不改;CORS 继续自己的时间函数和独立事件,Lifecycle/notification 不参与这个时间上界。
|
||||
- 原样保留 `saveMetadata` 的物理桶存在检查、桶删除锁序、后台通知上下文和加载/迁移路径。不得另加一套 peer-require-existing 或新锁。
|
||||
- 对真实历史桶 `Created == 0`,仅在该少见路径取得现有物理桶 Created 并补齐,再按现有默认时间规则加载;物理桶不存在就返回现有错误。若物理创建时间也未知,不能用本次到达时间伪造桶世代:不应用并报告 indeterminate。
|
||||
|
||||
提交 1 的完成条件:六类 peer PUT 落盘保留源时间;四类 DELETE 后旧 PUT 不复活;相同内容重复事件不保存;getter→Update 竞态和 bulk 旧事件覆盖被挡住;本地后续写入可以胜过已有未来源时间;导入的持久化时间与实际发出的事件一致。
|
||||
|
||||
### 提交 2:heal 统一选源与状态同步
|
||||
|
||||
改动主要在 `cmd/site-replication.go`。
|
||||
|
||||
- 先过滤未知/创建默认状态和 update-only 的空候选,再从剩余候选中按相同比较器选最新;必须显式返回 found=false。取消“先用 map 首项初始化,再判断是否默认”的写法。
|
||||
- 为 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类 heal 替换对应选源循环。不触碰 CORS、Lifecycle 或对象数据修复路径。
|
||||
- 本地 heal 通过提交 1 的源时间更新/删除路径;远端 heal 保留对应事件类型并携带选中状态的源时间,补齐 Tag 的 UpdatedAt。
|
||||
- heal 是否写入由完整状态比较决定,不再由公开 mismatch 计数或仅 payload 相等决定。即使内容相同,较旧时间也必须同步;完全相同的状态不保存、不发送。
|
||||
- 保留 #91 的 payload/数量统计语义和字段,不为实现时间戳同步更改 madmin API。持续 indeterminate 或因桶创建边界拒绝而留下的状态差异通过提交 3 的日志解释。
|
||||
|
||||
提交 2 的完成条件:map 顺序不影响选源;默认状态不能删除真配置;全默认/未知状态不修改磁盘;nil 不清空 Versioning/Object Lock;Quota 删除后磁盘和缓存一致;远端 Tag 时间完整;同内容不同时间、同时间不同内容都能确定性收敛。
|
||||
|
||||
### 提交 3:删除传播与有界诊断
|
||||
|
||||
使用一个启动配置开关,暂定名称 `MINIO_SITE_REPLICATION_METADATA_CONVERGENCE`,`off` 为默认,`on` 为显式启用。不加入 madmin/silo-pkg 字段、不新增 RPC、不构建通用能力协商。
|
||||
|
||||
| 行为 | off:兼容升级阶段 | on:全站点修复后 |
|
||||
| --- | --- | --- |
|
||||
| 有源时间的 peer apply、锁内排序、heal | 使用提交 1/2 的修复 | 同左 |
|
||||
| 专用事件零源时间 | 保留旧版兼容:锁内按本地新操作时间应用,限频记录兼容降级;此类事件不在排序收敛保证内 | 拒绝为无效事件,不写入 |
|
||||
| bulk 零源时间 | 维持当前拒绝行为 | 同左 |
|
||||
| Tags/SSE/Quota nil payload 时间导出 | 维持旧版 payload 条件导出 | 导出真实删除时间;零/创建默认仍不是删除事件 |
|
||||
| Policy 时间导出 | 保持已有行为 | 保持已有行为 |
|
||||
| 初次同步的删除状态 | 保持已有行为 | Policy/Tags/SSE/Quota 的真实删除也发送专用 nil + source time 事件 |
|
||||
|
||||
这里的开关只隔离新增的删除传播和不再接受无时间事件的行为。它不证明远端能力;启用的明确条件是所有参与站点、每站点全部节点都已部署包含提交 1/2/3 的修复版本,旧请求已排空。配置同一站点内必须一致。任何旧节点仍在线时保持 off。
|
||||
|
||||
- exporter 只把可区分的真实删除状态作为新增导出对象,不把新桶默认配置改成删除。Policy 已有导出保留,选择端过滤默认值。
|
||||
- `syncToAllPeers` 也使用同一状态判断发送真实删除;不能只修定期元数据查询而遗漏初次同步。保持现有逐类型 RPC 和编码。
|
||||
- 复用已有日志去重/限频设施,固定为有限的原因集合:legacy-zero、stale-conflict、before-created、indeterminate。精确重复属于正常 no-op,不记警告;普通顺序已知的旧事件不制造每轮噪音;仅实际错误/持续无法收敛的原因记录桶、配置类型、当前/源时间,不输出完整策略或配置。
|
||||
- stale 与 before-created 保持 RPC 成功/no-op;缺桶、非法 payload 和 on 模式下的零时间仍返回相应错误。不开新重试循环。
|
||||
- 更新 Server 的 site-replication 文档,写清 off/on、全节点升级条件、零时间兼容降级和回滚限制。回滚/降级前先在所有修复节点关闭该开关,再滚动降级;旧软件原有缺陷会恢复,不宣称回滚后仍具备收敛保证。
|
||||
- 旧版本已经记录成到达时间的历史值不能推导回真实源时间。升级完成后,操作者在看到各站点状态后选择权威配置,重新提交需要纠正的配置/删除;不得自动把时间归零或选任意站点强制覆盖。
|
||||
|
||||
## 最小验收矩阵
|
||||
|
||||
以下均为缺陷或上述修复引入的行为边界;不把发布制品、外部上游服务或不相关产品作为本计划硬门槛。
|
||||
|
||||
| 组 | 必须验证 | 验证层 |
|
||||
| --- | --- | --- |
|
||||
| T1 | 六类 PUT 原样源时间;四类 DELETE;较旧 PUT/DELETE 不回退;精确重复无写入 | 真实 admin 路由 + 两种 ObjectLayer + 磁盘重载 |
|
||||
| T2 | 同时间不同 live 状态两种到达顺序结果相同;可删除类型 PUT/DELETE 同时间删除获胜 | 比较器确定性测试 + 代表性真实 handler |
|
||||
| T3 | Versioning/Object Lock nil/空不删除;保留 lock→Enabled versioning 约束;空 quota 对象语义 | 类型回归 + 现有 #76/#78 测试 |
|
||||
| T4 | 旧 peer 在锁前通过检查,较新写入先提交后旧 peer 再落锁,不能覆盖;并发不同字段均保留 | 确定性锁屏障 + 两种 ObjectLayer,针对性 race |
|
||||
| T5 | bulk 有旧字段/新字段/未提供字段混合,分别忽略/应用/保留;非法字段不部分保存;不能借 bulk 复活墓碑 | admin bulk 路由 + 磁盘/缓存 |
|
||||
| T6 | 本地操作的时间大于收到的未来时间;两个相邻本地提交不倒序;ZIP 导入期间插入写入,最终提交字段与发出的 bulk 时间完全一致 | 本地 API、import 路由、捕获 RPC |
|
||||
| T7 | 多站点排序排列、默认/零时间、全部无候选、空 update-only;同 payload 新时间、同时间冲突;Tag 出站源时间;Quota nil 后缓存清空 | heal + 本地/远端路径 |
|
||||
| T8 | 保存/缓存失效/进程重启后墓碑仍有效;缺桶/排队写入/真实零 Created 历史桶;on/off 与新旧发送端混合;初次同步携带真实删除 | ObjectLayer、现有删除测试、固定旧版与修复版两个 SILO 进程 |
|
||||
| T9 | 短暂断线、事件漏发/重复/乱序后重新连通,六类允许状态收敛;开关 on 后旧零时间事件失败且无修改;日志有界 | 隔离的双站点/多节点实验 |
|
||||
|
||||
实现期间先把已有审计用例改成确定性正式回归:不能将反复遍历 map 的概率复现直接提交为生产测试。先记录未修复失败,再验证修复后通过。完成局部修复后运行相关现有回归、目标 race、仓库必需的格式/静态检查与完整 Go CI;同一基准通过后不因形式要求反复全量测试。
|
||||
|
||||
## 作者自审结论
|
||||
|
||||
- 必要:六类共享的是字段排序与锁内写入;删除、编码、bulk 未提供字段和 Object Lock 约束仍按类型处理。
|
||||
- 充分:普通本地写、专用 peer、bulk/import、local/remote heal、导出、初次同步、重载均在清单内。没有继续保留绕过排序的实际写入入口。
|
||||
- 最小:复用一把已有锁、现有持久化和通知、一套小的字段比较逻辑;只加一个针对实际混合版本风险的开关。没有存储 schema、SDK、Console、客户端或新协议依赖。
|
||||
- 明确限制:off 模式、无时间旧事件、任意未升级节点和已污染的历史时间不承诺自动收敛;on 模式的验收仅针对修复 PGSTY 栈的合法状态与已知桶世代。
|
||||
- 当前只批准进入外部方案评审。任何对抗评审发现的具体遗漏或多余工作,应先修订本文件并复审,再决定进入实现。
|
||||
@@ -1,118 +0,0 @@
|
||||
# Issue #77 最小充分修复计划
|
||||
|
||||
版本:v2,2026-09-12。状态:已处理首轮对抗评审并完成作者复核,待 Claude Code Opus 5 Max 复审。
|
||||
|
||||
基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](../../issue-77-current.md),首轮意见及处置见 [评审处置](decisions-v2.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,以及比较键和字段源时间。复用 CORS 已有设计思路,不改 CORS 本身或扩展为通用框架。
|
||||
|
||||
- 使用 `BucketMetadata` 原始字段时间;字段时间为零时在比较视图中补为 Created,与 `defaultTimestamps()` 一致,不借用会隐藏墓碑时间的 getter 或整记录 `lastUpdate()`。
|
||||
- 在已知 Created 下,零时间或 `fieldTime <= Created` 是 baseline;它不能成为 heal 权威源,也不能作为带时间 peer 更新覆盖实际状态。Versioning/Object Lock 的空候选无论时间如何都不参与选源。
|
||||
- 专用 peer 的零时间保留兼容例外:锁内按本地新操作分配时间并限频记录 legacy-zero;不受删除传播开关影响,不在源时间排序保证之内。bulk 零时间仍按现状拒绝。
|
||||
- 非零事件不晚于目标桶 Created 时成功/no-op,记录 before-created。heal 同时过滤源端 baseline;对候选时间不晚于目标 Created 的目标跳过,不能每轮继续发送必然拒绝的 RPC。不同桶世代不能自动合并,需要运维处理,不纳入收敛承诺。
|
||||
- 有效状态先按源时间排序;同时间 tombstone 胜 live;同时间 live/live 的**稳定比较键字节序较大者胜**。键与时间相同为 no-op。同状态来源按现有稳定遍历/选源方式取即可,deployment ID 不参与持久化冲突裁决。
|
||||
- Quota 使用现有解析结果的 JSON 编码作为比较键。Policy 在 Server 内生成确定性比较表示:既有解析器校验/去重后,对 JSON 对象键和集合数组递归排序,包括 Statement、Action、Resource、Principal、Condition 值。普通 `json.Marshal(BucketPolicy)` 不稳定,不能直接作键;不为此修改 silo-pkg 或 wire/schema。
|
||||
- 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 默认值。
|
||||
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、无效来源和 update-only 空配置,再选最大状态;无候选必须显式返回 found=false。消除六处“先 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 顺序不影响结果;默认值不再删真配置;同内容不同时间、同时间冲突最终一致;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。此隔离有实证依据:旧版接到新增 Quota heal 墓碑会留下已解析缓存残留。
|
||||
|
||||
日志仅保留三个实际原因:legacy-zero、before-created、indeterminate(未知创建时间、缺失/不可达来源或有实际差异却无可用候选)。精确重复、正常旧事件和成功裁决的同时间冲突不记警告。复用 `LogOnceIf`,以稳定的桶/字段/原因作为 key,**错误正文也必须稳定**;变化的时间与 peer 详情放入日志 ReqInfo,沿用现有每小时清理,不新增限流框架、不输出完整策略。
|
||||
|
||||
Server 文档解释启用顺序和回滚:降级前所有修复节点先关开关,然后滚动降级;旧软件缺陷会恢复。点名旧版 Tag heal 无 UpdatedAt 的来源。旧版到达时间污染、legacy-zero 产生的新本地时间以及创建世代分歧无法自动还原;操作者查看状态后在权威站点重新提交需要纠正的配置/删除。历史世代冲突先处理桶身份,不能靠任意站点强刷绕过创建保护。
|
||||
|
||||
## 最小验收矩阵
|
||||
|
||||
| 组 | 必须覆盖 | 证据方式 |
|
||||
| --- | --- | --- |
|
||||
| T1 | 六类 PUT 源时间;四类 DELETE;旧事件不回退;重复无写入;零 quota、空 Policy 的落盘/出站三元组一致 | 真实 admin/S3 路由、ErasureSD/Erasure16、磁盘重载、RPC 捕获 |
|
||||
| T2 | 同时间两种到达顺序结果相同、删除优先;Policy 多 Action/Resource/Principal/Condition 集合反复编码与排列后键稳定 | 确定性比较器测试与代表性真实 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 | 多站点排序排列、baseline/全无候选、空 update-only;同内容新时间;Tag 源时间;Quota 删除清缓存;空 deployment ID 不阻断其它目标;世代冲突跳过且不反复发 RPC | 确定性 heal 本地/远端用例 |
|
||||
| T8 | 墓碑经保存/缓存失效/重启仍有效;缺桶、排队写入、零 Created 历史桶;开关 on/off、legacy-zero 与初次同步删除 | ObjectLayer 与进程内旧版 wire/SRInfo 模拟;复用既有删除/迁移回归 |
|
||||
| T9 | 修复版双站点短暂断线、漏发/重复/乱序后六类合法配置收敛;删除传播启用后第二轮稳定无写入;日志确实有界 | 隔离双站点实验,不把未知桶世代或零时间事件算成通过 |
|
||||
|
||||
固定旧版与修复版混合进程仅作一次性升级冒烟,不新增为长期提交门槛。将已有审计用例改成正式确定性回归,不能把遍历 map 的概率复现直接提交。先记录未修复失败,再验证通过;复用原有 Object Lock、adoption、metadata-lock、计数、CORS 回归,并在实现后运行目标 race、仓库必需检查和完整 Go CI。方案审查、局部测试、双站点结果、合并与发布是不同证据。
|
||||
|
||||
## 作者复核结论
|
||||
|
||||
三个提交分别处理写入正确性、heal 收敛和新增删除信息的升级边界;每项对应已证实缺陷或本次修复直接触及的实际路径。缩减了拒绝零时间的新协议行为、混合版本长期测试门槛和新锁屏障;保留已有持久化、通知与缓存语义。
|
||||
|
||||
首轮三个 P1 意见均已有明确处置:R1 修发送/保存一致性;R2 使用有效状态和提交快照;R3 给出经过实际编解码验证的逐字段规则,而非照搬其不准确的 RawMessage 推断。作者另补充了 Policy 非确定性编码与日志去重问题。外部复审未返回前不标记为可进入实现;后续仍须用实现期测试证明,而非把本计划当作修复完成。
|
||||
@@ -1,120 +0,0 @@
|
||||
# Issue #77 最小充分修复计划
|
||||
|
||||
版本:v3,2026-09-12。状态:首轮三个阻断已被 Opus 复审确认关闭;已修订第二轮发现的历史桶初始化边界,待最后的针对性复审。
|
||||
|
||||
基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](../../issue-77-current.md),意见处置见 [首轮处置](decisions-v2.md)、[第二轮处置](decisions-v3.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。同状态来源不参与持久化冲突裁决。
|
||||
- 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 默认值。
|
||||
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 后一致;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 实质关闭,并确认作者的 Policy 编码和日志去重补查。v3 修正第二轮新增的历史桶 baseline-live 初始化回归,补齐相关验收与 off 模式说明,保留三个实现提交。最后针对性复审返回前不标记为可进入实现;后续仍须用实现期测试证明,而非把本计划当作修复完成。
|
||||
@@ -1,121 +0,0 @@
|
||||
# Issue #77 最小充分修复计划
|
||||
|
||||
版本:v4,2026-09-12。状态:Opus 已对 v3 给出 GO_WITH_NONBLOCKING_NOTES、0 阻断;作者追加实测发现接管时默认时间需随 Created 保持 baseline,补入提交 1 后待该增量复核。
|
||||
|
||||
基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](../../issue-77-current.md),意见处置见 [首轮处置](decisions-v2.md)、[第二轮处置](decisions-v3.md)、[接管边界补查](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 实质关闭,第三轮确认 v3 的历史桶初始化边界修正,给出 0 阻断。作者随后在两个真实 ObjectLayer 上补查到接管改变 Created 会把旧默认时间变成假墓碑,v4 仅补充默认时间随接管保持 baseline 的分支与回归。所有变更仍在三个实现提交内;该增量复审返回前不标记最终方案通过,后续仍须用实现期测试证明,而非把本计划当作修复完成。
|
||||
@@ -1,14 +0,0 @@
|
||||
package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
func main() {
|
||||
p,err:=policy.ParseBucketPolicyConfig(strings.NewReader(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::issue77-test/a/*","arn:aws:s3:::issue77-test/b/*"]}]}`),"issue77-test")
|
||||
if err!=nil {panic(err)}
|
||||
variants:=map[string]int{}
|
||||
for i:=0;i<100;i++ {b,err:=json.Marshal(p);if err!=nil{panic(err)};variants[string(b)]++}
|
||||
fmt.Printf("100 encodings of the same parsed BucketPolicy produced %d distinct byte sequences\n",len(variants))
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
func main() {
|
||||
for _, raw := range []string{`{"bucket":"b"}`, `{"bucket":"b","policy":null,"quota":null}`, `{"bucket":"b","policy":{"Version":"2012-10-17","Statement":[]},"quota":{}}`} {
|
||||
var x madmin.SRBucketMeta
|
||||
if err:=json.Unmarshal([]byte(raw),&x);err!=nil{panic(err)}
|
||||
fmt.Printf("wire=%s\nPolicy nil=%t bytes=%q; Quota nil=%t bytes=%q\n",raw,x.Policy==nil,[]byte(x.Policy),x.Quota==nil,[]byte(x.Quota))
|
||||
if len(x.Policy)>0 && !bytes.Equal(x.Policy,[]byte("null")) {
|
||||
p,err:=policy.ParseBucketPolicyConfig(bytes.NewReader(x.Policy),"b")
|
||||
if err!=nil{panic(err)}
|
||||
fmt.Printf("Policy parses successfully, IsEmpty=%t\n",p.IsEmpty())
|
||||
}
|
||||
}
|
||||
for _, v := range []json.RawMessage{nil,{},[]byte("null")} {
|
||||
b,err:=json.Marshal(madmin.SRBucketMeta{Bucket:"b",Policy:v});if err!=nil{panic(err)}
|
||||
fmt.Printf("encoded raw=%q -> %s\n",[]byte(v),b)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
wire={"bucket":"b"}
|
||||
Policy nil=true bytes=""; Quota nil=true bytes=""
|
||||
wire={"bucket":"b","policy":null,"quota":null}
|
||||
Policy nil=false bytes="null"; Quota nil=false bytes="null"
|
||||
wire={"bucket":"b","policy":{"Version":"2012-10-17","Statement":[]},"quota":{}}
|
||||
Policy nil=false bytes="{\"Version\":\"2012-10-17\",\"Statement\":[]}"; Quota nil=false bytes="{}"
|
||||
Policy parses successfully, IsEmpty=true
|
||||
encoded raw="" -> {"type":"","bucket":"b","updatedAt":"0001-01-01T00:00:00Z","expiryUpdatedAt":"0001-01-01T00:00:00Z"}
|
||||
encoded raw="" -> {"type":"","bucket":"b","updatedAt":"0001-01-01T00:00:00Z","expiryUpdatedAt":"0001-01-01T00:00:00Z"}
|
||||
encoded raw="null" -> {"type":"","bucket":"b","policy":null,"updatedAt":"0001-01-01T00:00:00Z","expiryUpdatedAt":"0001-01-01T00:00:00Z"}
|
||||
@@ -1 +0,0 @@
|
||||
[null,"2026-09-12T09:14:44.759058Z",null,"2026-09-12T09:14:44.759058Z",null,"2026-09-12T09:14:44.759058Z",null,"2026-09-12T09:14:44.759058Z","PFZlcnNpb25pbmdDb25maWd1cmF0aW9uIHhtbG5zPSJodHRwOi8vczMuYW1hem9uYXdzLmNvbS9kb2MvMjAwNi0wMy0wMS8iPjxTdGF0dXM+RW5hYmxlZDwvU3RhdHVzPjwvVmVyc2lvbmluZ0NvbmZpZ3VyYXRpb24+","2026-09-12T09:13:44.759058Z","PE9iamVjdExvY2tDb25maWd1cmF0aW9uPjxPYmplY3RMb2NrRW5hYmxlZD5FbmFibGVkPC9PYmplY3RMb2NrRW5hYmxlZD48UnVsZT48RGVmYXVsdFJldGVudGlvbj48TW9kZT5HT1ZFUk5BTkNFPC9Nb2RlPjxEYXlzPjMwPC9EYXlzPjwvRGVmYXVsdFJldGVudGlvbj48L1J1bGU+PC9PYmplY3RMb2NrQ29uZmlndXJhdGlvbj4=","2026-09-12T09:13:44.759058Z"]
|
||||
@@ -1,333 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
// Standalone disposable loopback-lab driver; see ../../issue-77.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
minio "github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/minio/minio-go/v7/pkg/tags"
|
||||
)
|
||||
|
||||
const bucket = "issue77-runtime"
|
||||
const user = "issue77-lab-admin"
|
||||
const password = "issue77-disposable-lab-secret"
|
||||
|
||||
var ctx = context.Background()
|
||||
var root string
|
||||
|
||||
type site struct {
|
||||
name, address, binary, gate string
|
||||
cmd *exec.Cmd
|
||||
log *os.File
|
||||
proxy *httptest.Server
|
||||
block atomic.Bool
|
||||
calls atomic.Int64
|
||||
dropped atomic.Int64
|
||||
admin *madmin.AdminClient
|
||||
s3 *minio.Client
|
||||
}
|
||||
|
||||
func check(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
func stamp(s string) { fmt.Println(time.Now().UTC().Format(time.RFC3339), s) }
|
||||
func port() string {
|
||||
l, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
check(e)
|
||||
s := l.Addr().String()
|
||||
check(l.Close())
|
||||
return s
|
||||
}
|
||||
func newSite(name, binary string) *site {
|
||||
s := &site{name: name, address: port(), binary: binary, gate: "on"}
|
||||
target, e := url.Parse("http://" + s.address)
|
||||
check(e)
|
||||
reverse := httputil.NewSingleHostReverseProxy(target)
|
||||
reverse.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { w.WriteHeader(503) }
|
||||
s.proxy = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/site-replication/peer/bucket-meta") {
|
||||
data, e := io.ReadAll(r.Body)
|
||||
check(e)
|
||||
r.Body = io.NopCloser(bytes.NewReader(data))
|
||||
var event madmin.SRBucketMeta
|
||||
check(json.Unmarshal(data, &event))
|
||||
if event.Bucket == bucket {
|
||||
s.calls.Add(1)
|
||||
if s.block.Load() {
|
||||
s.dropped.Add(1)
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
reverse.ServeHTTP(w, r)
|
||||
}))
|
||||
s.admin, e = madmin.New(s.address, user, password, false)
|
||||
check(e)
|
||||
s.s3, e = minio.New(s.address, &minio.Options{Creds: credentials.NewStaticV4(user, password, ""), Secure: false})
|
||||
check(e)
|
||||
return s
|
||||
}
|
||||
func (s *site) start() {
|
||||
var e error
|
||||
s.log, e = os.OpenFile(filepath.Join(root, s.name+".jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
check(e)
|
||||
args := []string{"--json", "server", "--quiet", "--address", s.address, "--console-address", port()}
|
||||
for i := 0; i < 4; i++ {
|
||||
dir := filepath.Join(root, s.name, fmt.Sprintf("disk%d", i))
|
||||
check(os.MkdirAll(dir, 0700))
|
||||
args = append(args, dir)
|
||||
}
|
||||
s.cmd = exec.Command(s.binary, args...)
|
||||
s.cmd.Env = append(os.Environ(), "MINIO_ROOT_USER="+user, "MINIO_ROOT_PASSWORD="+password, "MINIO_BROWSER=off", "MINIO_CI_CD=1", "MINIO_SITE_REPLICATION_METADATA_TOMBSTONES="+s.gate)
|
||||
s.cmd.Stdout = s.log
|
||||
s.cmd.Stderr = s.log
|
||||
check(s.cmd.Start())
|
||||
client := http.Client{Timeout: time.Second}
|
||||
deadline := time.Now().Add(50 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
r, e := client.Get("http://" + s.address + "/minio/health/ready")
|
||||
if e == nil {
|
||||
r.Body.Close()
|
||||
if r.StatusCode == 200 {
|
||||
stamp(s.name + " ready gate=" + s.gate)
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
panic(s.name + " startup timed out; inspect " + s.log.Name())
|
||||
}
|
||||
func (s *site) stop() {
|
||||
if s.cmd == nil {
|
||||
return
|
||||
}
|
||||
_ = s.cmd.Process.Signal(os.Interrupt)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.cmd.Wait() }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = s.cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
s.log.Close()
|
||||
s.cmd = nil
|
||||
}
|
||||
func (s *site) info() madmin.SRBucketInfo {
|
||||
i, e := s.admin.SRMetaInfo(ctx, madmin.SRStatusOptions{Buckets: true})
|
||||
check(e)
|
||||
return i.Buckets[bucket]
|
||||
}
|
||||
func (s *site) apply(e madmin.SRBucketMeta) {
|
||||
e.Bucket = bucket
|
||||
check(s.admin.SRPeerReplicateBucketMeta(ctx, e))
|
||||
}
|
||||
func enc(s string) *string { r := base64.StdEncoding.EncodeToString([]byte(s)); return &r }
|
||||
func events(at time.Time, newer bool) []madmin.SRBucketMeta {
|
||||
value, days := "old", 10
|
||||
if newer {
|
||||
value, days = "new", 30
|
||||
}
|
||||
return []madmin.SRBucketMeta{
|
||||
{Type: madmin.SRBucketMetaTypePolicy, UpdatedAt: at, Policy: []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Sid":%q,"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, value, bucket))},
|
||||
{Type: madmin.SRBucketMetaTypeTags, UpdatedAt: at, Tags: enc(`<Tagging><TagSet><Tag><Key>key</Key><Value>` + value + `</Value></Tag></TagSet></Tagging>`)},
|
||||
{Type: madmin.SRBucketMetaTypeSSEConfig, UpdatedAt: at, SSEConfig: enc(`<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`)},
|
||||
{Type: madmin.SRBucketMetaTypeQuotaConfig, UpdatedAt: at, Quota: []byte(`{"quota":1024,"quotatype":"hard"}`)},
|
||||
{Type: madmin.SRBucketMetaTypeVersionConfig, UpdatedAt: at, Versioning: enc(`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>`)},
|
||||
{Type: madmin.SRBucketMetaTypeObjectLockConfig, UpdatedAt: at, ObjectLockConfig: enc(fmt.Sprintf(`<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>%d</Days></DefaultRetention></Rule></ObjectLockConfiguration>`, days))},
|
||||
}
|
||||
}
|
||||
func states(m madmin.SRBucketInfo) []byte {
|
||||
b, e := json.Marshal([]any{m.Policy, m.PolicyUpdatedAt, m.Tags, m.TagConfigUpdatedAt, m.SSEConfig, m.SSEConfigUpdatedAt, m.QuotaConfig, m.QuotaConfigUpdatedAt, m.Versioning, m.VersioningConfigUpdatedAt, m.ObjectLockConfig, m.ObjectLockConfigUpdatedAt})
|
||||
check(e)
|
||||
return b
|
||||
}
|
||||
func times(m madmin.SRBucketInfo) []time.Time {
|
||||
return []time.Time{m.PolicyUpdatedAt, m.TagConfigUpdatedAt, m.SSEConfigUpdatedAt, m.QuotaConfigUpdatedAt, m.VersioningConfigUpdatedAt, m.ObjectLockConfigUpdatedAt}
|
||||
}
|
||||
func converge(a, b *site, label string) {
|
||||
stamp(label + ": waiting for ordinary 30-second heal")
|
||||
deadline := time.Now().Add(130 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if bytes.Equal(states(a.info()), states(b.info())) {
|
||||
stamp(label + ": six field states equal")
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
check(os.WriteFile(filepath.Join(root, label+"-a.json"), states(a.info()), 0600))
|
||||
check(os.WriteFile(filepath.Join(root, label+"-b.json"), states(b.info()), 0600))
|
||||
panic(label + " convergence timed out")
|
||||
}
|
||||
func quiet(a, b *site, label string) {
|
||||
beforeA, beforeB := a.calls.Load(), b.calls.Load()
|
||||
stamp(label + ": observing two heal intervals")
|
||||
time.Sleep(65 * time.Second)
|
||||
if a.calls.Load() != beforeA || b.calls.Load() != beforeB {
|
||||
panic(fmt.Sprintf("%s not quiet: a %d -> %d, b %d -> %d", label, beforeA, a.calls.Load(), beforeB, b.calls.Load()))
|
||||
}
|
||||
stamp(label + ": zero metadata RPCs across 65 seconds")
|
||||
}
|
||||
func main() {
|
||||
root = os.Args[1]
|
||||
binary, e := filepath.Abs(os.Args[2])
|
||||
check(e)
|
||||
check(os.MkdirAll(root, 0700))
|
||||
a, b := newSite("site-a", binary), newSite("site-b", binary)
|
||||
defer a.proxy.Close()
|
||||
defer b.proxy.Close()
|
||||
defer a.stop()
|
||||
defer b.stop()
|
||||
a.start()
|
||||
b.start()
|
||||
created := time.Now().UTC().Add(-time.Hour)
|
||||
check(a.admin.SRPeerBucketOps(ctx, bucket, madmin.MakeWithVersioningBktOp, map[string]string{"createdAt": created.Format(time.RFC3339Nano), "lockEnabled": "true", "versioningEnabled": "true"}))
|
||||
for _, event := range events(created, false) {
|
||||
a.apply(event)
|
||||
}
|
||||
stamp("historical bucket prepared: six live fields at Created")
|
||||
status, e := a.admin.SiteReplicationAdd(ctx, []madmin.PeerSite{{Name: a.name, Endpoint: a.proxy.URL, AccessKey: user, SecretKey: password}, {Name: b.name, Endpoint: b.proxy.URL, AccessKey: user, SecretKey: password}}, madmin.SRAddOptions{})
|
||||
check(e)
|
||||
if !status.Success || status.InitialSyncErrorMessage != "" {
|
||||
panic(fmt.Sprintf("add sites: %+v", status))
|
||||
}
|
||||
converge(a, b, "baseline-initial-sync")
|
||||
for _, tm := range times(a.info()) {
|
||||
if !tm.Equal(created) {
|
||||
panic("initial sync reclocked historical baseline: " + tm.String())
|
||||
}
|
||||
}
|
||||
stamp("initial sync preserved six historical Created timestamps")
|
||||
a.block.Store(true)
|
||||
b.block.Store(true)
|
||||
faultTags, e := tags.NewTags(map[string]string{"fault": "outgoing-message-dropped"}, true)
|
||||
check(e)
|
||||
check(a.s3.SetBucketTagging(ctx, bucket, faultTags))
|
||||
if b.dropped.Load() == 0 {
|
||||
panic("outbound fault injection did not drop a real metadata RPC")
|
||||
}
|
||||
stamp("real local PUT outgoing replication RPC dropped by partition")
|
||||
at := time.Now().UTC().Add(time.Minute)
|
||||
newer, older := events(at, true), events(at.Add(-time.Second), false)
|
||||
for i := range newer {
|
||||
a.apply(newer[i])
|
||||
a.apply(newer[i])
|
||||
b.apply(older[i])
|
||||
a.apply(older[i])
|
||||
}
|
||||
for _, tm := range times(a.info()) {
|
||||
if !tm.Equal(at) {
|
||||
panic("duplicate/out-of-order source time changed: " + tm.String())
|
||||
}
|
||||
}
|
||||
if bytes.Equal(states(a.info()), states(b.info())) {
|
||||
panic("fault setup did not create divergence")
|
||||
}
|
||||
a.block.Store(false)
|
||||
b.block.Store(false)
|
||||
converge(a, b, "live-reconnect")
|
||||
quiet(a, b, "live-steady")
|
||||
for _, event := range older {
|
||||
b.apply(event)
|
||||
}
|
||||
if !bytes.Equal(states(a.info()), states(b.info())) {
|
||||
panic("stale event after convergence rolled back state")
|
||||
}
|
||||
stamp("six duplicate/out-of-order events rejected without clock changes")
|
||||
a.block.Store(true)
|
||||
b.block.Store(true)
|
||||
dropped := b.dropped.Load()
|
||||
check(a.s3.RemoveBucketTagging(ctx, bucket))
|
||||
if b.dropped.Load() <= dropped {
|
||||
panic("ordinary delete RPC was not dropped")
|
||||
}
|
||||
stamp("real local DELETE outgoing replication RPC dropped by partition")
|
||||
deleteAt := at.Add(time.Minute)
|
||||
for _, event := range newer[:4] {
|
||||
a.apply(madmin.SRBucketMeta{Type: event.Type, UpdatedAt: deleteAt})
|
||||
}
|
||||
beforeRestart := states(a.info())
|
||||
a.stop()
|
||||
a.start()
|
||||
if !bytes.Equal(beforeRestart, states(a.info())) {
|
||||
panic("restart lost deletion/source state")
|
||||
}
|
||||
stamp("four tombstones survived process restart")
|
||||
a.block.Store(false)
|
||||
b.block.Store(false)
|
||||
converge(a, b, "delete-reconnect")
|
||||
quiet(a, b, "delete-steady")
|
||||
final := a.info()
|
||||
if len(final.Policy) != 0 || final.Tags != nil || final.SSEConfig != nil || final.QuotaConfig != nil {
|
||||
panic("deletion did not converge")
|
||||
}
|
||||
check(os.WriteFile(filepath.Join(root, "converged.json"), states(final), 0600))
|
||||
// Deliberate legacy and generation exceptions are outside convergence proof.
|
||||
for i := 0; i < 20; i++ {
|
||||
event := newer[1]
|
||||
event.UpdatedAt = time.Time{}
|
||||
a.apply(event)
|
||||
event.UpdatedAt = final.CreatedAt.Add(-time.Duration(i+1) * time.Second)
|
||||
a.apply(event)
|
||||
}
|
||||
data, e := os.ReadFile(filepath.Join(root, "site-a.jsonl"))
|
||||
check(e)
|
||||
for _, reason := range []string{"legacy-zero", "before-created"} {
|
||||
n := strings.Count(string(data), "bucket metadata replication: "+reason)
|
||||
if n != 1 {
|
||||
panic(fmt.Sprintf("%s diagnostics count %d, want 1", reason, n))
|
||||
}
|
||||
}
|
||||
stamp("40 exceptional events produced exactly one log per reason")
|
||||
if len(os.Args) > 3 {
|
||||
stamp("starting fixed/previous-server rolling-upgrade smoke with gate off")
|
||||
a.stop()
|
||||
b.stop()
|
||||
a.gate = "off"
|
||||
b.gate = "off"
|
||||
b.binary, e = filepath.Abs(os.Args[3])
|
||||
check(e)
|
||||
a.start()
|
||||
b.start()
|
||||
tag, e := tags.NewTags(map[string]string{"upgrade": "works"}, true)
|
||||
check(e)
|
||||
check(a.s3.SetBucketTagging(ctx, bucket, tag))
|
||||
remote, e := b.s3.GetBucketTagging(ctx, bucket)
|
||||
check(e)
|
||||
if remote.ToMap()["upgrade"] != "works" {
|
||||
panic("mixed-version live event lost")
|
||||
}
|
||||
check(a.s3.RemoveBucketTagging(ctx, bucket))
|
||||
if a.info().Tags != nil || b.info().Tags != nil {
|
||||
panic("mixed-version ordinary tag delete lost")
|
||||
}
|
||||
if !a.info().TagConfigUpdatedAt.IsZero() {
|
||||
panic("off exporter exposed new tombstone")
|
||||
}
|
||||
stamp("mixed-version ordinary PUT/DELETE and off tombstone visibility passed")
|
||||
}
|
||||
stamp("PASS: isolated two-site implementation acceptance")
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
# Conditional multipart completion across pools
|
||||
|
||||
## Defect and scope
|
||||
|
||||
An unfinished multipart upload can remain in one pool while another pool holds
|
||||
the logical current object. Evaluating `If-Match` against the upload pool's
|
||||
local copy can accept a stale ETag or reject the current ETag. The pools object
|
||||
lock serializes writes, but a local read still does not identify the logical
|
||||
current object.
|
||||
|
||||
This layout does not require rebalance. Commit
|
||||
`83b2ad418b15ff0fa78175e2014d78d02046edd8` (upstream #21115) made `getPoolIdx`
|
||||
choose an available pool even when `pinfo.Err == nil`: normal overwrites and
|
||||
upload initiation can select different pools. This change predates the SILO
|
||||
multi-pool consistency work. The deterministic regression fixtures place copies
|
||||
and uploads directly in real erasure pools; they do not claim to run rebalance.
|
||||
|
||||
## Minimal correction
|
||||
|
||||
For multi-pool conditional completion, retain the existing object lock and use
|
||||
`objectPoolInfos` to read the logical current object before completing the
|
||||
upload. An unreadable pool is an error, not proof of absence. The first sorted
|
||||
copy supplies the ETag and encryption metadata used by the existing callback.
|
||||
A current delete marker is treated as an absent key. `If-Match` then fails for
|
||||
an absent object; `If-None-Match: *` may proceed.
|
||||
|
||||
Use explicit read options with an empty `VersionID` and `NoAuditLog: true`.
|
||||
The precondition concerns the logical current object, independently of an
|
||||
internal completion's destination version. After a successful check, clear
|
||||
the callback before entering the set layer, so it is evaluated only once.
|
||||
No new lock, storage format, replica cleanup algorithm or distributed protocol
|
||||
is introduced. Single-pool and unconditional completion retain their existing
|
||||
paths.
|
||||
|
||||
## Availability and validation
|
||||
|
||||
If any pool cannot supply the required metadata, conditional completion fails,
|
||||
even when GET/HEAD can still read a copy from another pool. The unreadable pool
|
||||
might hold a newer object, a delete marker, or no copy at all; none of these
|
||||
possibilities can be assumed. Retry after recovery. This behavior is recorded
|
||||
in the unreleased changelog.
|
||||
|
||||
The regression suite covers both upload-pool directions, stale/current ETags,
|
||||
`If-None-Match: *`, absent objects and delete markers, read-quorum errors,
|
||||
explicit destination versions, tied modification times, callback counts,
|
||||
upload preservation, signed HTTP error bodies and concurrent completions.
|
||||
The ordinary HTTP routing control accepts every valid placement; deterministic
|
||||
fixtures provide the cross-pool regression gate.
|
||||
|
||||
## Separate follow-up scope
|
||||
|
||||
The pool-placement change and conditional checks in `PutObject` and
|
||||
`NewMultipartUpload` require separate assessment. This completion fix does not
|
||||
repair those paths. In particular, PUT has live destination-version and
|
||||
preserved-ETag semantics, so its repair must not copy this completion-specific
|
||||
empty-VersionID rule without examining that contract. Parallelizing the shared
|
||||
pool metadata reader is also outside this correctness fix.
|
||||
@@ -1,95 +0,0 @@
|
||||
# R4–R8 集成核验
|
||||
|
||||
## 结论与范围
|
||||
|
||||
2026-09-16,R5、R6、R8 的修复在已包含 R4、R7 的 main 基线上完成集成。
|
||||
本地完整 `cmd`、`internal` 测试、相关 race 检查、仓库 verifiers、构建和
|
||||
HTTP 超时进程探针均通过。环境中的真实 `claude-opus-5`(effort `max`)独立
|
||||
阅读合并差异与调用链,结论为 **GO_WITH_NONBLOCKING_NOTES,零阻断项**。
|
||||
|
||||
本记录对应 main 合并前的代码核验。最终 PR 的 Linux CI、DCO 和合并结果以该
|
||||
PR 的实际提交及检查为准;这里的本地结果不代表发布、部署或多站点生产验收。
|
||||
|
||||
## 提交对应关系
|
||||
|
||||
基线:`9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe`。
|
||||
|
||||
| 问题 | 原修复提交 | 集成提交 | 行为 |
|
||||
| --- | --- | --- | --- |
|
||||
| R4 | PR [#193](https://github.com/pgsty/silo/pull/193),已在基线 | `af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd`(merge) | SSE-KMS 复制保留标签修订时间 |
|
||||
| R7 | PR [#194](https://github.com/pgsty/silo/pull/194),已在基线 | `9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe`(merge) | 复制元数据恢复不再重新写入传输用 aws-chunked |
|
||||
| R5 | `115fe8b12329d147adbaf817faa1737392ecbf9b` | `680eac66e40b0980bc20e70d7ad34185096e63f5` | 删除标签推进修订,接收端抵御乱序事件,重试及 ACK 保留新状态 |
|
||||
| R6 | `cf381a7151ef25fc95ace5fedcd767fa19410de2` | `0c61128d23f05ce6b37e7ace713c3ffbfb68f4cb` | 旧形态 marker purge 正确分类,MRF 恢复 marker 并保留重试次数 |
|
||||
| R6 核验记录 | `d38edb2c46182d3a8fa96e040604493d20a4b478` | `aea3882c95d16ec5598a07b40d593e04054137a9` | 保存 v3 共识及验证边界 |
|
||||
| R8 | `0d48d32d7e038ae1ea5966f3d7e0cb86780a6311` | `055030ea53ca92ee22ce1e601ef4757c247edde8` | 配置绑定到读头绝对超时,正文继续采用滚动空闲超时 |
|
||||
|
||||
各原任务先取得 Opus 方案共识,再实施修复。原始方案、实现复核及验证记录保留在
|
||||
[R5](../r5/verification.md)、[R6](../r6/README.md)、[R8](../r8/README.md)。
|
||||
R5、R6、R8 原任务又分别只读核验了集成后的交叉影响,未发现新增生产阻断项。
|
||||
|
||||
集成使用 `git cherry-pick -x -s`,保留原作者、来源及 DCO。后续
|
||||
`80684fed59f556d579e268c5a855d936c1347b68` 仅处理两类贡献规范问题:
|
||||
|
||||
- 六个新建测试文件统一使用实际贡献者姓名及 AGPL-3.0-or-later 头部;从
|
||||
`package` 开始的内容逐字节不变,Linux build tag 保留。
|
||||
- 按 CONTRIBUTING 的规则更新兼容标识清单。唯一新增条目是 R6 测试拼接既有
|
||||
replication ARN 所用的 `arn:minio:replication::`,没有新增协议名称或生产行为。
|
||||
|
||||
22 个源码/测试文件的最终哈希见 [manifest.json](manifest.json)。共享文件中的
|
||||
R5、R6 补丁与原修复具有相同稳定 patch ID;其余源文件直接比较,六个测试仅允许
|
||||
上述头部差异。[等价检查](evidence/integration-equivalence.json)全部通过。
|
||||
|
||||
## Opus 集成复核与处置
|
||||
|
||||
实际 CLI 为 2.1.270,显式指定 `claude-opus-5 --effort max`;只允许 Read、Grep、
|
||||
Glob,未执行测试或修改代码。实际返回模型为 `claude-opus-5`,进程和结果均成功。
|
||||
复核基于 `055030ea53ca92ee22ce1e601ef4757c247edde8` 的 22 个文件及完整差异;
|
||||
此后的代码变化仅为上文已证明等价的头部与兼容清单调整。
|
||||
|
||||
原文、调用元数据及提示词分别见 [复核结果](evidence/opus-review.md)、
|
||||
[metadata](evidence/opus-integration.metadata.json)、[prompt](evidence/opus-integration.prompt.md)。
|
||||
保留原文中的判断,再用直接证据逐项处置,避免把模型意见当作测试结果:
|
||||
|
||||
| 非阻断意见 | 核验与决定 |
|
||||
| --- | --- |
|
||||
| 非法或空的历史标签时间戳可能使复制失败并重试 | 保留 R5 共识中的失败关闭行为;历史异常数据修复另行处理 |
|
||||
| 带标签修订的版本在 resync 时可能多一次 metadata COPY | R5 已接受的可靠性成本;正常 COMPLETED 路径保持原有门控 |
|
||||
| purge 审计状态由 COMPLETE 规范为 COMPLETED,统计开始记录实际目标结果 | R6 的预期行为;后续发布说明应告知审计/指标使用者 |
|
||||
| 配置的较短 ReadHeaderTimeout 同时缩短 TLS 握手窗口 | Go net/http 的预期语义,已在 R8 共识中说明 |
|
||||
| 新增多池标签测试单独运行可能缺少全局初始化 | **未成立**:精确单独运行通过;`consistencyPools` 经 `prepareErasurePoolsWithContext` → `initObjectLayer` → `newTestObjectLayer` 调用 `initAllSubsystems`。保留测试原样 |
|
||||
| 审计 fixture 重复取消可能输出栈信息 | 本地完整及 race 测试通过;不扩大本次生产修复范围 |
|
||||
| 新测试文件头部应按实际贡献者整理 | 已在 `80684fed` 修正,测试代码及 build tag 不变 |
|
||||
|
||||
## 本地直接验证
|
||||
|
||||
下表全部针对 `80684fed59f556d579e268c5a855d936c1347b68`,未使用额外的源码或
|
||||
容量 overlay;测试代码自身的容量 fixture 保留。限制并行度仅为
|
||||
`GOMAXPROCS=4`、`GOFLAGS=-p=2`,并使用
|
||||
仓库 CI 的 `MINIO_API_REQUESTS_MAX=10000`。详细命令、时间和日志哈希在
|
||||
[validation-results.json](evidence/validation-results.json)。
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| `make verifiers`:lint、生成文件、rebrand guard | 通过,76.9 秒;可选 typos 工具按现有 Makefile 规则跳过 |
|
||||
| `make build`、`./silo --version` | 通过,产物为 silo |
|
||||
| `CGO_ENABLED=0 go test -p 2 ./cmd ./internal/... -count=1 -timeout=30m` | 全部通过,340.2 秒,50 个有测试的包 |
|
||||
| `CGO_ENABLED=1 go test -race`,cmd/deadlineconn/http 中变更测试的函数集合 | 通过,46.3 秒;Linux build tag 用例由最终 Linux CI 覆盖 |
|
||||
| `TestAPIPoolsTaggingReplicaDeletion` 精确单独执行 | 通过,无需其他测试预先运行 |
|
||||
| 实际 silo 进程的 CLI/环境变量读头超时探针 | 两种配置均在 100ms 读头限制下拒绝 400ms 才完成的请求头;空闲超时为 2s,随后健康请求成功 |
|
||||
|
||||
进程探针使用二进制 SHA-256
|
||||
`1cc536f1a3c8d8372ff2d5b140b1fd2bc98a299324fea0f73f67288d48104ce4`。
|
||||
原始输出见 [runtime-probe.json](evidence/runtime-probe.json)。
|
||||
|
||||
各子任务较早遇到的磁盘容量不足或筛选测试初始化问题,不作为这次通过的证据。
|
||||
本地完整测试已重新执行并成功;原失败记录仍保留在各自调查档案。
|
||||
|
||||
## 验收边界
|
||||
|
||||
- 最终 PR 必须通过实际提交的全部仓库检查,尤其 Linux internal 测试、完整 cmd
|
||||
测试、构建/vet、lint/生成文件、交叉编译、S3 Select race、DCO 和漏洞检查。
|
||||
- 既有无时间戳对象、异常时间戳、标签筛选的目标选择、任意站点时钟偏差等不由本次
|
||||
修复追溯重建。共享状态解析器的历史限制按 R6 v3 共识在写入点规避。
|
||||
- R8 原先未完成的 S3 长传输脚本不计为通过;本次进程探针验证配置生效,不替代
|
||||
S3 长传输、独立多进程、多节点或跨区域生产验收。
|
||||
- 本次不引入依赖变更或上游 MinIO 兼容硬门槛;R9 不属于这五项修复。
|
||||
@@ -1,79 +0,0 @@
|
||||
ok github.com/minio/minio/cmd 320.458s
|
||||
ok github.com/minio/minio/internal/amztime 1.605s
|
||||
ok github.com/minio/minio/internal/arn 0.392s
|
||||
ok github.com/minio/minio/internal/auth 0.440s
|
||||
ok github.com/minio/minio/internal/bpool 0.422s
|
||||
ok github.com/minio/minio/internal/bucket/bandwidth 0.450s
|
||||
ok github.com/minio/minio/internal/bucket/cors 0.484s
|
||||
ok github.com/minio/minio/internal/bucket/encryption 0.644s
|
||||
ok github.com/minio/minio/internal/bucket/lifecycle 0.647s
|
||||
ok github.com/minio/minio/internal/bucket/object/lock 0.653s
|
||||
ok github.com/minio/minio/internal/bucket/replication 0.496s
|
||||
ok github.com/minio/minio/internal/bucket/versioning 0.449s
|
||||
ok github.com/minio/minio/internal/cachevalue 5.457s
|
||||
? github.com/minio/minio/internal/color [no test files]
|
||||
ok github.com/minio/minio/internal/config 0.656s
|
||||
? 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 0.624s
|
||||
ok github.com/minio/minio/internal/config/dns 0.731s
|
||||
? github.com/minio/minio/internal/config/drive [no test files]
|
||||
ok github.com/minio/minio/internal/config/etcd 1.015s
|
||||
? github.com/minio/minio/internal/config/heal [no test files]
|
||||
ok github.com/minio/minio/internal/config/identity/ldap 0.593s
|
||||
ok github.com/minio/minio/internal/config/identity/openid 0.675s
|
||||
? 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 1.057s
|
||||
? github.com/minio/minio/internal/config/lambda [no test files]
|
||||
ok github.com/minio/minio/internal/config/lambda/event 0.485s
|
||||
? github.com/minio/minio/internal/config/lambda/target [no test files]
|
||||
ok github.com/minio/minio/internal/config/notify 0.752s
|
||||
? 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 0.585s
|
||||
ok github.com/minio/minio/internal/config/subnet 0.582s
|
||||
ok github.com/minio/minio/internal/crypto 0.843s
|
||||
ok github.com/minio/minio/internal/deadlineconn 4.934s
|
||||
ok github.com/minio/minio/internal/disk 0.418s
|
||||
ok github.com/minio/minio/internal/dsync 131.144s
|
||||
ok github.com/minio/minio/internal/etag 0.504s
|
||||
ok github.com/minio/minio/internal/event 0.595s
|
||||
ok github.com/minio/minio/internal/event/target 0.957s
|
||||
ok github.com/minio/minio/internal/grid 7.616s
|
||||
ok github.com/minio/minio/internal/handlers 0.717s
|
||||
ok github.com/minio/minio/internal/hash 0.700s
|
||||
? github.com/minio/minio/internal/hash/sha256 [no test files]
|
||||
ok github.com/minio/minio/internal/http 13.643s
|
||||
? github.com/minio/minio/internal/init [no test files]
|
||||
ok github.com/minio/minio/internal/ioutil 1.921s
|
||||
ok github.com/minio/minio/internal/jwt 0.423s
|
||||
ok github.com/minio/minio/internal/kms 0.538s
|
||||
ok github.com/minio/minio/internal/lock 1.077s
|
||||
ok github.com/minio/minio/internal/logger 0.546s
|
||||
? 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 10.501s
|
||||
? 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 0.511s
|
||||
ok github.com/minio/minio/internal/rest 0.525s
|
||||
ok github.com/minio/minio/internal/ringbuffer 1.316s
|
||||
ok github.com/minio/minio/internal/s3select 0.603s
|
||||
ok github.com/minio/minio/internal/s3select/csv 0.446s
|
||||
ok github.com/minio/minio/internal/s3select/json 0.439s
|
||||
ok github.com/minio/minio/internal/s3select/jstream 0.417s
|
||||
? 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 0.425s
|
||||
ok github.com/minio/minio/internal/store 1.451s
|
||||
@@ -1,50 +0,0 @@
|
||||
[
|
||||
{
|
||||
"file": "cmd/replication-delete-mrf_test.go",
|
||||
"before_sha256": "5e160f7e19cbbb8cd5fa4e7ffd9cff9e09361b3fc4c5ee5ae61458f99c777781",
|
||||
"after_sha256": "6fcfaf505d28f98e42dff9c0d965895be2c2d112e9e996220d424aea1f76d691",
|
||||
"body_sha256": "5b3cecec74273540f4a5f82ca55e8a28545d0252822af3568f7e36b19011bda8",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
},
|
||||
{
|
||||
"file": "cmd/replication-delete-operation_test.go",
|
||||
"before_sha256": "2888a04a543324776041316de2821f388d28c3c1a6f5d0031e5ac9d34f58d504",
|
||||
"after_sha256": "2e674cab5ca4dbc38cb2c1ddcca117269276e6419e1869c154c3bd6a05676715",
|
||||
"body_sha256": "7921af42a9f123450e3e567d0bf65cd030678404709c77fde4a9cbb366230c35",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
},
|
||||
{
|
||||
"file": "cmd/server_deadline_config_test.go",
|
||||
"before_sha256": "1013157f83baa5f7882ec2d41c7b1fccb9e05fb418d0fa61263953037c9698c4",
|
||||
"after_sha256": "a8259d273922a8973b44d9a468791761d373fe265fff8b2b53871e4626b11405",
|
||||
"body_sha256": "5714a9275aee7230d54ba8ed03e3705a3cf6e150bda122929a9131b4b2b8dd5c",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
},
|
||||
{
|
||||
"file": "internal/deadlineconn/deadlineconn_strict_test.go",
|
||||
"before_sha256": "f405690c9ff044595f48323d68f4a9b33ce695b3ad6820f54f17151067bfae5e",
|
||||
"after_sha256": "b4aec28c5daddb36e6ebb98dd8af2a44b7a2c48f0660068534f69669609f67a7",
|
||||
"body_sha256": "ab2b54e29ed34de6e59ba2fa4984d61ed5f93d74ae4f6cd22dd44ba77a37e569",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
},
|
||||
{
|
||||
"file": "internal/http/dial_deadline_linux_test.go",
|
||||
"before_sha256": "0939d05b72a09760d53fcdf249775989e3f89bca824b9961d0b2a657ebfdf41e",
|
||||
"after_sha256": "c4c83bda92ba9bac53166453920456134b3d8265cd59101b4203067c67eca59e",
|
||||
"body_sha256": "fac091ef08f29fe32c2668eccd8cd505106c9fe3c4715e4c3ed9063d4b71c86e",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
},
|
||||
{
|
||||
"file": "internal/http/server_deadline_test.go",
|
||||
"before_sha256": "a6e687b3904a876fa92a4c5b86453159f3e5a38a4b9412dc213c7772f47fbf0b",
|
||||
"after_sha256": "87303cc389bf1cf759898489f06b001073de2dd9c4b3687c4eca14aa186b62ab",
|
||||
"body_sha256": "20b238f34090c356b2254388e863d77d0a316f33b849291d15d94fad260f23dd",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
}
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"head": "80684fed59f556d579e268c5a855d936c1347b68",
|
||||
"checks": {
|
||||
"r6_shared_file_patch": true,
|
||||
"r5_shared_file_patch": true,
|
||||
"cmd/erasure-object.go": true,
|
||||
"cmd/erasure-server-pool-consistency.go": true,
|
||||
"cmd/erasure-server-pool.go": true,
|
||||
"cmd/object-handlers-common.go": true,
|
||||
"cmd/object-handlers.go": true,
|
||||
"cmd/object-multipart-handlers.go": true,
|
||||
"cmd/replication-tagging-order_test.go": true,
|
||||
"cmd/replication-tagging-sender_test.go": true,
|
||||
"cmd/bucket-replication-utils.go": true,
|
||||
"cmd/replication-delete-marker_test.go": true,
|
||||
"cmd/replication-delete-operation_test.go": true,
|
||||
"cmd/replication-delete-mrf_test.go": true,
|
||||
"cmd/common-main.go": true,
|
||||
"cmd/server-main.go": true,
|
||||
"internal/deadlineconn/deadlineconn.go": true,
|
||||
"internal/http/listener.go": true,
|
||||
"internal/http/server.go": true,
|
||||
"cmd/server_deadline_config_test.go": true,
|
||||
"internal/deadlineconn/deadlineconn_strict_test.go": true,
|
||||
"internal/http/dial_deadline_linux_test.go": true,
|
||||
"internal/http/server_deadline_test.go": true,
|
||||
"only_reviewed_hygiene_changes": true,
|
||||
"dco:80684fed59f556d579e268c5a855d936c1347b68": true,
|
||||
"dco:055030ea53ca92ee22ce1e601ef4757c247edde8": true,
|
||||
"dco:aea3882c95d16ec5598a07b40d593e04054137a9": true,
|
||||
"dco:0c61128d23f05ce6b37e7ace713c3ffbfb68f4cb": true,
|
||||
"dco:680eac66e40b0980bc20e70d7ad34185096e63f5": true
|
||||
},
|
||||
"all_pass": true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user