A Maven publish can commit a maven_versions row under a package tombstone

The window

MavenPackageStore.FindOrCreatePackage resolves the package on the pool, before the upload body streams. A package mark can commit soft_deleted_at on that row after the resolve. The upload then calls MavenVersionStore.FindOrCreateVersion, also on the pool. That statement reads no parent column. Its only liveness predicate is on the version's own soft_deleted_at. So it commits a live maven_versions row under the tombstoned package.

The row is committed outside the upload's own transaction, so it survives the upload.

What the client sees

The client gets a 404, and this is the difference from #901 (closed).

upsertFileRow calls MavenPackageStore.ReVerifyPackageAlive inside the commit transaction. That check takes FOR UPDATE on the package row and holds it to commit. The whole commit transaction rolls back. TestUpload_PackageMarkedMidCommit_FailsTheCommit in internal/format/maven/upload_liveness_race_test.go pins that behavior.

maven_files is therefore not affected. maven_versions is the one child table with no such re-check.

What the window leaves behind

Three states, and none of them is a lost artifact:

  • A live maven_versions row under a tombstoned package, with no maven_files row under it. MavenPackageReaper's state-blind version page removes it on a later chunk.
  • One ErrReapParentPinned on the package delete, raised by fk_maven_versions_maven_package_id_maven_packages. The chunk rolls back whole, and the tombstone drains on the attempt that follows.
  • A components_count value that is one low between the mark and the reap. S22 reconciliation corrects it.

The decision this issue tracks

The purge destroys a row that a client request created, and nothing declares that as the intended semantic. Two answers are open.

Fence the insert. Take the maven_packages row lock before the maven_versions insert, so the insert and the mark serialize. A pool-side re-read of soft_deleted_at only narrows the window. It does not close it. This is an upload-path change in internal/format/maven/ and internal/datastore/maven_versions.go, not a lifecycle change.

Accept delete-wins and record it. Add a row to ## Error Cases in docs/specs/S20-a-lifecycle-closed-beta.md, beside the repository-level row that !1953 (merged) merged. Add one integration test that pins the interleaving: package resolved live, mark lands, version row commits, then the package reaper drives the tombstone.

The in-tree precedents point both ways

npm closed the same window. LockActiveNpmPackageForPublish in internal/datastore/npm_packages.go takes SELECT ... FOR UPDATE on the active npm_packages row. Its doc comment names this exact failure: "leaving an active version orphaned under a soft-deleted package with permanently skewed artifacts_count / size_bytes counters".

The Maven reaper already declares delete-wins at the code level. deleteReapedMavenVersionStmt in internal/datastore/lifecycle_reap_maven.go says "a live version under a tombstoned parent is reaped on the same terms as a marked one".

So the semantic is already implemented. What is missing is a decision that says it is intended.

Where the semantic is recorded today

docs/dev/storage-accounting.md already declares the general cause: a concurrent writer that commits a child row under the parent after a chunk took its snapshot. Merge request !2040 (merged) corrects the Maven paragraph so it names this window and the foreign key that refuses the package delete. While !2040 (merged) is open, main still carries the sentence that denies the window. Once it merges, main names the window and points at this issue.

docs/dev/storage-accounting.md carries a wider form of the same claim on a line !2040 (merged) does not touch, in the paragraph about a page that reads its ids with a SELECT. That sentence generalizes "nothing inserts under a tombstone" to every table. It needs the same correction, and it is not in !2040 (merged)'s diff.

#901 (closed) is the repository-level sibling. Its scope list says "A Maven upload is not affected", on the ground that MavenRepositoryStore.ReVerifyRepositoryAlive re-verifies the parent chain inside the commit. That ground holds for maven_files. It does not hold for the maven_packages and maven_versions rows that the pre-stream resolve creates on the pool.

#535 asks for the same fence on the same statement, for a different consequence. Its scope bullet 1 is "Take the package FOR UPDATE lock (or an equivalent serialization against Reconcile) on the upload path". It states that its own half "has no soft-delete component at all". Whoever takes either issue closes both.

Related to #937 (closed)