chore(datastore): container hosted reap (S20-A plan: 13/21)
What this step delivers
ContainerReaper.Reap removes the subtree under one tombstoned container_images row: the manifests, the blobs, the tag and relationship rows, and each of those rows' blob_storage_attachments row. It reaps parents first, and it reaps through the two existing per-artifact deleters rather than through a cascade of its own.
The reason for the deleters is that a raw cascade leaves the same rows absent. Row absence therefore cannot tell the two apart. A seam between the walk and the deleters is the only place the difference is observable. The walk therefore drives them through two narrow interfaces, and the tests decorate those interfaces and count the calls.
The walk pages the image's root manifests, then its blobs. MaxLifecycleReapPageSize = 1000 bounds both pages, and Reap rejects a larger limit before any statement runs. A subtree larger than one chunk stays partly reaped for the next call. The image row goes last, and only in a chunk where both legs report a drained table.
Reap requires the caller's open transaction, per ADR-007. Every statement runs on the handle the caller passes, and Reap begins and commits nothing. qrm.DB exposes no Commit and no Rollback, so the doc comment carries that obligation to the caller rather than the signature.
There is no caller yet. Reaper had no production implementor before this step, and it has no production caller after it. internal/datastore/lifecycle_reap_container.go:141 carries _ Reaper = (*ContainerReaper)(nil), and that compile-time assertion is the whole of the wiring. NewContainerReaper has no caller outside its own file. The chunk driver that will own the transaction lands with internal/lifecycle at step 15, and background reaping is turned on at step 16.
The documentation corrections that ride along
The implementation and its review found all of them. Each is small, and each is in a file this MR already had reason to open.
docs/dev/conformance.mdsaid the delivery mode ofscripts/conformance/run.shmatches the Maven job'sDELIVERY_MODE_PROXY.run.shsetsDELIVERY_MODE_REDIRECT, and it did so before that sentence was written.scripts/conformance/npm-e2e.shdoes setPROXY, so half the sentence was wrong. The correction names each script with the value it sets.- The S20-A spec carries four amendments,
+11 / −4in total:- The image-tombstone section said the change adds no reap logic. The walk is written at the image level first, as
ContainerReaper, and the repository level reuses it, so the direction was inverted rather than imprecise. - The resolution on deleter reuse said that reaping parents first leaves the existing deleter reusable unchanged. What stays unchanged is the cascade logic. The handle changed in both deleters.
- The quoted
cascadecomment in the same section is updated to match the source this MR edits. - The Follow-ups entry on the two comments that attributed blob reclamation to S20 is closed, because both are now corrected. The entry also records what the second correction had to fix, which is the deduplication boundary below.
- The image-tombstone section said the change adds no reap logic. The walk is written at the image level first, as
What the review round changed
The first three commits are the state an earlier version of this description described. Eight commits followed. Six of them answer 22 review findings. The seventh enforces the transaction the two delete sequences need, and the eighth scopes a work item's attribution. Production code moves in three places across the eight: the digest added to the refusal wrap, and one pool guard on each delete sequence. Everything else is comments, the spec, and tests. The findings a reviewer will notice:
- The
Reapcontract now states every obligation it puts on its caller, where it previously stated only the transaction. Eligibility is the caller's, and it is the one obligation with no recovery behind it.Reapbinds the namespace and the image and nothing else. An id from the wrong scan therefore hard-deletes a live image's whole subtree, and it can reach another repository inside the namespace. Nothing bounds how long a chunk waits either. The service sets nostatement_timeoutand nolock_timeout, and a sweep-driven call inherits no request deadline. A chunk also pins one pool connection for its whole duration. - The reap clamps at the shared
MaxLifecycleReapPageSize, which is a refusal boundary and not a suggested page size.ContainerReaper.Reap's doc separates the measured half of a chunk's cost from the unmeasured one, so a caller does not read the whole of it as priced. Measured: the page term, about 1.2 s for a thousand executions against a 5000-manifest chain. Unmeasured: six statements per cascaded manifest and three per unlinked blob. One of the six carries ten correlatedNOT EXISTSarms and pays its planning per row. - The refusal path gained a diagnosis.
cascadeLayerdropped the refused digest, and the datastore layer has no logger of its own. An operator triaging a stalled purge therefore got no attribution at all. The digest is now in the wrap. A refused image delete now runs throughmapReapParentDeleteError, so it reaches the caller as the mergedErrReapParentPinnedsentinel, which is what theReapercontract tells a caller to match on a parent delete. The container reaper was the only one of the seven not routing its parent delete that way.40P01is not a foreign-key violation, so a deadlock still travels out through the op-prefixed wrap and the caller retries it at once. No sentinel of this step's own was added. - The blob unlinker's deduplication boundary was wrong and is corrected. The comment made physical reclamation wait on cross-namespace reachability. Deduplication is scoped to one namespace (ADR-002, ADR-022), and
fk_blob_storage_attachments_ns_id_and_sha256_blob_storage_blobscarriesnamespace_id, so no attachment outside the namespace reaches the blob row. A blob becomes reclaimable when its attachment count inside its own namespace reaches zero (ADR-025). The same commit also records a gap once, on the type whose contract it qualifies: no statement takes a blob-row lock, and the schema holds no candidate table. That record points at work item 676. - Four tests were added, each closing a predicate that can be deleted or tightened with the suite still green.
TestContainerReaper_ReapPages_ArePartitionPrunedpins both page statements' plans in the shared explain suite.TestContainerReaper_RootManifestPage_ChildOfAnotherImagepins the anti-join's namespace scoping, which every fixture had hidden.TestContainerReaper_Reap_WrapsLegFailurescovers the three leg-failure returns that nothing triggered.TestContainerReaper_Reap_AcceptsTheMaximumPageSizepins the clamp's accepted boundary, which the guard table cannot carry because every row there asserts an error. - Work item 676's attribution is scoped to the lock. The record in
internal/datastore/container_blob_unlinker.gonames two ADR-025 obligations: a blob-row lock, and a reclamation candidate captured in the same transaction. Its closing sentence followed both obligations, so it read as a claim that the work item tracks both. Work item 676 covers the blob-row lock alone, at every attachment mint and every unreference. The comment now says that, and it says the reclamation candidate has no tracked owner and arrives with whatever creates the candidate table.
The commit messages carry the rest, one finding at a time.
The two existing deleters, and the authority for touching them
The plan's Files: list for step 13 names two new files and no Modify. This MR also edits internal/datastore/container_manifest_deleter.go and internal/datastore/container_blob_unlinker.go. The operator authorized that, and no plan amendment goes with it.
ContainerManifestDeleter.cascadetakesdb qrm.DBin place oftx *sql.Tx.ContainerBlobUnlinker.UnlinkBlob's two deletes moved into a new unexportedunlink, which takes the same parameter.UnlinkBlobkeeps itsBeginTx,CommitandRollbackenvelope and delegates tounlink.
Both exported entry points keep their envelopes and their behavior. The walk runs the two sequences on the transaction its caller passed.
This is necessary rather than convenient. The merged acceptance criterion requires that the test observe the deleters as they run. The spec therefore closes the store-level route around the deleters to this step.
The accepted smell, named, and what now enforces it. Two functions give up a compile-time transaction guarantee, on a seam that ADR-007's same-transaction attachment rule governs. That guarantee does not come back: the parameter is still qrm.DB, and no signature on the path to either sequence refuses a pool. The obligation is enforced at run time instead, once per sequence. validateCascadeWriteHandle and validateUnlinkWriteHandle refuse a *sql.DB on entry, each with its own sentinel, so a caller that runs errors.Is across stores learns which sequence refused it. A *sql.Tx passes, and so does any other qrm.DB that is not the pool. Neither validator carries a nil arm, because each sequence's first statement returns its own nil-handle error before the handle is touched. The remaining alternative is a second copy of both sequences with no test that compares them, and that one is worse.
The widening moves cascade toward that rule rather than away from it. An unwidened cascade cannot run inside the chunk transaction at all, and BlobStorageAttachmentStore.DeleteIfUnreferenced already takes a qrm.DB under exactly this documented contract.
The behavior-preserving claim is measured rather than asserted: the whole internal/datastore integration suite passes with both edits in place. That run predates the two pool guards, which add a refusal on a handle no caller passes.
Query names
This arm adds 2 names, container_manifests_select_reap_root_page and container_blobs_select_reap_page. The first is one name that the manifest leg re-executes once per layer it peels, and not one name per layer.
The basis travels with the total, because the basis moves. Measured on this branch at 03:03 UTC on 2026-08-20: 352 names, 340 in internal/datastore/query_names.go and 12 in internal/storage/queries.go. That total already carries this arm's two. The ceiling is 400, in internal/metrics/cardinality.go, and TestNameBudget_CoversEveryDeclaredQueryName in internal/metrics/name_budget_test.go asserts it. The test passes, and it needs no database and no build tag. Headroom on the branch is 48.
main moves under that figure. Measured in the same minute, main at 3e2293005 declares 353 across the same two catalogs. Neither of this arm's two names is among them. No merged total is projected here, because main keeps adding names: read the basis at merge time rather than from this paragraph.
Both figures are declaration counts on each side, not + lines in a diff: gofmt realignment shows as added lines and overstates the arm by more than double.
No remainder is quoted for the other steps. The sibling arms are concurrent rather than alternative, so base-plus-own-arm arithmetic overstates what is left for everyone else.
Four readings that are deliberate
A reviewer can read each of these as an oversight, so each is stated.
Reapdoes not re-read the image's ownsoft_deleted_at. Step 14's repository walk must reap live images and already-marked ones in one pass. A predicate on the image's own state makes that impossible. The image's mark is what already withdrew the whole subtree from every read.ReapTotals.SizeBytesstays zero, and not for want of a column.container_manifests.sizeis a manifest-tree total, and ADR-007 says of it that it can double-count blobs shared across manifests or across an index's children. What an image contributes to a byte total belongs where a purge issues its counter deltas, at step 19.Componentsis not zero: it is thecontainer_manifestsrows the chunk cascaded, which is what a namespace's component count holds for hosted OCI.Rowsruns to2*limitrather than tolimit.limitbounds artifacts, and both legs draw on one budget, while a blob reports two rows against a manifest's one. A page per family, which gives3*limit, is the alternative the contract permits. This arm declined it.- The shared ten-arm attachment delete stays as it is here, and the three sibling reapers each add a narrowed one. !1705 (merged) adds
DeleteIfUnreferencedByNpm, !1702 (merged) addsDeleteIfUnreferencedByMaven, and !1704 (merged) addsDeleteIfUnreferencedByContainerRemote. Each stands besideBlobStorageAttachmentStore.DeleteIfUnreferencedrather than replacing it, and each is called from its own new reaper. This arm calls no narrowed method, because its manifest leg reaches the shared one throughContainerManifestDeleter.cascade. That function is the merged client-facing manifest-delete path: the OCI/v2/manifest delete and the management API's manifest delete both run it. Narrowing it here changes a live path, where a sibling adds a new statement for a new reaper. The cost of the ten arms is disclosed instead, inContainerReaper.Reap's doc comment, as the unmeasured half of a chunk at the ceiling.
Spec coverage
test-author produced this table before the implementation existed, and it is pasted unchanged. Three claims in it moved afterwards, and the corrections are here rather than in the table:
- The
ReapTotals.Rowsrows read against alimitbound. The bound is2*limit, for the reason above. - S-4 says "five argument guards". The guard table now pins eight sentinels across nine cases: the page-ceiling refusal, the connection-pool refusal, and the nil-seam refusal were each added after that line was written. Eight of the nine cases reject an argument and the last rejects the receiver; the two limit cases share one sentinel.
TestContainerReaper_Reap_AcceptsTheMaximumPageSizepins the accepted boundary alongside them. - AC-52 says
Componentsis asserted only where nothing was deleted.Componentsnow carries the cascaded-manifest count, andTestContainerReaper_Reap_ChunkBoundasserts it against the seeded manifests.
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-1 | Repository tombstone older than the window is discovered, newer is not | Not this step — step 5, TestLifecycleScanStore_ScanTombstonedRepositories |
| AC-2 | A soft_deleted_at IS NULL row is never discovered by any scan or walk |
Not this step for the scans (steps 5, 6, 7). Nothing below container_images carries the column, so the walk half is step 14's |
| AC-3 | The three parent-level scans use their index and prune to one partition | Not this step — steps 5 and 7 |
| AC-4 | The version-level scans use their indexes and prune to one partition | Not this step — step 6 |
| AC-5 | Re-running a purge on a reaped subtree is a no-op reporting success; a mid-walk abort leaves fewer rows | Partial: TestContainerReaper_Reap_ChunkBound covers the re-run half for this reaper. The gofail mid-walk abort is step 14's |
| AC-6 | The npm repository walk reaps live and already-tombstoned rows in one pass | Not this step — step 8 |
| AC-7 | Every purge transaction is bounded | Partial: TestContainerReaper_Reap_ChunkBound bounds this reaper's chunk. The per-transaction observation is step 15's |
| AC-8 | A purge job in a namespace that stopped serving writes refuses retryably | Not this step — step 15a |
| AC-9 | A tombstoned npm version is reaped with its files and their attachments | Not this step — step 8 |
| AC-10 | npm unpublish then republish at one coordinate leaves the live row alone | Not this step — step 8 |
| AC-11 | A tombstoned npm package is reaped with its tags, metadata files and attachments | Not this step — step 8 |
| AC-12 | DeleteNpmMetadataFiles removes each row's attachment at the unpublish call site |
Not this step — step 8 |
| AC-13 | Tombstoned Maven versions and packages are reaped with their files and attachments | Not this step — step 9 |
| AC-14 | A tombstoned container repository is reaped through the deleters, then ContainerImageStore.Delete |
Not this step — step 14 drives this reaper from the repository level |
| AC-15 | A container repository holding an index and its children is reaped in one purge, parent-first | TestContainerReaper_Reap_ManifestOrdering/reaps an index and the manifests it references in one pass, parents first covers the image-level half; the repository-level driver is step 14 |
| AC-16 | A tombstoned container_images row under a live repository is reaped through the same walk, deleters observed, repository and sibling image untouched |
TestContainerReaper_Reap_ImageUnderLiveRepository (all three subtests), TestContainerReaper_Reap_ManifestOrdering (both), TestContainerReaper_Reap_RunsOnTheCallerHandle, TestContainerReaper_Reap_ChunkBound |
| AC-17 | The container repository walk reaps live images and already-marked ones in one pass | Not this step — step 14. Reap reads no mark of its own, which is what leaves that criterion reachable |
| AC-18 | A tombstoned remote-cache subtree is reaped, a positive hit per table | Not this step — steps 10, 11, 12 |
| AC-19 | container_remote_manifests and container_remote_blobs reaped with their attachments |
Not this step — step 12 and its follow-on |
| AC-20 | After a reap, every blob the subtree referenced has zero attachment rows | TestContainerReaper_Reap_ImageUnderLiveRepository/leaves every content address the image referenced with no attachment row, counted per sha256 |
| AC-21 | DELETE without destructive, or with a non-boolean value, returns 400 |
Not this step — step 18 |
| AC-22 | destructive=false on an empty repository returns 204 and frees the name |
Not this step — step 18 |
| AC-23 | destructive=false on a non-empty repository returns 409 |
Not this step — step 18 |
| AC-24 | The 409 message names the remedy the caller has |
Not this step — step 18 |
| AC-25 | destructive=true on a non-empty repository returns 202, tombstones and enqueues |
Not this step — step 18 |
| AC-26 | destructive=true on an empty repository returns 204 |
Not this step — step 18 |
| AC-27 | After the 202 every client route naming the repository returns 404 |
Not this step — step 18 |
| AC-28 | Create at a tombstoned name is 409, then 201 once the finalizer ran |
Not this step — step 18 |
| AC-29 | The finalizer cannot succeed early with an artifact left in place | Not this step — step 14 |
| AC-30 | A finalizer reaching an empty subtree removes the repository row and its links | Not this step — step 14 |
| AC-31 | A tombstoned repository with no live purge job is re-queued within one interval | Not this step — step 16 |
| AC-32 | The sweep reads each namespace's tombstones oldest-first | Not this step — steps 5 and 16 |
| AC-33 | A tombstoned npm_packages row whose versions are reaped is enumerated and purged |
Not this step — steps 7, 8, 16 |
| AC-34 | A repository that already has a live purge job is not re-queued | Not this step — step 16 |
| AC-35 | A job in a terminal state is re-queued on the next tick | Not this step — step 16 |
| AC-36 | A permanently failing purge stays counted and re-queued at the bounded cadence | Not this step — steps 16, 17b |
| AC-37 | The sweep registers a worker for its periodic kind | Not this step — step 16 |
| AC-38 | A frozen namespace has nothing enqueued, and is re-queued once the condition clears | Not this step — step 16 |
| AC-39 | ContainerRepositoryStore.FindByNameInNamespace hides a tombstoned repository |
Not this step — step 3 |
| AC-40 | RepositoryStore.FindByID hides a tombstoned repository |
Not this step — step 3 |
| AC-41 | RepositoryStore.Update hides a tombstoned repository |
Not this step — step 3 |
| AC-42 | Delete, the counter writes and Missing still act on a tombstoned row |
Not this step — step 3 |
| AC-43 | parentRepositoryIsActive refuses a parent tombstoned mid-transaction |
Not this step — step 4 |
| AC-44 | Each of the four container_images gates hides a marked image |
Not this step — S17 Phase 4, merged |
| AC-45 | Every /v2/ route resolving through FindByName answers 404 NAME_UNKNOWN |
Not this step — S17 Phase 4, merged |
| AC-46 | A blob mount whose source images are all marked answers 202 |
Not this step — S17 Phase 4, merged |
| AC-47 | A from= naming a marked image still mounts and answers 201 |
Not this step — S17 Phase 4, merged |
| AC-48 | A mount into a marked destination name answers 201 |
Not this step — S17 Phase 4, merged |
| AC-49 | UpsertContainerImage does not resurrect a marked image |
Not this step — S17 Phase 4, merged. TestContainerReaper_Reap_ImageUnderLiveRepository/frees the image name for a later push asserts the post-reap half: the name holds exactly one row afterwards, the pushed one |
| AC-50 | ContainerImageStore.Delete still acts on a marked row |
Exercised end to end here: the walk's last statement removes the marked image row, asserted on the recorder in TestContainerReaper_Reap_ImageUnderLiveRepository. The store-level pin is S17 Phase 4's |
| AC-51 | The destructive DELETE emits no counter delta at the tombstone write |
Not this step — step 19 |
| AC-52 | The purger's size and component deltas fire at one format-dispatched site | Not this step — step 19. ReapTotals.Components and SizeBytes are asserted here only where nothing was deleted, so step 19 is free to fix what an image counts as |
| AC-53 | Every emit site commits its source rows before issuing the delta | Not this step — step 19 |
| AC-54 | A purge chunk emits from its own committed affected-row aggregate | Not this step — step 19 |
| AC-55 | A raw foreign-key cascade emits nothing and is not used | TestContainerReaper_Reap_ImageUnderLiveRepository asserts the deleters ran, per call, which is the deleter-observation half the spec names in place of a negative test |
| AC-56 | The two discovery-index migrations apply and roll back cleanly | Not this step — step 1 |
| AC-57 | No migration in this spec adds or drops a column | Not this step — step 1 |
| AC-58 | unique_container_images_ns_id_cr_id_name is partial on the parent and every partition |
Not this step — S17 Phase 4, merged. Its behavioral half is relied on by TestContainerReaper_Reap_ImageUnderLiveRepository/frees the image name for a later push |
| AC-59 | Config load accepts and rejects the window and interval values | Not this step — step 2 |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | DELETE omits destructive |
Not this step — step 18 |
| E-2 | destructive carries a non-boolean value |
Not this step — step 18 |
| E-3 | destructive=false on a non-empty repository |
Not this step — step 18 |
| E-4 | An artifact is published between the DELETE and its finalizer |
Not this step — step 18 |
| E-5 | DELETE on a missing or already-tombstoned repository |
Not this step — step 18 |
| E-6 | Create at a name held by a tombstoned repository | Not this step — step 18 |
| E-7 | Any client route naming a tombstoned repository | Not this step — steps 3 and 18 |
| E-8 | A blob mount whose from= names a tombstoned repository |
Not this step — step 3 |
| E-9 | Purge job: 23503 on a parent delete |
TestContainerReaper_RootManifestPage_ChildOfAnotherImage: the image-row delete is a parent delete, and its refusal reaches the caller as ErrReapParentPinned |
| E-10 | Purge job: the manifest deleter refuses a manifest as still referenced | TestContainerReaper_Reap_ManifestOrdering/surfaces a refusal as a retryable work-ordering error asserts the sentinel; its sibling subtest asserts the ordering that keeps the refusal unreachable |
| E-11 | Purge job: the finalizer aborts because an artifact remains | Not this step — step 14 |
| E-12 | Purge job: the target is already hard-deleted when the job runs | TestContainerReaper_Reap_ChunkBound closes on a re-run of a drained subtree: zero rows, no error |
| E-13 | Purge job: the retention window changed between enqueue and run | Not this step — step 15. Reap evaluates no window |
| E-14 | Sweep: the enumeration query fails | Not this step — step 16 |
| E-15 | Sweep: the interval elapses before enumeration completes | Not this step — step 16 |
| E-16 | The emit API is unavailable at a purge or tombstone site | Not this step — step 19 |
| E-17 | Parent gate: the parent is tombstoned between the gate and the child insert | Not this step — step 4 |
| E-18 | A tombstoned artifact's coordinate is reoccupied before the reap | Not this step for npm (step 8). The container equivalent, a push at a marked image's name, is S17 Phase 4's; its post-reap half is asserted in TestContainerReaper_Reap_ImageUnderLiveRepository/frees the image name for a later push |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Authorization is unchanged; the purger makes no authorization decision | Not tested here. The reaper takes no caller identity and no request context, so there is no decision to assert against |
| S-2 | destructive is a confirmation, not an authorization control |
Not this step — step 18 |
| S-3 | Deletion is irreversible in closed beta and the API says so | Not this step — step 18 and the API documentation |
| S-4 | Input validation; no purger input is user-controlled | TestContainerReaper_Reap_Guards pins eight sentinels across nine cases, eight of the cases rejecting an argument and one the receiver, and TestContainerReaper_Reap_AcceptsTheMaximumPageSize pins the accepted page-size boundary. Every value the reaper takes is a UUID or an int, so no format-layer name or digest reaches a statement as text |
| S-5 | No injection surface is added | All reap SQL stays in internal/datastore, built through jet or a parameterized statement. TestEveryStatementIsInstrumented is the standing check that no bare statement is added; the implementation commit is where it applies |
| S-6 | Error messages must not become an existence oracle | Not this step — steps 3 and 18. The reaper answers no caller |
| S-7 | Purge failures are operationally visible and never report success | TestContainerReaper_Reap_ManifestOrdering/surfaces a refusal as a retryable work-ordering error asserts a refused subtree reports an error rather than success. The gauge that surfaces a stalled purge is step 17b's |
e2e scenario catalogs
No scenario is added, and none changes. The catalogs were read for expectations that no longer hold. A grep for this step's subject returns nothing, which is the right answer for the wrong reason.
Two rows name the reap: docs/testing/e2e/oci.md:115 (e2e.oci.lifecycle.delete-image-via-api) and docs/testing/e2e/docker.md:117 (e2e.docker.lifecycle.delete-image-via-api). Both assert the image is absent from the management list "before any reap has run", then that a push at the same name succeeds.
This step does not retire that precondition. It adds a Reaper with no production caller. ContainerReaper appears in its own file, in its three test files, and in comments in the two deleters and their unit tests, and Reaper has no other production implementor. A deployed service therefore still cannot reap, and the clause stays satisfiable. The step asserts its own push-after-reap criterion at the datastore level, through ContainerImageStore.UpsertContainerImage, and not through a client route.
A seam with no caller creates the condition those two clauses will misdescribe later. The break lands at the step that turns reaping on, where the precondition becomes a race against the retention window and the sweep interval. Both catalogs must be updated together at that point, because a fix to one row alone leaves the two in disagreement.
One adjacent row was checked and is unaffected. docs/testing/e2e/maven.md:135 says the blob stays in storage until the purger reaps it, and no route serves it. That stays true: the reap removes attachment rows and never touches blob_storage_blobs.
One gap is recorded and is not this step's to close. No catalog expresses blob_storage_attachments at all, because no protocol route and no management route projects it. The observable consequence of a stranded attachment is that reclamation never frees the blob, which is where the assertion becomes expressible. It is recorded against S28's reclamation work.
Size, and the plan's forecast
git diff --numstat origin/main...HEAD at the head of the third review round reports 18 files, +2857 / −92, against the merge base b5cecd904:
| File group | Added | Removed |
|---|---|---|
Production Go — lifecycle_reap_container.go 822, container_blob_unlinker.go 140, container_manifest_deleter.go 91, lifecycle_scan.go 35, lifecycle_reap_maven.go 15, lifecycle_reap_maven_remote.go 15, lifecycle_reap_npm.go 13, query_names.go 2 |
1133 | 55 |
Tests — lifecycle_reap_container_integration_test.go 1267, lifecycle_reap_container_test.go 183, lifecycle_explain_integration_test.go 75, container_blob_unlinker_test.go 67, container_manifest_deleter_test.go 59, lifecycle_reap_maven_remote_integration_test.go 28, lifecycle_reap_maven_integration_test.go 22, lifecycle_reap_npm_integration_test.go 9 |
1710 | 31 |
Docs — the S20-A spec 11, docs/dev/conformance.md 3 |
14 | 6 |
The plan forecast ~860 added LOC for this step, at ~220 source and ~640 tests. The measurement is +2857, which is 3.3 times the forecast and above the plan's own working ceiling as well. Two things account for it. The forecast assumed a reap that drives ready-made handle-taking deleters, and that seam did not exist. The deleter widening and the suite that observes the deleters run are therefore the larger half of the difference. The first review round moved the added-line figure from 1903 to 2587, a difference of 684 lines, at 418 test, 262 production and 4 docs. A second round moved it from 2587 to 2803, at 141 production and 75 test. A third round moved it from 2803 to 2857, a difference of 54 lines, at 28 production and 26 test, all of it answering review findings on ReapTotals.Drained, the Rows contract prose, and the two write-handle guard comments. The plan is not amended for this: the operator chose the narrower implementation arm, so the correction travels here.
Guardrail 18: why this MR is not split
The table above is a breakdown and not a justification. The justification is separate, and it rests on three grounds:
- The plan adopts a ~900 added-LOC working ceiling rather than the documented ~500, in its "Size forecast and the working ceiling" section, on measured predecessor plans. That section also says the harness's 500-LOC warning is expected on most of these steps. It asks a reviewer who applies the documented ceiling to read it against that paragraph. This MR is over that ceiling too, which is what the paragraph above states rather than hides.
- The plan interrogated this step for an extractable seam and recorded the outcome. Step 13 "is one format's walk over four child tables through two existing deleters, with the parent-first ordering as its distinguishing concern", and "splitting by child table would produce fragments that cannot delete an image".
- 1710 of the 2857 added lines are tests, which is 60%. The reviewable source is 822 lines of reaper, 231 across the two deleters, 78 across
lifecycle_scan.goand the three other reap files, and 2 query names. Of the 311 outside the reaper, a large part is doc comment rather than statements.
Merge order: !1702 (merged), now merged
This is a fact about main, and not a requirement on this MR. This step's code needs nothing from !1702 (merged).
!1702 (merged) "chore(datastore): Maven hosted reap and its attachment guard (S20-A plan: 9/21)" merged on 2026-08-20, landing f79239807, and this branch's rebase brought it in.
The three merged claims this section used to track are settled, and one of them needed an edit here after all.
- "At most limit rows are deleted per call" is gone.
f79239807replaced it with the rows-versus-artifacts bullet that this MR has since amended, described below. - "
ReapTotalscounts what the reap's own statements affected, read back throughRowsAffected" was rewritten by the same commit to name two mechanisms, theRowsAffecteda bareDELETEreports and the rows aDELETE ... RETURNINGhands back. The container arm uses both, so the clause holds for this arm and needed no change. The second row of each blob pair is still added on the pair rather than read back, for the reason the arm's own doc gives:BlobStorageAttachmentStore.Deletereturns an error alone, and the foreign key with noON DELETEaction is what makes the addition sound. - The claim that nothing outside the package's own tests implements
Reaperis also gone.internal/datastore/lifecycle_scan.goandinternal/datastore/lifecycle_scan_test.goboth now say the contract is implemented and called only inside this package until the chunk driver lands, which holds with a production implementor.
This MR does edit internal/datastore/lifecycle_scan.go, in two commits, and internal/datastore/lifecycle_scan_test.go in none.
An earlier ruling assigned the file to the step that rewrites it, which was !1702 (merged); that step has merged, and no open MR touches the file.
fc5910fc3addsDrained booland makes it the chunk driver's stop signal in place ofRows.912d4cfafcorrects two sentences thatContainerReaperis the first implementor to falsify.Rowsnow says what the chunk's own delete statements reported, and names why the rows a leg cannot count cost a caller no bound, since each is bounded per artifact on its own. Thelimitobligation now saysRowsmay run pastlimitrather than that it does: a manifest-only chunk fills its page, skips the blob leg and holds the image row back, soRowsis exactlylimit. Both sentences were true whenf79239807wrote them, against the four arms that then existed. Both other arms already state the conditional form in their own files, atinternal/datastore/lifecycle_reap_maven.go:126andinternal/datastore/lifecycle_reap_npm.go:132.
Merge order: !1705 (merged) and the ADR-025 record
!1705 (merged) (step 8 of this plan) records the same ADR-025 gap on BlobStorageAttachmentStore.DeleteIfUnreferenced, and it words the work-item attribution the way this MR now does: work item 676 owns the lock half, and the capture half arrives with whatever creates the candidate table. This MR records the unlinker's side, on ContainerBlobUnlinker.
While both MRs are open, main carries neither record. Plan order puts step 8 before step 13. If the two merge in plan order, !1705 (merged)'s record reaches main first, and this MR adds the second site. In the other order this MR's record reaches main first. Either way main ends with one obligation recorded at two sites, and the repository asks for one tracking-item pointer per obligation.
Branch state against origin/main
The branch was rebased again at 18:26 UTC on 2026-08-20, onto main as it stood at that time. The merge base is 36029d3f4, the tip is d57d47409, and the branch holds 20 commits. The rebase was proved content-neutral with git range-diff --creation-factor=100, which reported all 19 replayed commits as =, and the twentieth commit is the one this round added.
No ahead-behind pair is quoted, and that is deliberate. main moves through the same files this branch touches, and it moved past the rebase base before the rebase finished. Any pair written here is stale before a reviewer reads it. Read the current pair from the MR itself.
The branch merges cleanly, and that is measured rather than assumed. git merge-tree --write-tree d57d47409 origin/main returns a tree and exits 0, read at 19:00 UTC on 2026-08-20. GitLab reports the same: has_conflicts is false, and the merge status is can_be_merged. This says nothing about the MR's discussions, which are counted separately.
Every figure in this description is a three-dot origin/main...HEAD measurement, so it reads against the merge base 36029d3f4 rather than against today's main tip.
What this MR deliberately does not carry
- Row 13 of the plan's Status table stays empty, and the plan file is not touched at all. This is a knowing deviation from the repository's guardrail 4, by the operator's ruling. Six sibling runs share that one plan file, and a separate MR fills every row at once. !1559 (merged) argues the same batching at project level, and it is unmerged, so it is context here rather than authority. The ruling is what this rests on. Read the empty cell as intentional rather than as work not started.
- No
Related toline. The step ischore, which the repository's guardrail exempts. Steps 1 and 5 of this plan merged aschorewith no issue line.
Verification
CI at the 3-commit state. Pipeline 2771956366 is green on the merged result of 0c1f627d, across 48 jobs: go_unittests, test:race, test:integration on PostgreSQL 16, 17 and 18, golangci_lint, and both OCI conformance jobs (conformance:oci:s3-garage and conformance:oci:gcs-key-creds). That last pair is what settles the local conformance run below.
A pipeline for the 9-commit state 607e4831 started at 13:42 UTC. No result is claimed for it here, and the MR's own pipeline list is where to read it. The rebase rewrote every commit, so neither 0c1f627d nor 607e4831 is reachable on the branch now. Production code moved in three places after the green pipeline: the digest added to the refusal wrap, and one pool guard on each delete sequence.
The reaper suite at the 9-commit state, run at 13:57 UTC on PostgreSQL 17.8 with the integration build tag and -count=1, over TestContainerReaper and TestNewContainerReaper in internal/datastore: 13 tests and 20 subtests pass, none skipped and none failed. The four tests the review round added are in that set.
| Pass, at the 3-commit state | Result |
|---|---|
| OCI conformance, local | 70 of 80 specs pass, 5 fail, 5 skip |
| Service driven for real | 22 of 22 smoke checks, 23 of 23 hand-driven OCI delete checks |
The five local conformance failures are pre-existing. A scratch worktree at the merge base returns the same 70/5/5, with the same spec names, so this branch does not cause them. CI passes all five of those specs against two backends. All 21 delete-bearing specs pass in both runs.
The 23 hand-driven checks ran against the restructured blob-delete and manifest-delete paths, which is where the deleter widening lands. Five rounds of concurrent DELETE requests against one image left no stranded attachment row.
A live query plan for both page statements is posted as a note on this MR, from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17.10 container. One index covers the root-manifest anti-join exactly, and the planner declines it and hashes the namespace's relationship rows instead. Forcing the index probe measures about ten times slower, so the doc comments name the partition pruning that every measured plan showed and leave the index choice to the planner.
Lint at the 9-commit state, measured between 13:50 and 13:52 UTC on the pinned golangci-lint 2.12.2, with GOFLAGS cleared and a cleaned lint cache:
| Pass | Findings |
|---|---|
| Default build, whole tree, CI-equivalent | 0 |
Untagged, --new-from-merge-base=origin/main |
0 |
--build-tags=integration, whole internal/datastore |
4050 |
--build-tags=integration, new-only |
48 |
Every one of the 48 is contextcheck, and all 48 sit in lifecycle_reap_container_integration_test.go. contextcheck is 3726 of the package's 4050 tagged findings, which is 92%, and CI compiles none of those files, because .golangci.yaml sets no build tags. All four passes used --max-same-issues=0 --max-issues-per-linter=0, so no count is capped.
This branch adds 19 //nolint directives, counted on the branch after the rebase. A nolintlint pass over the whole tagged package reports no unused directive, so each of the 19 suppresses a finding that fires.
The reaper run and the four lint passes are at the 9-commit state, and the merge base has moved since. The two commits after that state add container_blob_unlinker_test.go and container_manifest_deleter_test.go, both untagged, plus the two pool guards and one comment correction. No pass above covers them.
This is a bot message
Review decisions recorded here
-
The rename of
MaxContainerReapPageSizetoMaxContainerReapLimitis declined, and the constant is deleted instead ind57d47409. The constant duplicated the mergedMaxLifecycleReapPageSizeat the same value, 1000, with no recorded reason.internal/datastore/lifecycle_reap_npm.go:123records thatMaxLifecycleReapPageSizeis what every reap arm clamps against, and every other arm does. The guard now clamps there too, and the ceiling's cost prose moves ontoContainerReaper.Reap. The onlyLimit-suffixed constants ininternal/datastoreare the three...BatchLimitones, all unexported, and none clamps a caller-supplied SQLLIMIT. -
The
Reapcomment-density finding is declined, and theReapTotals.Drainedparagraph ofContainerReaper.Reap's doc stays. The finding asks to delete that paragraph. Commit89574ebcbwrote it in answer to an earlier review round, which found thatReap's doc mapped a zeroRowsto a drained subtree when it does not. Before that commit the doc told a caller it "reads a zero from a drained subtree and stops", and that claim was false. A delete of the paragraph, with noDrainedfield onReapTotals, restores the false claim.
ReapTotals.Drained and the merge order it needs
This MR adds Drained bool to ReapTotals and makes it the chunk driver's stop signal, in place of Rows.
The field is set in every reap body in the tree: MavenVersionReaper, MavenPackageReaper, MavenRemoteVersionReaper, MavenRemotePackageReaper, ContainerReaper, NpmVersionReaper, and NpmPackageReaper.
The hosted npm window is closed.
An earlier round of this MR left NpmVersionReaper and NpmPackageReaper unset, both in internal/datastore/lifecycle_reap_npm.go, merged in d6a7a30b0.
The one recorded reason was that those two bodies were not on this branch's base.
A later rebase onto main put that file in the tree and spent the reason, and 73bac1bdd now sets the field in both arms.
No window remains for hosted npm.
Still in flight.
Four more reap bodies must set the field when they rebase onto this change:
two in !1746 (merged) "chore(datastore): npm remote cache reap (S20-A plan: 10/21)", and two in !1704 (merged) "chore(datastore): container remote reap (S20-A plan: 12/21)".
Each of those MRs merging before this one leaves its bodies unset, with Drained: false as the safe default; each merging after it must set them in the same change set.
That order is stated here and not in the plan.
docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md carries no Depends on line naming step 13 for steps 10 or 12, and a step MR does not edit the plan file, so this MR closes the window rather than that gap.