fix(maven): gate metadata erasure on the empty version set alone (S20-A Maven metadata purge plan: 4/5)
Summary
Serializes Maven package-metadata erasure with concurrent version creation, and gates it on the empty live-version-set check alone rather than on whether the previously stored maven-metadata.xml parses.
Before this change, reconcileOnce parsed the stored metadata document before checking the live version set, so an unparseable, oversize, or plugins-shaped document under an emptied package was skipped instead of erased, and the erasure itself ran directly on the connection pool with no lock, racing a concurrent FindOrCreateVersion.
eraseEmptiedPackageMetadata now runs inside datastore.RunInTx: ReVerifyRepositoryAlive (FOR SHARE), then ReVerifyPackageAlive (FOR UPDATE, the lock FindOrCreateVersion also takes FOR SHARE), then the guarded SoftDeletePackageMetadataFile update on the transaction handle. Both re-verify calls now also reject a wrong-but-live repositoryID argument and a bare connection-pool handle, mirroring the guards writeRewrittenMetadata and its sibling re-verify seams already have.
Spec and plan
- Spec: S10 Reconciliation on deletion, S20-A hosted Maven package metadata tombstones
- Plan:
docs/plans/2026-09-11-maven-metadata-purge.md, Step 4 (Erasure and publication coordination)
What changed
internal/format/maven/reconciler.go: reorderedreconcileOnceso the live-version-set check gates the erasure arm ahead ofreadStoredMetadata; addederaseEmptiedPackageMetadata, which runs the erasure under the package lock and rejects a repository/package parentage mismatch. Both arms now sharereverifyPackageChainfor the parentage guard, the repository FOR SHARE re-check, and the package FOR UPDATE re-check, so the lock order has one home. The parentage-mismatchlogger.ErrorContextthat carriedpackage_repository_idandargument_repository_idis removed from both arms: the sentinel is returned rather than logged, per the go-style handle-each-error-once rule. A mismatch therefore no longer logs those two IDs.internal/datastore/maven_files.go:SoftDeletePackageMetadataFilenow takes the caller's transaction handle and rejects a bare connection-pool handle at runtime, matchingReVerifyPackageAlive/ReVerifyVersionAlive.- Tests: two new integration test files covering the erasure's lock composition (concurrent publish ordering, rollback, dead-repository/dead-package aborts) and the HTTP-level metadata/sidecar reads before and after erasure; new unit and integration coverage for the parentage guard, the connection-pool guard, and the erasure arm's stale-retry path through
Reconciler.Reconcile. docs/testing/e2e/maven.md: adds thee2e.maven.lifecycle.delete-last-versionscenario and describes the erasure arm alongside the existing rewrite-arm description.- Documentation:
docs/user/**is intentionally out of this MR. The product documentation for a plan is authored by the end-of-plandocs-authorpass, which runs once per plan after its steps merge (docs/dev/agentic-development.md); Step 4's file list owns onlydocs/testing/e2e/maven.md.
Size
1,111 reviewable LOC, over the 500 LOC ceiling. Not split, because the pieces are interdependent: the datastore signature change, the reconciler reordering, and their tests prove one correctness property together (metadata erasure is safe under a concurrent publish), and splitting them would let one land without the lock discipline the other half proves.
By file group:
- Production code: 166 LOC (
wire_maven.go,maven_files.go,reconciler.go) - Tests: 935 LOC (two new integration test files, plus unit/integration coverage for the parentage guard, the pool-rejection guard, and the stale-retry path)
- Docs: 10 LOC (
docs/testing/e2e/maven.md)
Test plan
go test ./internal/datastore/... ./internal/format/maven/...(plain unit tests)go test -tags=integration ./internal/datastore/... ./internal/format/maven/...(integration suite, real Postgres)golangci-lint runand the--build-tags=integrationvariant on both packagesscripts/ci/check-comment-caps.sh --base main
Related to #1167 (closed)