chore(datastore): version-level tombstone discovery scans (S20-A plan: 6/21)
What this step delivers
Five version-level tombstone scans in internal/datastore, one per table:
maven_versionsmaven_remote_versionsnpm_versionsnpm_remote_versionscontainer_remote_manifests
Each scan reads a tombstone-discovery index that is already on main.
This step adds no migration, no column and no index.
Each scan takes a namespace id, a retention window and a page limit.
It returns the tombstoned rows of one table, oldest first.
Nothing calls the five scans yet. Step 15 is the first production caller. Step 16 is the step that makes reaping live.
Spec coverage
Spec: docs/specs/S20-a-lifecycle-closed-beta.md
Step: Step 6: Version-level tombstone scans of
docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md
Acceptance criteria — Purger and discovery
| # | Criterion | Tests |
|---|---|---|
| P-1 | repositories row older than the window discovered, newer one not; verified non-zero and again at zero |
Step 5. The version-level equivalent of both halves is TestLifecycleScanStore_VersionScans_WindowSplit and TestLifecycleScanStore_VersionScans_ZeroWindow, per table |
| P-2 | soft_deleted_at IS NULL never discovered, for every table in the reap inventory |
TestLifecycleScanStore_VersionScans_LiveRowNeverReturned, per table, for the five scanned here. The tables reached by foreign key belong to the reap steps |
| P-3 | repositories, npm_packages, container_images scans use their index and prune to one partition, EXPLAIN for each |
Owned by Step 5 (repositories) and Step 7 (npm_packages, container_images) |
| P-4 | Version-level scans use the four existing indexes and prune to one partition, EXPLAIN per table; container_remote_manifests joins that set |
TestLifecycleScanStore_VersionScans_AreIndexBackedOldestFirst, five subtests, each asserting the index-partition match, one partition, no Sort Key: or Sort Method:, both predicates in the Index Cond, and no Filter: |
| P-5 | Re-run on a fully reaped subtree is a no-op; mid-walk abort through a gofail seam |
Owned by Step 14 and Step 15 |
| P-6 | Walking a tombstoned npm repository reaps live and already-tombstoned rows in one pass | Owned by Step 8 and Step 14 |
| P-7 | Every purge transaction is bounded | Owned by Steps 8 through 13 |
| P-8 | A purge job refuses retryably in a namespace that stopped serving writes | Owned by Step 15a |
Acceptance criteria — Per-format reap logic
| # | Criterion | Tests |
|---|---|---|
| R-1 | npm version reaped with its files and their attachments, through npm's own guard | Owned by Step 8 |
| R-2 | Republish before the reap leaves the live row and its files untouched | Owned by Step 8 |
| R-3 | npm package reaped with tags, metadata files and attachments, without a 23503 |
Owned by Step 8 |
| R-4 | DeleteNpmMetadataFiles removes each attachment in the same transaction |
Owned by Step 8 |
| R-5 | Maven version and package reaped with their files and attachments | Owned by Step 9 |
| R-6 | Container repository reaped through the existing per-artifact deleters | Owned by Step 13 |
| R-7 | An index and its children reaped parent-first, no MANIFEST_REFERENCED refusal |
Owned by Step 13 |
| R-8 | Tombstoned container_images row under a live repository reaped through the same walk |
Owned by Step 13 |
| R-9 | Repository walk reaps live and already-tombstoned images in one pass | Owned by Step 13 |
| R-10 | Remote-cache subtree reaped, a positive hit per table | Owned by Steps 10, 11 and 12 |
| R-11 | container_remote_manifests and container_remote_blobs reaped, attachments in the same transaction |
Owned by Step 12 |
| R-12 | Zero blob_storage_attachments rows per reaped sha256 |
Owned by Steps 8 through 13 |
Acceptance criteria — Repository entry point
| # | Criterion | Tests |
|---|---|---|
| E-1 | DELETE without destructive, or with a non-boolean value: 400 |
Owned by Step 18 |
| E-2 | destructive=false on an empty repository: 204, name immediately reusable |
Owned by Step 18 |
| E-3 | destructive=false on a non-empty repository: 409, contents intact |
Owned by Step 18 |
| E-4 | The 409 message names the remedy the caller actually has |
Owned by Step 18 |
| E-5 | destructive=true on a non-empty repository: 202, tombstone and job in one transaction |
Owned by Step 18 |
| E-6 | destructive=true on an empty repository: 204, no tombstone, no job |
Owned by Step 18 |
| E-7 | Every client route naming the repository answers 404 |
Owned by Step 3 and Step 18 |
| E-8 | Create at a tombstoned name: 409 while outstanding, 201 after the finalizer |
Owned by Step 18 |
| E-9 | The finalizer cannot succeed early | Owned by Step 14 |
| E-10 | A finalizer over an empty subtree removes the row, its format-child row and its links | Owned by Step 14 |
Acceptance criteria — Sweep
| # | Criterion | Tests |
|---|---|---|
| S-1 | Tombstoned repository with no live job re-queued within one interval | Owned by Step 16 |
| S-2 | Oldest-first per namespace, with EXPLAIN showing a backwards DESC scan and no Sort above it |
The version-level half is here: TestLifecycleScanStore_VersionScans_OldestTombstoneFirst, TestLifecycleScanStore_VersionScans_BoundedPageKeepsOldest, TestLifecycleScanStore_VersionScans_AreIndexBackedOldestFirst and TestVersionScanStmts_OrderOnSoftDeletedAtAlone. The tick half is Step 16 |
| S-3 | Tombstoned npm_packages row whose versions are reaped is enumerated |
Owned by Step 7 and Step 16 |
| S-4 | A repository with a live job is not re-queued | Owned by Step 16 |
| S-5 | A terminal prior job is re-queued, which pins UniqueByArgsWhileLive |
Owned by Step 16 |
| S-6 | A permanently failing purge stays counted and re-queued at the bounded cadence | Owned by Step 16 and Step 17b |
| S-7 | The sweep registers a worker for its periodic kind | Owned by Step 16 |
| S-8 | A namespace that does not serve writes has nothing enqueued | Owned by Step 15a and Step 16 |
Acceptance criteria — Tombstone visibility
| # | Criterion | Tests |
|---|---|---|
| V-1 | ContainerRepositoryStore.FindByNameInNamespace not-found for both formats |
Owned by Step 3 |
| V-2 | RepositoryStore.FindByID not-found |
Owned by Step 3 |
| V-3 | RepositoryStore.Update not-found |
Owned by Step 3 |
| V-4 | Delete, the three counter movers and Missing still act on a tombstone |
Owned by Step 3 |
| V-5 | parentRepositoryIsActive under a concurrent tombstone |
Owned by Step 4 |
| V-6 | The four container_images gates hide a tombstoned image |
Owned by S17 Phase 4 |
| V-7 | /v2/ routes answer 404 NAME_UNKNOWN at the image tier |
Owned by S17 Phase 4 |
| V-8 | Blob mount answers 202 when every source image is tombstoned |
Owned by S17 Phase 4 |
| V-9 | A tombstoned image named in from= still mounts |
Owned by S17 Phase 4 |
| V-10 | A tombstoned mount destination is not a 404 case |
Owned by S17 Phase 4 |
| V-11 | UpsertContainerImage does not resurrect a tombstoned image |
Owned by S17 Phase 4 |
| V-12 | ContainerImageStore.Delete still acts on a tombstoned row |
Owned by S17 Phase 4 |
Acceptance criteria — Accounting call sites
| # | Criterion | Tests |
|---|---|---|
| A-1 | The destructive DELETE emits no delta at the tombstone write |
Owned by Step 19 |
| A-2 | Deltas fire at the purger, with a level-dependent delta set | Owned by Step 19 |
| A-3 | Every emit site commits its source rows before issuing the delta | Owned by Step 19 |
| A-4 | A chunk emits from its own committed affected-row aggregate | Owned by Step 19 |
| A-5 | A raw foreign-key cascade emits nothing and is not used | Owned by Step 19 |
Acceptance criteria — Schema and configuration
| # | Criterion | Tests |
|---|---|---|
| C-1 | The two discovery-index migrations apply and roll back cleanly | Owned by Step 1 |
| C-2 | No migration adds or drops a column, and no CHECK pins soft_deleted_at |
Owned by Step 1 |
| C-3 | unique_container_images_ns_id_cr_id_name is partial on the parent and every partition |
Owned by S17 Phase 4 |
| C-4 | Config load accepts, rejects and bounds the lifecycle values | Owned by Step 2 |
Error cases
| # | Condition | Tests |
|---|---|---|
| X-1 | DELETE omits destructive |
Owned by Step 18 |
| X-2 | destructive carries a value other than true or false |
Owned by Step 18 |
| X-3 | destructive=false on a non-empty repository |
Owned by Step 18 |
| X-4 | A publish races the DELETE FROM repositories |
Owned by Step 18 |
| X-5 | DELETE on a missing or already-tombstoned repository |
Owned by Step 18 |
| X-6 | Create at a name held by a tombstoned repository | Owned by Step 18 |
| X-7 | Any client route naming a tombstoned repository in its own path | Owned by Step 3 and Step 18 |
| X-8 | A blob mount whose from= names a tombstoned repository |
Owned by Step 3 |
| X-9 | Purge job: 23503 on a parent delete |
Owned by Step 14 and Step 15 |
| X-10 | Purge job: ContainerManifestDeleter refuses a manifest as still referenced |
Owned by Step 13 |
| X-11 | Purge job: the finalizer aborts because an artifact remains | Owned by Step 14 |
| X-12 | Purge job: repository already hard-deleted when the job runs | Owned by Step 14 |
| X-13 | Purge job: retention window changed between enqueue and run | The run-time evaluation half is here: TestLifecycleScanStore_VersionScans_WindowSplit shows a lengthened window leaving a row ineligible. The job's success reporting is Step 15 |
| X-14 | Sweep: enumeration query fails | The scan's own failure is TestLifecycleScanStore_VersionScans_QueryFailureWrap: the cause stays reachable through errors.Is, the message names the operation and the table, no page comes back, and no namespace reaches the text. River's retry is Step 16 |
| X-15 | Sweep: interval elapses before enumeration completes | The oldest-first property the row rests on is TestLifecycleScanStore_VersionScans_OldestTombstoneFirst and TestLifecycleScanStore_VersionScans_BoundedPageKeepsOldest. The tick behavior is Step 16 |
| X-16 | Emit API unavailable at a purge or tombstone site | Owned by Step 19 |
| X-17 | Parent gate: parent tombstoned between the gate and the child INSERT |
Owned by Step 4 |
| X-18 | A tombstoned coordinate reoccupied by a republished row before the reap | Owned by Step 8. The scan half, that the live row at the same coordinate is never returned, is TestLifecycleScanStore_VersionScans_LiveRowNeverReturned |
Security considerations
| # | Concern | Tests |
|---|---|---|
| Y-1 | Authorization is unchanged, and the purger holds no caller identity | No behavior in this step. Each scan takes a namespace id and no principal |
| Y-2 | destructive is a confirmation, not an authorization control |
Owned by Step 18 |
| Y-3 | Deletion is irreversible in closed beta and the API says so | Owned by Step 18 and the API reference |
| Y-4 | Input validation; no purger input is user-controlled | TestLifecycleScanStore_VersionScans_Guards: each of the five scans refuses a nil context, a zero-UUID namespace, a limit outside (0, MaxLifecycleScanPageSize] and a negative window, before any statement runs, against a zero-value store |
| Y-5 | No injection surface is added | TestVersionScanStmts_OrderOnSoftDeletedAtAlone asserts the namespace id and the limit are the two bound arguments rather than interpolated text, per statement. The statements are jet-built |
| Y-6 | Error messages must not become an existence oracle | TestLifecycleScanStore_VersionScans_QueryFailureWrap asserts the wrap is identifier-free. Tenant isolation is TestLifecycleScanStore_VersionScans_NamespaceIsolation |
| Y-7 | Purge failures are operationally visible rather than silent | Owned by Step 17a and Step 17b. The scan's own contribution is that a failed read never reads as an empty page: TestLifecycleScanStore_VersionScans_QueryFailureWrap asserts a wrapped error and a nil page |
Departures from the plan's Files: list
Three files in the diff are outside the step's Files: list.
Each one has a reason.
internal/datastore/query_names.go takes five query-name constants, one per scan.
This file is forced rather than chosen.
internal/datastore/queries_test.go parses only query_names.go to build the catalog it checks.
A constant declared in any other file escapes four checks: TestQueryNames_WellFormedAndUnique, TestQueryNames_IdentifierMatchesValue, TestQueryNames_EachUsedExactlyOnce and TestNameBudget_CoversEveryDeclaredQueryName.
internal/datastore/lifecycle_scan_versions_test.go is a new untagged unit file.
CLAUDE.md guardrail 6 requires this suite to mirror step 5's suite.
This file carries the argument-guard table and the statement-shape pins that step 5's internal/datastore/lifecycle_scan_test.go carries.
It is a new path, so it does not contend with step 5's file or with step 7.
internal/metrics/cardinality.go raises the query-name budget.
The operator authorized that raise separately, and the next section states its basis.
The plan Status row
Step 6's row in the Status table of docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md names this MR.
An earlier revision of this description said the row stayed empty on purpose, because one separate MR would fill every S20-A row at once and no child MR would edit the plan.
That claim does not hold.
No such MR exists, and no thread, plan section or repository document records the ruling it cited.
The practice on these plans runs the other way: of the 17 step MRs merged across the S20-A and S22 plans, 16 filled their own row.
Guardrail 4 of CLAUDE.md asks for the row in the opening MR's own change set, and this row now follows it.
The metric-name cardinality budget: 350 to 400
internal/metrics/cardinality.go moves labelName from 350 to 400.
Two measured numbers stand behind that line, and no third number is claimed:
- The merged basis is 315 names on
origin/mainat76316b30: 303 ininternal/datastore/query_names.goplus 12 ininternal/storage/queries.go. - This step's own arm is 5 names.
The raise to 400 is a judgment about headroom. It is not derived from an aggregate. The argument for headroom is structural, and it needs no aggregate:
instrumentQuerytakes the query name as a mandatory positional argument.TestEveryStatementIsInstrumentedforces every statement throughinstrumentQuery.TestQueryNames_EachUsedExactlyOnceforbids the reuse of an existing name.
So no merge request can drop its own names to stay under the ceiling.
The budget test reads two catalogs, and counting only the first understates every total by 12.
That is stated because it is the mistake this measurement corrects, and the next reader is likely to repeat it.
internal/metrics/name_budget_test.go lists both internal/datastore/query_names.go and internal/storage/queries.go.
Measured across both catalogs, with each figure anchored to the commit it was read at, because this number moves:
origin/maindeclared 315 names at76316b30, and 323 at46dd2f02b, the base this branch was last rebased onto. It gained eight while this branch was under review.- This branch declares 328 at
20e0ee05a: 316 ininternal/datastore/query_names.goplus 12 ininternal/storage/queries.go. - Its own arm is 5 of those.
So this branch fits the old ceiling of 350 with 22 to spare, as well as the raised 400, and the raise is not needed to land it. The case for the raise is the queue behind it rather than this branch: further merge requests in flight each add names none of them can drop, and the margin at 350 was observed shrinking while this one was reviewed. No total for that queue is quoted here. Three separate sweeps produced three different totals and none of them reproduced, and the fourth attempt found the cause — a count of one catalog where the budget test reads two. The raise to 400 buys room that a further wave of comparable size would exhaust.
That is the case for headroom, and it is the whole case. This MR does not claim that any merge request fails the budget test at present, because none does.
The 400 bounds metric-series cardinality on the name label.
It is not a bound on a linter's patience.
One check performed rather than an assumption.
Every merge request that was open and touching internal/metrics/cardinality.go when this was checked was read, and not one of them edits labelName: !1011, !1542 (merged), !1675 (closed), !1687 (closed), !1694 (merged) and !1707 (merged).
Whichever of those have merged by the time this is read cannot conflict with this line, and the rest were clean when read.
!1011 needed a second method, and the next reader needs it too.
The /changes API returns an empty diff for its internal/metrics/cardinality.go entry, on a merge request carrying 172 changed files, so an empty response there is not evidence of an empty change.
Fetching the file at !1011's own base and head shas and diffing it directly shows the change: it edits handler and code, and leaves labelName untouched.
datastore.TombstoneCursor stays unconsumed
datastore.TombstoneCursor has no consumer, in production code or in a test.
That state is deliberate.
The spec contains no occurrence of "cursor", and this step's acceptance names none.
The type lives in step 5's merged internal/datastore/lifecycle_scan.go, which this step does not own and does not touch.
It is a known state, not an oversight.
ADR-007 and the container_remote_manifests index
This is a factual note. It takes no position on which side must move.
ADR-007 line 665 describes a two-column index for container_remote_manifests, keyed (namespace_id, soft_deleted_at DESC).
internal/datastore/migrations/structure.sql ships index_container_remote_manifests_on_ns_id_soft_deleted_at_id as a three-column index, keyed (namespace_id, soft_deleted_at DESC, id DESC).
Line 73 of docs/specs/S20-a-lifecycle-closed-beta.md already records the same three-column form.
The divergence is container_remote_manifests alone.
The four version tables scanned here, plus npm_packages, container_images and repositories, all ship ADR-007's two-column form.
This step reads the shipped index, so it conforms to its governing spec. The divergence is between ADR-007 and merged state, and both sides merged before this branch existed. This step deviates from no ADR and owes no handbook amendment. A reviewer who owns ADR-007 can act in the ADR's own venue.
Two comments in this diff describe the three-column key, and both go stale if the ADR side wins.
They are the doc on ScanTombstonedContainerRemoteManifests and the doc on versionBackwardIndexScanRe.
Neither is a test risk, because the regular expression admits both plan shapes on purpose.
E2E scenario catalogs
This step adds no e2e scenario, and it changes or invalidates none.
The lead reason is reachability: no production caller reaches the five scans, so no scenario's observable behavior can move.
The read covered docs/testing/e2e/{oci,docker,npm,maven}.md, plus every occurrence of reap, purge, tombstone and soft-delete under docs/testing/e2e/ — eight lines in four files.
The three scenarios that mention a reap all assert the pre-reap state, which this step does not change.
npm.md:129 asserts that only a hard delete lowers versions_count, and this step deletes nothing.
The plan already assigns the catalog work to step 18, with step 16 confirming it.
Reviewable LOC
7 files, 1411 insertions and 3 deletions, measured against this branch's merge base 46dd2f02b.
That is past two thresholds: the documented ~500 in docs/dev/development-model.md, and the plan's own ~900 working ceiling in its Size forecast and the working ceiling section (plan line 287).
| Group | Files | Lines |
|---|---|---|
| Production | internal/datastore/lifecycle_scan_versions.go |
358 |
| Integration tests | internal/datastore/lifecycle_scan_versions_integration_test.go 571, internal/datastore/lifecycle_explain_integration_test.go 185 |
756 |
| Unit tests | internal/datastore/lifecycle_scan_versions_test.go |
290 |
| Query-name catalog | internal/datastore/query_names.go |
5 |
| Metric budget | internal/metrics/cardinality.go |
1 changed line |
| Plan Status row | docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md |
1 changed line |
The overage sits in the test half, which is 1046 of the 1411 lines. The acceptance criteria are the cause rather than sprawl:
- The acceptance requires a positive
EXPLAINhit per table for all five tables, not one representative table. - Five scans need five guard rows and five statement-shape assertions, where step 5 needed one of each.
A split does not help here. The reviewable unit is one scan plus its per-table coverage, and the acceptance requires all five.
Merge order
This MR raises the labelName ceiling from 350 to 400.
A later step whose own query-name arm crosses the old ceiling of 350 needs this MR merged first.
While this MR is open, that step's budget test fails on the ceiling rather than on its own names.
Once this MR merges, the ceiling is 400 and that step's budget test measures its own names again.
This note names no sibling on purpose. Merge order is nobody's to predict, and a named sibling makes the sentence stale as soon as that MR lands.
One file interaction, separate from the ceiling.
Step 7 also modifies internal/datastore/lifecycle_explain_integration_test.go.
The two edits do not conflict textually — step 7 appends after that file's old line 121, and this step inserts at lines 100 to 105 — but they share the file and its import block, so whichever of the two lands second rebases.
Verification
Three lint passes, all at the current tip, with GOFLAGS cleared, a private lint cache, and base origin/main:
| Pass | Scope | Findings |
|---|---|---|
Untagged, new code only (--new-from-merge-base=origin/main) |
./internal/datastore/ |
0 |
Tagged (--build-tags=integration), new code only |
./internal/datastore/ |
14, all contextcheck |
The untagged pass never compiles the integration test file, so its 0 says nothing about that file.
The tagged new-only pass is the one that measures it.
Its 14 findings share one cause.
seedTombstonedVersionRow takes *testing.T and derives its context from tt.Context(), which is the convention of every seed helper in this package.
This step adds no //nolint:contextcheck, and it does not restructure the helper to lower the number.
Both figures are diff-scoped and each names its base. A whole-package total is deliberately not quoted: the package changes under every branch, so such a number is true at the instant it is taken and this description outlives that instant.
Four mutations that this suite admitted are now closed, each confirmed by watching the new assertion fail against the mutation and pass again after it was reverted: a soft_deleted_at <= predicate in place of the strict <, a >= page-size boundary in place of >, a tuple keyset bound, and an ordering key other than soft_deleted_at.
Step 5's merged suite still admits the same four.
This step's coverage therefore exceeds its predecessor's at those four points rather than matching it, and whether to widen step 5's suite is a reviewer's call rather than something this MR decides.
docs/specs/S03-b-metrics.md:494 documents the name label budget as "up to ~200".
The code carried 350 before this branch and carries 400 after it, so both figures already diverge from that row, and this MR does not amend it.
The labelName entry's own comment does not cite S03-b as its source, unlike six of the seven other labels in the same map, which match that table exactly.
The runtime pass did not fire, and the reason is reachability rather than a skip.
A repository-wide grep -rln over every *.go file for the five method names returns two files: the declarations in internal/datastore/lifecycle_scan_versions.go, and the method values in internal/datastore/lifecycle_scan_versions_test.go.
No handler, route, worker, command or composition root reaches them.
Nothing boots differently and nothing dispatches differently, so .claude/skills/run-artifact-registry/ needs no update.
This MR is type chore, which docs/dev/conventions.md exempts from the Related to line.
Database Review Evidence
Query mode only.
This MR adds no migration: git diff --name-only origin/main...HEAD -- internal/datastore/migrations/sql/ is empty, so migration mode had nothing to time and did not run.
The five discovery indexes the scans read are already on main.
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17.10 container (matching GL_PG_CURR_VERSION: "17" from
.gitlab-ci-other-versions.yml), with synthesized seed data rolled
back per query and the container torn down at the end of the run.
Numbers reflect moderate cardinality and do not capture
production-scale effects. See
Database review evidence
for seed sizing, methodology, and the anomalies the skill flags.
Expand each row's details for the seed shape, rendered SQL, bound args,
and raw plan.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.ScanTombstonedContainerRemoteManifests |
Limit → Index Only Scan Backward | container_remote_manifests_p4_namespace_id_soft_deleted_at_idx6 |
25 / 25 | 3.21 | 0.236ms | 3 / 0 | 1/64 |
datastore.ScanTombstonedMavenRemoteVersions |
Limit → Index Scan Backward | maven_remote_versions_p24_namespace_id_soft_deleted_at_idx |
25 / 25 | 2.23 | 0.054ms | 3 / 0 | 1/64 |
datastore.ScanTombstonedMavenVersions |
Limit → Index Scan Backward | maven_versions_p49_namespace_id_soft_deleted_at_idx |
25 / 25 | 2.28 | 0.032ms | 3 / 0 | 1/64 |
datastore.ScanTombstonedNpmRemoteVersions |
Limit → Index Scan Backward | npm_remote_versions_p19_namespace_id_soft_deleted_at_idx |
25 / 25 | 2.23 | 0.049ms | 3 / 0 | 1/64 |
datastore.ScanTombstonedNpmVersions |
Limit → Index Scan Backward | npm_versions_p03_namespace_id_soft_deleted_at_idx |
25 / 25 | 2.28 | 0.057ms | 3 / 0 | 1/64 |
No anomaly from the skill's catalog fired on any of the five: no sequential scan, no partition fan-out, no sort node, no plan-versus-actual divergence, and each statement is bounded by LIMIT $2 whose value validateLifecycleScanArgs clamps to MaxLifecycleScanPageSize before the statement runs.
How this run differs from the committed EXPLAIN suite.
TestLifecycleScanStore_VersionScans_AreIndexBackedOldestFirst seeds 60 rows per table and runs on a connection pinned with enable_seqscan=off, because a handful of rows plans as a sequential scan whatever the query shape.
This run seeds 5000 tombstoned rows per table and leaves enable_seqscan at its default, so the plans below record that the planner reaches the same index on cost.
The two bodies of evidence answer different questions and neither replaces the other.
Measured, not flagged. Two figures are recorded here because a reviewer will see them in the raw plans, and neither is a finding.
- Planning time (5.083ms to 7.937ms) is two orders of magnitude above execution time (0.032ms to 0.332ms), with 385 to 470 planning buffer hits. Both come from planning against a 64-partition table, and the same figures appear for the sibling
repositoriesscan already onmain. - The
container_remote_manifestsplan reportsHeap Fetches: 25under its Index Only Scan. The seed inserts those rows in the same transaction that reads them, so the visibility map is unset for every page the scan touches.
datastore.ScanTombstonedContainerRemoteManifests
Summary: The plan matches the method's intent: an Index Only Scan Backward over one partition of index_container_remote_manifests_on_ns_id_soft_deleted_at_id, which returns the oldest tombstone first from a DESC key with no sort node above it. The index covers the whole projection because it keys id as a third column, which is the one visible difference from the other four scans and is what the method's own doc comment describes. No anomalies.
Seed shape: namespaces=1, repositories=1, container_remote_repositories=1, container_remote_images=1, blob_storage_blobs=1, blob_storage_attachments=1, container_remote_manifests=5000
Rendered SQL:
SELECT container_remote_manifests.namespace_id AS "tombstone_row.namespace_id",
container_remote_manifests.id AS "tombstone_row.id",
container_remote_manifests.soft_deleted_at AS "tombstone_row.soft_deleted_at"
FROM public.container_remote_manifests
WHERE (container_remote_manifests.namespace_id = $1::uuid) AND (container_remote_manifests.soft_deleted_at < (NOW() - INTERVAL '0 MICROSECOND'))
ORDER BY container_remote_manifests.soft_deleted_at ASC
LIMIT $2;Bound args: ['056655a9-d14a-4ab7-9726-0467ef260c90', 25]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..3.21 rows=25 width=40) (actual time=0.214..0.236 rows=25 loops=1)
Buffers: shared hit=3
-> Index Only Scan Backward using container_remote_manifests_p4_namespace_id_soft_deleted_at_idx6 on container_remote_manifests_p46 container_remote_manifests (cost=0.29..584.66 rows=5000 width=40) (actual time=0.210..0.226 rows=25 loops=1)
Index Cond: ((namespace_id = '056655a9-d14a-4ab7-9726-0467ef260c90'::uuid) AND (soft_deleted_at < (now() - '00:00:00'::interval)))
Heap Fetches: 25
Buffers: shared hit=3
Planning:
Buffers: shared hit=418 read=1
Planning Time: 5.901 ms
Execution Time: 0.332 msTimings: planning 5.901ms, execution 0.332ms, total 6.233ms.
datastore.ScanTombstonedMavenRemoteVersions
Summary: The plan matches the method's intent: an Index Scan Backward over one partition of index_maven_remote_versions_on_ns_id_soft_deleted_at, oldest tombstone first, with no sort node above it. Both halves of the predicate reached the Index Cond, so no eligible-row filtering was left for the heap. No anomalies.
Seed shape: namespaces=1, repositories=1, maven_remote_repositories=1, maven_remote_packages=1, maven_remote_versions=5000
Rendered SQL:
SELECT maven_remote_versions.namespace_id AS "tombstone_row.namespace_id",
maven_remote_versions.id AS "tombstone_row.id",
maven_remote_versions.soft_deleted_at AS "tombstone_row.soft_deleted_at"
FROM public.maven_remote_versions
WHERE (maven_remote_versions.namespace_id = $1::uuid) AND (maven_remote_versions.soft_deleted_at < (NOW() - INTERVAL '0 MICROSECOND'))
ORDER BY maven_remote_versions.soft_deleted_at ASC
LIMIT $2;Bound args: ['f1ab99cb-38cb-4c93-acc8-dd3413e53391', 25]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..2.23 rows=25 width=40) (actual time=0.045..0.054 rows=25 loops=1)
Buffers: shared hit=3
-> Index Scan Backward using maven_remote_versions_p24_namespace_id_soft_deleted_at_idx on maven_remote_versions_p24 maven_remote_versions (cost=0.29..388.04 rows=5000 width=40) (actual time=0.044..0.051 rows=25 loops=1)
Index Cond: ((namespace_id = 'f1ab99cb-38cb-4c93-acc8-dd3413e53391'::uuid) AND (soft_deleted_at < (now() - '00:00:00'::interval)))
Buffers: shared hit=3
Planning:
Buffers: shared hit=440
Planning Time: 5.083 ms
Execution Time: 0.090 msTimings: planning 5.083ms, execution 0.090ms, total 5.173ms.
datastore.ScanTombstonedMavenVersions
Summary: The plan matches the method's intent: an Index Scan Backward over one partition of index_maven_versions_on_ns_id_soft_deleted_at, oldest tombstone first, with no sort node above it. The namespace equality pruned to one of 64 partitions and the window bound rode into the same Index Cond. No anomalies.
Seed shape: namespaces=1, repositories=1, maven_repositories=1, maven_packages=1, maven_versions=5000
Rendered SQL:
SELECT maven_versions.namespace_id AS "tombstone_row.namespace_id",
maven_versions.id AS "tombstone_row.id",
maven_versions.soft_deleted_at AS "tombstone_row.soft_deleted_at"
FROM public.maven_versions
WHERE (maven_versions.namespace_id = $1::uuid) AND (maven_versions.soft_deleted_at < (NOW() - INTERVAL '0 MICROSECOND'))
ORDER BY maven_versions.soft_deleted_at ASC
LIMIT $2;Bound args: ['621ff601-2495-4e1c-913c-2d77c674bc31', 25]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..2.28 rows=25 width=40) (actual time=0.029..0.032 rows=25 loops=1)
Buffers: shared hit=3
-> Index Scan Backward using maven_versions_p49_namespace_id_soft_deleted_at_idx on maven_versions_p49 maven_versions (cost=0.29..399.60 rows=5000 width=40) (actual time=0.028..0.030 rows=25 loops=1)
Index Cond: ((namespace_id = '621ff601-2495-4e1c-913c-2d77c674bc31'::uuid) AND (soft_deleted_at < (now() - '00:00:00'::interval)))
Buffers: shared hit=3
Planning:
Buffers: shared hit=470 read=1
Planning Time: 7.937 ms
Execution Time: 0.055 msTimings: planning 7.937ms, execution 0.055ms, total 7.992ms.
datastore.ScanTombstonedNpmRemoteVersions
Summary: The plan matches the method's intent: an Index Scan Backward over one partition of index_npm_remote_versions_on_ns_id_soft_deleted_at, oldest tombstone first, with no sort node above it. Both predicate halves are index conditions. No anomalies.
Seed shape: namespaces=1, repositories=1, npm_remote_repositories=1, npm_remote_packages=1, npm_remote_versions=5000
Rendered SQL:
SELECT npm_remote_versions.namespace_id AS "tombstone_row.namespace_id",
npm_remote_versions.id AS "tombstone_row.id",
npm_remote_versions.soft_deleted_at AS "tombstone_row.soft_deleted_at"
FROM public.npm_remote_versions
WHERE (npm_remote_versions.namespace_id = $1::uuid) AND (npm_remote_versions.soft_deleted_at < (NOW() - INTERVAL '0 MICROSECOND'))
ORDER BY npm_remote_versions.soft_deleted_at ASC
LIMIT $2;Bound args: ['5963e8e7-b2c8-40a5-a864-6aa7ac5dbf8c', 25]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..2.23 rows=25 width=40) (actual time=0.040..0.049 rows=25 loops=1)
Buffers: shared hit=3
-> Index Scan Backward using npm_remote_versions_p19_namespace_id_soft_deleted_at_idx on npm_remote_versions_p19 npm_remote_versions (cost=0.29..388.04 rows=5000 width=40) (actual time=0.038..0.045 rows=25 loops=1)
Index Cond: ((namespace_id = '5963e8e7-b2c8-40a5-a864-6aa7ac5dbf8c'::uuid) AND (soft_deleted_at < (now() - '00:00:00'::interval)))
Buffers: shared hit=3
Planning:
Buffers: shared hit=385 read=2
Planning Time: 6.479 ms
Execution Time: 0.081 msTimings: planning 6.479ms, execution 0.081ms, total 6.560ms.
datastore.ScanTombstonedNpmVersions
Summary: The plan matches the method's intent: an Index Scan Backward over one partition of index_npm_versions_on_ns_id_soft_deleted_at, oldest tombstone first, with no sort node above it. The namespace equality pruned to one of 64 partitions. No anomalies.
Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=1, npm_versions=5000
Rendered SQL:
SELECT npm_versions.namespace_id AS "tombstone_row.namespace_id",
npm_versions.id AS "tombstone_row.id",
npm_versions.soft_deleted_at AS "tombstone_row.soft_deleted_at"
FROM public.npm_versions
WHERE (npm_versions.namespace_id = $1::uuid) AND (npm_versions.soft_deleted_at < (NOW() - INTERVAL '0 MICROSECOND'))
ORDER BY npm_versions.soft_deleted_at ASC
LIMIT $2;Bound args: ['358b3b2e-e796-413e-8573-695622c9bf60', 25]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..2.28 rows=25 width=40) (actual time=0.049..0.057 rows=25 loops=1)
Buffers: shared hit=3
-> Index Scan Backward using npm_versions_p03_namespace_id_soft_deleted_at_idx on npm_versions_p03 npm_versions (cost=0.29..399.60 rows=5000 width=40) (actual time=0.047..0.053 rows=25 loops=1)
Index Cond: ((namespace_id = '358b3b2e-e796-413e-8573-695622c9bf60'::uuid) AND (soft_deleted_at < (now() - '00:00:00'::interval)))
Buffers: shared hit=3
Planning:
Buffers: shared hit=424 read=1
Planning Time: 7.251 ms
Execution Time: 0.099 msTimings: planning 7.251ms, execution 0.099ms, total 7.350ms.
Run parameters
The rendered statements come from the store's own builders through a generated .Sql() harness, so the SQL above is the text the store executes.
| Parameter | Value | Why |
|---|---|---|
window |
0 |
The lifecycle.retention_window default, which is what closed beta runs at. It renders as INTERVAL '0 MICROSECOND', so every seeded tombstone is eligible. |
limit |
25 |
The page size the committed EXPLAIN suite uses, so the two runs plan the same statement. |
| Tombstoned rows per target | 5000 |
The skill's read-target seed size. All 5000 carry a non-NULL soft_deleted_at, so each partial discovery index holds 5000 entries. |
enable_seqscan |
default (on) |
Left at its default so the planner reaches the index on cost. |
This is a bot message