chore(datastore): Maven hosted reap and its attachment guard (S20-A plan: 9/21)
Step 9 of 21 in the S20-A plan, docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md.
What this step delivers
Two Reaper implementations and one attachment guard, all in internal/datastore.
MavenVersionReaperreaps a tombstonedmaven_versionsrow. It deletes the version'smaven_filesrows in bounded pages, then each file'sblob_storage_attachmentsrow, then the version row.MavenPackageReaperreaps a tombstonedmaven_packagesrow on the same shape, over the package-level files that carry no version.BlobStorageAttachmentStore.DeleteIfUnreferencedByMavenremoves an attachment only when the lastmaven_filesormaven_remote_filesrow that references it is gone.MaxLifecycleReapPageSizebounds thelimitone reap call can ask for.
Both reapers defer the parent row until a short page shows that the children are drained.
Each chunk runs in one BeginTx and Commit pair, which is the "bounded, asserted as in Step 8" half of the acceptance criteria.
This is net-new logic above the file level.
MavenFileDeleter.DeleteFile is the one maven_* DELETE that predates this change, and it removes a single file row by its id triple.
No live caller reaches the new code.
Reaper has no consumer outside internal/datastore, and the chunk driver that will call one arrives with the internal/lifecycle package at step 15.
That is why this MR is typed chore.
Maintenance work carries no Related to line under docs/dev/conventions.md, and merged steps 1 and 5 carry none either.
For the same reason, the note in internal/datastore/maven_file_deleter.go that nothing reaps these rows yet stays true of the running product.
That file is outside this step's file set and is not edited here.
MaxLifecycleReapPageSize is new exported surface
The constant is a refusal boundary, not a suggested page size, and its doc says so. At 1000 one full page runs 1000 attachment-guard statements inside the caller's transaction and holds every deleted row's locks until that transaction ends. A chunk driver's own page size therefore belongs far below the ceiling.
The doc names MaxCounterDrainBatchSize as the precedent, for the reason that precedent gives itself: lock hold time and materialization inside the caller's transaction.
It also names where the precedent stops.
MaxCounterDrainBatchSize stands above an operator-facing knob, and a reap ceiling has none, because config.LifecycleConfig carries RetentionWindow and SweepInterval only.
MaxLifecycleScanPageSize is deliberately not cited.
Its stated reason is read-path materialization, which inverts if it is borrowed for a write-path bound.
The Reaper contract correction
Two merged sentences in internal/datastore/lifecycle_scan.go are now wrong, and this change is what makes one of them wrong.
The first sentence said no caller is in the tree yet, which the interface assertions and the two reapers here contradict.
The second said that at most limit rows are deleted per call.
This step's reap deletes a file page of up to limit rows plus the attachments those files free, so the count runs past limit.
The corrected text states the rule.
limit bounds the artifact rows one call selects across every leg of the walk.
ReapTotals.Rows reports those rows together with the collateral the walk reaches, so it runs past limit.
The 2*limit ceiling appears only next to its two conditions: the page spans every leg, and each artifact row frees at most one attachment row.
The text also says which branch the maximum comes from, because the filled-page branch is the one that leaves the parent in place.
The run reconciled the wording against four reap arms before it landed.
No single multiplier holds for all of them, because the npm version subtree has two child legs plus the parent.
The reapers in internal/datastore/lifecycle_reap_maven.go carry Maven's exact 2*limit, where it is true by construction.
A test now pins the clause the rewrite turns on.
assert.Greater(first.Rows, int64(limit)) fails against an implementation that deletes at most limit rows per call.
The old floor assertion passed under both readings, so it looked like coverage and was not.
internal/datastore/lifecycle_scan_test.go carried the only surviving copy of the first sentence.
Both files move for one reason.
No size delta is emitted, and the gap is stated
MavenVersionReaper reports one component and no size delta.
This is a stated gap, not a claim that the reap freed nothing.
The figure repositories.size_bytes wants is repository-scoped.
A digest's bytes leave the repository only when the last maven_files row that carries it goes, anywhere in the repository.
maven_versions.size_bytes is version-scoped.
If two versions of one repository share a digest, that digest comes off the counter twice and goes on once.
An emit of that column moves the counter by more than the reap freed, always in the one direction.
Only the reap can compute the right figure.
Once the chunk's transaction commits, the deleted rows and their digests are gone.
An emit site that runs after the commit has nothing left to derive the figure from.
That computation is tracked in work item 681, #681 (closed).
The doc comment on MavenVersionReaper.Reap carries the derivation and the pointer.
The package-level file page has no seekable index
maven_files has one index whose second column is maven_package_id, unique_maven_files_ns_id_package_id_file_name_when_ver_null.
It is partial on soft_deleted_at IS NULL AND maven_version_id IS NULL, and a state-blind reap cannot use it, because the reap must reach marked rows too.
The version page has no such problem, because index_maven_files_on_ns_id_ver_id is not partial and leads with both equalities.
Package-level files are few per package, and that is the wrong denominator: the seek range is every version-less maven_files row in the namespace, and maven_package_id filters what the index returns rather than narrowing what it reads, so the LIMIT almost never cuts the walk short.
A repository walk reaping P packages pays P full walks over a set that grows with P, and ADR-004 caps repositories per namespace and versions per package while setting no cap on packages.
This query is a second consumer of the index #684 proposes, and the one that answers its open question on column order, because it wants maven_version_id in the index.
This is a shape to know about rather than a hot path.
No migration is added here.
Step 11 reuses this shape for the Maven remote reap.
That step can decide whether it wants an index first.
Files in the diff
The plan's Files: entry for step 9 names three paths.
The diff has twelve.
| Group | Files | Why it is here |
|---|---|---|
| Reap logic | internal/datastore/lifecycle_reap_maven.go, internal/datastore/blob_storage_attachments.go |
The plan's Files: entry |
| Integration suite | internal/datastore/lifecycle_reap_maven_integration_test.go |
The plan's Files: entry |
| Catalog and fixture pins | internal/datastore/query_names.go, internal/datastore/queries_test.go |
Forced by pinned fixtures in this package |
Reaper contract |
internal/datastore/lifecycle_scan.go, internal/datastore/lifecycle_scan_test.go |
The doc correction above, with the operator's consent |
| Attachment statement pins | internal/datastore/blob_storage_attachments_stmt_test.go |
The Maven guard's statement-text pins, beside their every-table siblings |
| Spec corrections | docs/specs/S20-a-lifecycle-closed-beta.md, docs/specs/S22-storage-accounting.md |
Claims this change falsifies |
| e2e catalog | docs/testing/e2e/maven.md |
The delete-surface claim this change falsifies |
| Plan Status row | docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md |
Guardrail 4: the step's MR is recorded in the row |
Which fixture forces which file:
internal/metrics/name_budget_test.goparsesinternal/datastore/query_names.goandinternal/storage/queries.goand no other file. A query name declared beside its statement escapes the budget audit, which is the production surprise that test exists to prevent.TestQueryNames_EachUsedExactlyOncethen pins each declared name to exactly one use site.TestEveryStatementIsInstrumentedfails on a function that calls a statement verb directly unless the function is pinned inrawSQLTimedFunctionsininternal/datastore/queries_test.go.DeleteIfUnreferencedByMavencallsExecContexton a raw statement, which mirrors the guard beside it. The run measured the pin in both directions. With the pin the test passes, and without it the test fails and names the function.
Both catalog entries are single appended lines at the end of their existing group.
The spec corrections are the claims this change falsifies:
- The S20-A per-format reap criterion said no
maven_*table had aDELETEstatement before this change. That was already false at the merge base, and the same spec contradicts it elsewhere. The row now namesMavenFileDeleter.DeleteFileand says what it removes. - The S20-A no-cascade criterion cited Maven's deleter-observation criteria. Maven has none, because its reap is store-level, so the rows going is what its criteria assert.
- The S22 dependency row for S10 Maven hosted carried the same first-delete overclaim. The row now records the served single-file Maven delete, its three call sites, and the fact that it writes no repository counter today. The row leaves the emit question open rather than settling it.
Spec coverage
Spec: docs/specs/S20-a-lifecycle-closed-beta.md
Acceptance criteria
Group ids follow the spec's own headings. Rows outside this step name the plan step that owns them.
| # | Criterion | Tests |
|---|---|---|
| P-1 | Window split at repository level, non-zero and zero | Step 5. TestLifecycleScanStore_ScanTombstonedRepositories |
| P-2 | soft_deleted_at IS NULL never discovered by any scan or walk |
Reaper half here: TestMavenVersionReaper_Reap/a_version_row_is_reaped_in_either_marker_state, .../a_file_is_reaped_in_either_marker_state, TestMavenPackageReaper_Reap/a_package_row_is_reaped_in_either_marker_state, .../a_version-less_file_is_reaped_in_either_marker_state — the reaper is state-blind about children by design. Scan half is Steps 5-7 |
| P-3 | Repository, npm_packages, container_images scan EXPLAIN |
Steps 5 and 7 |
| P-4 | Version-level scan EXPLAIN, per table |
Step 6 |
| P-5 | Re-run on a reaped subtree is a success no-op; mid-walk abort | No-op half here: TestMavenVersionReaper_Reap/a_second_reap_of_a_drained_subtree_reports_no_rows_and_no_error, TestMavenPackageReaper_Reap/a_second_reap_of_a_drained_package_reports_no_rows_and_no_error. gofail abort is Step 14 |
| P-6 | npm repository walk reaps live and tombstoned rows in one pass | Step 14 (walk); the Maven reaper's state-blindness is covered at P-2 |
| P-7 | Every purge transaction bounded | TestMavenVersionReaper_Reap/each_chunk_runs_in_one_transaction,_deletes_at_most_limit_file_rows,_and_draining_takes_more_than_one, TestMavenPackageReaper_Reap/each_chunk_runs_in_one_transaction,_deletes_at_most_limit_file_rows,_and_draining_takes_more_than_one |
| P-8 | Frozen namespace refuses retryably before any row is reaped | Step 15a |
| F-1 | npm version reap with files and attachments | Step 8 |
| F-2 | npm unpublish-then-republish leaves no orphan | Step 8 |
| F-3 | npm package reap with tags and metadata files | Step 8 |
| F-4 | DeleteNpmMetadataFiles removes attachments in-transaction |
Step 8 |
| F-5 | Maven version and package reap, net-new logic | TestMavenVersionReaper_Reap/a_tombstoned_version_is_reaped_with_its_files_and_each_file's_attachment, .../an_attachment_two_files_share_goes_only_when_the_last_of_them_goes, .../the_totals_carry_one_component_and_no_size_delta, .../the_reap_does_not_widen_past_the_version_row_it_was_handed, TestMavenPackageReaper_Reap/a_tombstoned_package_is_reaped_with_its_version-less_files_and_their_attachments, .../the_totals_carry_no_component_and_no_size, .../the_reap_does_not_widen_past_the_package_row_it_was_handed |
| F-6 | Container repository reap through the existing deleters | Step 13 |
| F-7 | Index and children reaped parent-first, no MANIFEST_REFERENCED |
Step 13 |
| F-8 | Tombstoned container_images under a live repository |
Step 13 |
| F-9 | Container walk reaps live and tombstoned images in one pass | Step 13 |
| F-10 | Remote-cache subtree, a positive hit per table | Steps 10 and 11. maven_remote_files gets its guard hit here: TestBlobStorageAttachmentStore_DeleteIfUnreferencedByMaven_EveryReferencingTable/maven_remote_files |
| F-11 | container_remote_manifests and container_remote_blobs |
Step 12 |
| F-12 | Zero attachments per sha256 after a subtree is reaped |
TestMavenVersionReaper_Reap/every_blob_the_subtree_referenced_ends_with_no_attachment_rows, and the attachment counts in TestMavenPackageReaper_Reap/a_tombstoned_package_is_reaped_with_its_version-less_files_and_their_attachments. Counted per sha256, never by asserting the blob row is gone |
| R-1 | DELETE without destructive returns 400 |
Step 18 |
| R-2 | destructive=false on an empty repository returns 204 |
Step 18 |
| R-3 | destructive=false on a non-empty repository returns 409 |
Step 18 |
| R-4 | The 409 message names the remedy the caller has | Step 18 |
| R-5 | destructive=true on non-empty returns 202 and enqueues |
Step 18 |
| R-6 | destructive=true on empty returns 204, no tombstone |
Step 18 |
| R-7 | Every client route naming the repository returns 404 | Steps 3 and 18 |
| R-8 | Create at a tombstoned name returns 409, then 201 | Step 18 |
| R-9 | Finalizer cannot succeed early | Step 14 |
| R-10 | Finalizer removes repository, format-child and collection links | Step 14 |
| S-1 | Tombstone with no live job re-queued within one interval | Step 16 |
| S-2 | Sweep reads oldest-first | Step 16 |
| S-3 | Orphaned npm_packages row enumerated and reaped |
Steps 7 and 16 |
| S-4 | A repository with a live job is not re-queued | Step 16 |
| S-5 | Terminal-state job re-queued (UniqueByArgsWhileLive) |
Step 16 |
| S-6 | Permanently failing purge stays counted and re-queued | Step 16, with Step 17b's gauge |
| S-7 | Sweep registers a worker for its periodic kind | Step 16 |
| S-8 | A frozen namespace has nothing enqueued, then is re-queued | Step 16 |
| V-1 | ContainerRepositoryStore.FindByNameInNamespace, both formats |
Step 3 |
| V-2 | RepositoryStore.FindByID returns ErrNotFound |
Step 3 |
| V-3 | RepositoryStore.Update returns ErrNotFound |
Step 3 |
| V-4 | Delete, decrement, increment and Missing still act on a tombstone |
Step 3 |
| V-5 | parentRepositoryIsActive under a concurrent tombstone |
Step 4 |
| V-6 | Four container_images gates hide a tombstoned image |
S17 Phase 4 |
| V-7 | Every /v2/ route resolving a tombstoned image answers 404 |
S17 Phase 4 |
| V-8 | Blob mount source answers 202, not 404 | S17 Phase 4 |
| V-9 | A tombstoned image named in from= still mounts |
S17 Phase 4 |
| V-10 | Mount destination is not a 404 case | S17 Phase 4 |
| V-11 | UpsertContainerImage does not resurrect a tombstoned image |
S17 Phase 4 |
| V-12 | ContainerImageStore.Delete still acts on a tombstoned row |
S17 Phase 4 |
| A-1 | Destructive DELETE emits no counter delta at the tombstone |
Step 19 |
| A-2 | Decrements fire at the purger, one format-dispatched site | Step 19. The deltas this step's reapers hand it are pinned at F-5's totals subtests |
| A-3 | Every emit site commits its source rows before the delta | Step 19 |
| A-4 | A chunk emits from its committed affected-row aggregate | Step 19. ReapTotals is read from affected rows here, never from a discovery projection — .../the_totals_carry_one_component_and_no_size_delta |
| A-5 | No raw foreign-key cascade is used | Step 19, over this step's deleter-driven reap |
| C-1 | Discovery index migrations apply and roll back | Step 1 |
| C-2 | No migration adds or drops a column | Step 1 |
| C-3 | unique_container_images_ns_id_cr_id_name is partial |
S17 Phase 4 |
| C-4 | Config load accepts and rejects the window and interval | Step 2 |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | DELETE omits destructive |
Step 18 |
| E-2 | destructive carries a non-boolean value |
Step 18 |
| E-3 | destructive=false on a non-empty repository |
Step 18 |
| E-4 | Artifact published between DELETE and its finalizer |
Step 18 |
| E-5 | DELETE on a missing or already-tombstoned repository |
Step 18 |
| E-6 | Create at a name a tombstoned repository holds | Step 18 |
| E-7 | Client route naming a tombstoned repository | Steps 3 and 18 |
| E-8 | Blob mount whose from= names a tombstoned repository |
S17 Phase 4 |
| E-9 | Purge job: 23503 on a parent delete |
TestMavenPackageReaper_Reap/a_version_row_still_beneath_the_package_surfaces_the_foreign-key_violation, asserted on the SQLSTATE and on the rows standing, so a partial success cannot pass. Also TestBlobStorageAttachmentStore_DeleteIfUnreferencedByMaven_NonMavenReferenceIsNotProtected for the guard's own 23503 |
| E-10 | ContainerManifestDeleter refuses a referenced manifest |
Step 13 |
| E-11 | Finalizer aborts because an artifact remains | Step 14 |
| E-12 | Repository already hard-deleted when the job runs | Step 14. The reaper half is the idempotent no-op at P-5 |
| E-13 | Retention window changed between enqueue and run | Steps 5 and 15 |
| E-14 | Sweep enumeration query fails | Step 16 |
| E-15 | Sweep interval elapses before enumeration completes | Step 16 |
| E-16 | Emit API unavailable at a purge or tombstone site | Step 19 |
| E-17 | Parent gate: parent tombstoned between gate and child INSERT |
Step 4 |
| E-18 | Tombstoned coordinate reoccupied before the reap | Step 8 for npm. Out of reach for Maven, for a reason this row first got wrong: unique_maven_versions_ns_id_pkg_id_version is partial on soft_deleted_at IS NULL, and MavenVersionStore.FindOrCreateVersion inserts a new row rather than reviving a marked one, so a republish creates a distinct row the id-keyed reap cannot reach |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Authorization unchanged, purger decides none | Not in this step. The reapers take a row and a handle and read no caller identity |
| S-2 | destructive is a confirmation, not authorization |
Step 18 |
| S-3 | Deletion is irreversible and the API says so | Step 18 |
| S-4 | Input validation; no purger input is user-supplied | TestMavenVersionReaper_Reap/argument_guards_..., TestMavenPackageReaper_Reap/argument_guards_..., TestBlobStorageAttachmentStore_DeleteIfUnreferencedByMaven_ArgumentGuards — every identifier is a UUID or an int64 rejected at the boundary, and no name or digest reaches a query as text |
| S-5 | No injection surface; the raw guard is parameterized | Partial. The behavior is covered by ..._EveryReferencingTable and ..._AnotherAttachmentDoesNotHold; the statement-text pin belongs with deleteUnreferencedByMavenAttachmentStmt, which lands with the implementation beside the existing TestDeleteUnreferencedAttachmentStmt_GuardsEveryReferencingTable |
| S-6 | Error messages must not become an existence oracle | Step 18 |
| S-7 | Purge failures visible, never reported as success | TestMavenPackageReaper_Reap/a_version_row_still_beneath_the_package_surfaces_the_foreign-key_violation — a reap that cannot complete returns an error rather than reporting a drained subtree. The gauge is Step 17b |
The table is the test author's, reproduced as written except for two subtest names that later commits on this branch renamed.
The P-7 rows carry the new name of the bounded-chunk subtests.
The F-5 and A-4 rows carry the new name of the version totals subtest, which the size-delta decision renamed.
The statement-text pin that row S-5 defers is on the branch as TestDeleteUnreferencedByMavenAttachmentStmt_GuardsEveryMavenReferencingTable.
e2e scenario catalogs
No scenario's stated expected outcome becomes wrong or incomplete, and no row's Automation value moved.
This MR does edit that catalog, and the edit is a correction rather than a scenario change: two paragraphs asserting Maven has no delete surface, and the four Lifecycle rows whose blocked status rested on it.
All four keep blocked and now name the dependency each one actually rests on — the unwired metadata reconciler — because whether a row is automatable today is a catalog-owner judgement rather than a code fact, and two rows say so explicitly instead of guessing.
The step is chore, so the e2e guardrail exempts it, and the analysis ran anyway.
The rows checked are the four in the ## Lifecycle group of docs/testing/e2e/maven.md: e2e.maven.lifecycle.delete-version, e2e.maven.lifecycle.delete-package, e2e.maven.lifecycle.deploy-after-delete, and e2e.maven.lifecycle.soft-delete-invisible.
All four read blocked and not started under a preamble that states there is no Maven delete surface yet.
Nothing on this branch reaches the new reap code, so all four stay as they are.
A keyword sweep for attachment across docs/testing/ returns nothing, so a subject search alone clears this step for the wrong reason.
Diff size
2615 insertions and 42 deletions across twelve files, measured with git diff --numstat origin/main...HEAD.
| Group | Files | + | − |
|---|---|---|---|
| Reap logic | internal/datastore/lifecycle_reap_maven.go, internal/datastore/blob_storage_attachments.go |
805 | 0 |
| Integration suite | internal/datastore/lifecycle_reap_maven_integration_test.go |
1446 | 0 |
| Attachment statement pins | internal/datastore/blob_storage_attachments_stmt_test.go |
187 | 0 |
Reaper contract |
internal/datastore/lifecycle_scan.go, internal/datastore/lifecycle_scan_test.go |
146 | 18 |
| Catalog and fixture pins | internal/datastore/query_names.go, internal/datastore/queries_test.go |
9 | 3 |
| Spec corrections | docs/specs/S20-a-lifecycle-closed-beta.md, docs/specs/S22-storage-accounting.md |
8 | 6 |
| e2e catalog | docs/testing/e2e/maven.md |
13 | 14 |
| Plan Status row | docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md |
1 | 1 |
Of the 805 reap-logic insertions, 242 are code lines. The rest is doc comment and blank lines.
This is past the 500 reviewable-LOC guardrail, and a split does not help. The two reapers share the paged-delete shape and both call the one attachment guard. A split by reaper therefore separates the guard from one of its two callers. The plan already examined finer-grained splits and settled on one reap step per format family, in its "Seam interrogation outcomes" section. The other three groups are a few lines each, and each one exists because of the reap logic in this same MR.
The plan's size forecast for step 9 was wrong on both sides.
It forecasts ~180 source and ~480 test.
The measured figures are 954 source insertions and 1639 test insertions, counting every non-test Go file as source.
Read as insertions, the source side is 5.3 times the forecast, and 1.3 times read as code lines only.
The test side is 3.4 times the forecast.
This MR edits the plan only to fill its Status row; the forecast figures are left as the plan records them.
The parent-deferral rule is strengthened, not weakened
A reviewer meeting a 23503 discussion in this MR may read it as weakening the parent-deferral rule.
It does the opposite.
The rule stands, and the finding strengthens its second reason: the parent row is itself an artifact row, so deferring it is what holds one call's selection inside one page, and that reason holds with no reference to drainage at all.
What changed is only the claim that a short page proves the children have drained, and the diagnosis built on it.
The refusal is a run-time check rather than a *sql.Tx parameter.
Typing the internals would let the compiler carry it, and that remains available if a reviewer wants it; it was left out as more churn than this MR should add.
Verification
The figures below were measured at ce5a500a6, this branch's tip.
ba0d6ca18 is the last commit before the branch was squashed, and its tree is byte-identical to the squashed 3210347a0; the diff between them is now 296 insertions and 42 deletions across five files, which is the review work committed after the squash.
The branch carries five conventional-commit subjects.
Every lint run cleared GOFLAGS first and used a private lint cache.
This worktree exports -tags=integration,development_stubs, and a plain run picks that up.
| Check | Tag state | Result |
|---|---|---|
golangci-lint, diff-scoped against 0de47ebf, on ./internal/datastore/ |
untagged, CI parity | 0 issues. |
the same run with --build-tags=integration |
tagged, CI cannot see it | 20 findings, all modernize newexpr on ptr(x), all in the new integration test file, none in either production file |
go test -tags=integration over this step's suites |
tagged | 61 subtests, 61 PASS, 0 SKIP, 0 FAIL |
CI's lint job sets no build tags, so it never compiles a //go:build integration file.
The tagged pass above is the only thing that lints this step's suite.
The tagged pass first reported 137 contextcheck findings on the new suite.
Each site now takes tt.Context() rather than the parent test's context, across 18 Reap calls, and the count went to zero.
No //nolint directive was added for them.
The 17 modernize findings that remain were taken rather than suppressed.
No merged code under internal/ suppresses modernize anywhere, and this MR does not start that precedent.
The run measured every //nolint token in the two new files: it removed each token, re-ran the tagged pass, and watched the finding appear.
thelper reports zero findings on this suite and carries no token.
Merge order and rebase
The blob_storage_attachments constant block
Steps 8, 9 and 12 each append one long constant to the blob_storage_attachments group in internal/datastore/query_names.go.
gofmt re-aligns the whole group to a different column width in each arm, so the block conflicts in any merge order.
internal/datastore/queries_test.go does not have the same shape.
Step 8's key and this arm's are both shorter than the block's longest key, and both leave lines 390-402 byte-identical, so the two do not conflict there in either order.
Step 12's key becomes the new longest and moves all 14 lines, so that realignment is step 12's to resolve when it rebases.
The operator fixed the order as 8, then 9, then 12, with a deliberate rebase between each.
This MR is second, and the rebase onto step 8 is outstanding as of writing.
It falls to whoever handles this MR, because the run that wrote this description ends before step 8 merges.
If step 8 is merged and this branch does not yet carry step 8's DeleteIfUnreferencedByNpm entry in the same constant group, the rebase is still owed.
The query-name budget
internal/metrics/cardinality.go caps distinct name label values at 350, as labelName: 350, and internal/metrics/name_budget_test.go is what enforces it: the test reads that budget and asserts the declared count stays within it.
The number itself is not in the test file.
This arm adds five names.
At this branch's merge base 0de47ebf the audit counts 312 values, and 317 with this arm.
The basis moves as sibling MRs land, so the count at merge time is the one that decides, not the count on this line.
The wave's committed arms cross the old ceiling, and step 6 raises it from 350 to 400.
If the audit is over 350 at rebase time and step 6 is not merged, TestNameBudget_CoversEveryDeclaredQueryName goes red.
Step 6 must merge first in that case.
The reap page ceiling
MaxLifecycleReapPageSize is declared in this arm.
It is named for the reap page so that the sibling reap arms can use it.
None has yet: steps 8 and 12 declare no ceiling, and step 13 declares its own MaxContainerReapPageSize = 1000.
Whichever reap arm merges first owns the declaration; whether the others converge on it is not settled here.
The e2e catalog, a tenth file outside the plan's list
docs/testing/e2e/maven.md said in two paragraphs that there is no Maven delete surface.
Three Maven delete routes are mounted and served on main — package, version and file — and all three emit artifact_registry_artifact_deleted through trackArtifactDeleted.
So the catalog's ## Lifecycle preamble and its usage-data paragraph were both wrong, the second in its conclusion as well as its premise, and the four blocked rows rested on a blocker that is gone.
The false text predates this branch and this MR does not create it.
A chore carries no catalog obligation, so this edit is out of this step's list by any reading, and it is here on an explicit decision rather than by default.
The reason it was not deferred: this run's own e2e check cited that preamble as authority for clearing the obligation, instead of checking it against the routes. That is how a false claim survives — the next pass quotes it — and leaving it would have set the same trap for the pass after this one.
The S22 spec file
Four open merge requests — !1645 (merged), !1652 (merged), !1695 (merged) and !1696 (merged) — and two branches with no merge request touch docs/specs/S22-storage-accounting.md.
None amends the row this MR amends, and the nearest hunk is 18 lines away.
This is a rebase risk in a shared file, not a semantic conflict.
The plan Status row
Row 9 of the Status table in docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md names this MR, and this MR is the change that fills it.
Guardrail 4 in CLAUDE.md asks for the row to be filled in the opening MR's own change set, because an empty cell reads as "not started" however far along the branch is.
An earlier version of this section said the cell was deliberately empty, and that a separate merge request would edit every row once the step MR numbers existed.
That is withdrawn.
No open merge request touches docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md: checked against the full set of open MRs and their changed paths on 2026-08-19.
So the arrangement the empty cell was said to reflect had nothing behind it, and the row would have stayed empty until somebody noticed.
Filling it now is safe in both directions. The cell holds one reference and nothing about it depends on the merge order of the sibling steps.
Rows 8 and 9 are the only adjacent pair being filled in this round. Row 8 is !1705 (merged), which fills its own row on its own branch. Whichever of the two branches rebases second resolves a one-line hunk, and the resolution is to keep both rows.
Database Review Evidence
Produced by the db-review-prep query-mode pass at branch tip 9ff3617d0.
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL
17.10 container (matching GL_PG_CURR_VERSION 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.
Query mode only. This MR adds no file under
internal/datastore/migrations/sql/, so migration mode did not run.
Queries
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.BlobStorageAttachmentStore.DeleteIfUnreferencedByMaven |
Delete on blob_storage_attachments (Nested Loop Anti Join) |
index_blob_storage_attachments_on_namespace_id_and_sha256, index_maven_files_on_ns_id_attachment_id, index_maven_remote_files_on_ns_id_bsa_id |
1 / 1 | 24.93 | 26.199ms | 46 / 17 | 1/64 blob_storage_attachments, 1/64 maven_files, 1/64 maven_remote_files |
datastore.deleteReapedMavenPackageStmt |
Delete on maven_packages (Index Scan) |
pk_maven_packages |
1 / 1 | 8.30 | 3.395ms | 11 / 0 | 1/64 maven_packages |
datastore.deleteReapedMavenVersionStmt |
Delete on maven_versions (Index Scan) |
unique_maven_versions_id_pkg_id_ns_id |
1 / 1 | 8.30 | 2.227ms | 9 / 0 | 1/64 maven_versions |
datastore.reapMavenFilePageStmt.PackageBranch |
Delete on maven_files (Hash Semi Join) |
none at this seed shape; see the notes | 100 / 100 | 476.87 | 1.860ms | 679 / 0 | 1/64 maven_files |
datastore.reapMavenFilePageStmt.VersionBranch |
Delete on maven_files (Hash Semi Join) |
none at this seed shape; see the notes | 100 / 100 | 486.13 | 1.413ms | 518 / 0 | 1/64 maven_files |
Every statement prunes to one partition of every table it touches. No fan-out.
Query notes:
datastore.reapMavenFilePageStmt(both branches): the seed shape, not the statement. The recipe seed puts all 5000 version-bound rows under one version and all 5000 version-less rows under one package, so the page predicate matches half the partition and aSeq Scanunder theLIMITwins on cost. A second probe at 200000maven_filesrows spread over 200 versions, with the page aimed at the last of them (1000 of 200000 rows, at the tail of the heap), gives the shape the MR description claims: the innerSELECTseeksindex_maven_files_on_ns_id_ver_id, and the outerDELETEbecomes a Nested Loop overpk_maven_files. Both plans are in the details blocks. No action on this MR.datastore.deleteReapedMavenPackageStmt: the row delete is an index seek, but the statement also fires theNO ACTIONcheck behindfk_maven_files_maven_package_id_maven_packages, andmaven_filescarries no non-partial index that leads withmaven_package_id. The two indexes that do carry the column areunique_maven_files_ns_id_package_id_file_name_when_ver_nullandunique_maven_files_ns_id_version_id_file_name, and both are definedWHERE soft_deleted_at IS NULL, so referential integrity cannot use either. The check therefore reads the whole partition to prove no child survives, which is the state this reap always deletes in. Measured trigger time:1.998msat 10000 rows in the partition,16.286msat 200000. Themaven_versionsside of the same reap pays0.503msat 200000, becauseindex_maven_files_on_ns_id_ver_idseeks its check. A non-partial index onmaven_files (namespace_id, maven_package_id)would close the gap. Out of scope for this MR, and worth a follow-up: this is the first code path that deletesmaven_packagesrows in bulk.datastore.BlobStorageAttachmentStore.DeleteIfUnreferencedByMaven: the guard's own plan is what the comment describes, three index seeks and one partition per table. The per-row bill is not. A delete that removes a row fires one referential-integrity check per table with a foreign key intoblob_storage_attachments, and there are ten of them:container_blobs,container_manifests,container_remote_blobs,container_remote_manifests,maven_files,maven_remote_files,npm_files,npm_metadata_files,npm_remote_files, andnpm_remote_metadata_files. They cost25.2msof the statement's26.199mson a cold cache. The two Maven checks are the cheapest two of the ten (0.269msand0.338ms), because the guard's own scans warmed those partitions first. Warm steady state over 200 successive single-row deletes:753.9ms, or3.77msper row.- The
deleteUnreferencedByMavenAttachmentStmtdoc comment reads as if the two-clause shape holds the per-row bill down. It holds down the statement's own planning, which is real. It does not touch the ten referential-integrity checks, and those dominate the success path. The narrower claim is the accurate one. No code change proposed; the comment is what would change. MaxLifecycleReapPageSize = 1000already documents itself as a refusal boundary rather than a page size. The guard measurement puts a number on it: a full page costs about3.8sof attachment-guard time inside the caller's transaction, warm, before the file-pageDELETEand the parent row.- No unbounded
SELECT. The innerSELECTcarriesLIMIT $4, andcheckMavenReapArgsclamps that value toMaxLifecycleReapPageSizeand rejects zero and negative values before any statement runs. - Planner estimates match seeded reality on every statement. No mismatch above 1x.
datastore.BlobStorageAttachmentStore.DeleteIfUnreferencedByMaven
Summary: Plan matches the method's intent. The (namespace_id, sha256)
predicate prunes blob_storage_attachments to one of 64 partitions and both
correlated NOT EXISTS probes seek their table's
(namespace_id, blob_storage_attachment_id) index, one partition each. The
anomaly is downstream of the plan: ten referential-integrity checks fire on the
row's removal, and they carry 25.2ms of the 26.199ms.
Seed shape: namespaces=1, repositories=2, maven_repositories=1, maven_remote_repositories=1, maven_packages=5000, maven_versions=5000, maven_remote_packages=1, maven_remote_versions=1, blob_storage_blobs=20000, blob_storage_attachments=20000, maven_files=10000, maven_remote_files=5000
The 5000 spare attachments that no Maven row references are filtered through
satisfies_hash_partition('blob_storage_attachments', 64, 0, ...), so the
guard's target partition holds a non-trivial row count instead of about 78.
Rendered SQL:
DELETE FROM blob_storage_attachments bsa
WHERE bsa.namespace_id = $1 AND bsa.id = $2 AND bsa.sha256 = $3
AND NOT EXISTS (
SELECT 1 FROM maven_files ref
WHERE ref.namespace_id = bsa.namespace_id AND ref.blob_storage_attachment_id = bsa.id
)
AND NOT EXISTS (
SELECT 1 FROM maven_remote_files ref
WHERE ref.namespace_id = bsa.namespace_id AND ref.blob_storage_attachment_id = bsa.id
)Bound args: ['ffbc5873-cef2-4845-ace5-4d63a86362ed', 100001, decode('00000000000000000000000000000000000000000000000000000000000f4294', 'hex')]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Delete on blob_storage_attachments bsa (cost=0.85..24.93 rows=0 width=0) (actual time=0.562..0.563 rows=0 loops=1)
Delete on blob_storage_attachments_p00 bsa_1
Buffers: shared hit=46 read=17
-> Nested Loop Anti Join (cost=0.85..24.93 rows=1 width=30) (actual time=0.036..0.037 rows=1 loops=1)
Buffers: shared hit=7
-> Nested Loop Anti Join (cost=0.57..16.62 rows=1 width=44) (actual time=0.027..0.028 rows=1 loops=1)
Buffers: shared hit=5
-> Index Scan using blob_storage_attachments_p00_namespace_id_sha256_idx on blob_storage_attachments_p00 bsa_1 (cost=0.28..8.30 rows=1 width=34) (actual time=0.015..0.016 rows=1 loops=1)
Index Cond: ((namespace_id = 'ffbc5873-cef2-4845-ace5-4d63a86362ed'::uuid) AND (sha256 = '\x00000000000000000000000000000000000000000000000000000000000f4294'::bytea))
Filter: (id = '100001'::bigint)
Buffers: shared hit=3
-> Index Scan using maven_files_p40_namespace_id_blob_storage_attachment_id_idx on maven_files_p40 ref (cost=0.29..8.30 rows=1 width=34) (actual time=0.010..0.010 rows=0 loops=1)
Index Cond: ((namespace_id = 'ffbc5873-cef2-4845-ace5-4d63a86362ed'::uuid) AND (blob_storage_attachment_id = '100001'::bigint))
Buffers: shared hit=2
-> Index Scan using maven_remote_files_p40_namespace_id_blob_storage_attachment_idx on maven_remote_files_p40 ref_1 (cost=0.28..8.30 rows=1 width=34) (actual time=0.009..0.009 rows=0 loops=1)
Index Cond: ((namespace_id = 'ffbc5873-cef2-4845-ace5-4d63a86362ed'::uuid) AND (blob_storage_attachment_id = '100001'::bigint))
Buffers: shared hit=2
Planning:
Buffers: shared hit=475 read=1
Planning Time: 3.031 ms
Trigger for constraint container_blobs_blob_storage_attachment_id_namespace_id_bl_fkey on blob_storage_attachments_p00: time=3.662 calls=1
Trigger for constraint container_manifests_blob_storage_attachment_id_namespace_i_fkey on blob_storage_attachments_p00: time=4.611 calls=1
Trigger for constraint npm_files_blob_storage_attachment_id_namespace_id_blob_sha_fkey on blob_storage_attachments_p00: time=2.744 calls=1
Trigger for constraint npm_metadata_files_blob_storage_attachment_id_namespace_id_fkey on blob_storage_attachments_p00: time=2.024 calls=1
Trigger for constraint maven_files_blob_storage_attachment_id_namespace_id_blob_s_fkey on blob_storage_attachments_p00: time=0.269 calls=1
Trigger for constraint npm_remote_metadata_files_blob_storage_attachment_id_names_fkey on blob_storage_attachments_p00: time=2.521 calls=1
Trigger for constraint npm_remote_files_blob_storage_attachment_id_namespace_id_b_fkey on blob_storage_attachments_p00: time=2.810 calls=1
Trigger for constraint maven_remote_files_blob_storage_attachment_id_namespace_id_fkey on blob_storage_attachments_p00: time=0.338 calls=1
Trigger for constraint container_remote_manifests_blob_storage_attachment_id_name_fkey on blob_storage_attachments_p00: time=3.550 calls=1
Trigger for constraint container_remote_blobs_blob_storage_attachment_id_namespac_fkey on blob_storage_attachments_p00: time=2.722 calls=1
Execution Time: 26.199 msTimings: planning 3.031ms, execution 26.199ms, total 29.230ms.
Warm steady state over 200 successive deletes of distinct unreferenced
attachments in one transaction: 753.9ms total, 3.77ms per row. That figure
is a lower bound on the Go path's per-row cost, because the measurement loop
runs inside plpgsql and reuses one cached plan, where the client runs the
simple protocol and plans per call.
datastore.deleteReapedMavenPackageStmt
Summary: Plan matches the method's intent. The whole primary key gives an
index seek on pk_maven_packages and prunes to one of 64 partitions. The cost
is in the referential-integrity checks the delete fires: 1.083ms for
maven_versions and 1.998ms for maven_files at this seed size, and the
maven_files one grows with the partition because no non-partial index leads
with maven_package_id.
Seed shape: namespaces=1, repositories=2, maven_repositories=1, maven_remote_repositories=1, maven_packages=5000, maven_versions=5000, maven_remote_packages=1, maven_remote_versions=1, blob_storage_blobs=20000, blob_storage_attachments=20000, maven_files=10000, maven_remote_files=5000
The bound package is the one seeded package with no versions and no files, which
is the state the reaper deletes in: MavenPackageReaper.Reap takes the package
row only after a short file page.
Rendered SQL:
DELETE FROM public.maven_packages
WHERE (maven_packages.namespace_id = $1::uuid) AND (maven_packages.id = $2::uuid);Bound args: ['c9d68ed7-4843-4cb1-8859-03d59bfa719f', '2da0bd4c-eea3-46b6-8617-124acdd7ebf6']
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Delete on maven_packages (cost=0.28..8.30 rows=0 width=0) (actual time=0.054..0.055 rows=0 loops=1)
Delete on maven_packages_p14 maven_packages_1
Buffers: shared hit=11
-> Index Scan using maven_packages_p14_pkey on maven_packages_p14 maven_packages_1 (cost=0.28..8.30 rows=1 width=10) (actual time=0.014..0.015 rows=1 loops=1)
Index Cond: ((id = '2da0bd4c-eea3-46b6-8617-124acdd7ebf6'::uuid) AND (namespace_id = 'c9d68ed7-4843-4cb1-8859-03d59bfa719f'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=56
Planning Time: 0.631 ms
Trigger for constraint maven_versions_maven_package_id_namespace_id_fkey14 on maven_packages_p14: time=1.083 calls=1
Trigger for constraint maven_files_maven_package_id_namespace_id_fkey14 on maven_packages_p14: time=1.998 calls=1
Execution Time: 3.395 msTimings: planning 0.631ms, execution 3.395ms, total 4.026ms.
datastore.deleteReapedMavenVersionStmt
Summary: Plan matches the method's intent. The whole primary key gives an
index seek and prunes to one of 64 partitions. The delete fires one
referential-integrity check, against maven_files, and
index_maven_files_on_ns_id_ver_id seeks it: 1.775ms here and 1.586ms at
200000 rows in the partition. No anomalies.
Seed shape: namespaces=1, repositories=2, maven_repositories=1, maven_remote_repositories=1, maven_packages=5000, maven_versions=5000, maven_remote_packages=1, maven_remote_versions=1, blob_storage_blobs=20000, blob_storage_attachments=20000, maven_files=10000, maven_remote_files=5000
The bound version is the one seeded version with no files, matching the state
MavenVersionReaper.Reap deletes in.
Rendered SQL:
DELETE FROM public.maven_versions
WHERE (maven_versions.namespace_id = $1::uuid) AND (maven_versions.id = $2::uuid)
RETURNING maven_versions.id AS "maven_versions.id";Bound args: ['ee94da6a-f4fb-4043-b7a0-3a7800a70d0b', '9d8e8381-e507-4673-b8f9-3e2157e435de']
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Delete on maven_versions (cost=0.28..8.30 rows=1 width=10) (actual time=0.067..0.068 rows=1 loops=1)
Delete on maven_versions_p42 maven_versions_1
Buffers: shared hit=9
-> Index Scan using maven_versions_p42_id_maven_package_id_namespace_id_idx on maven_versions_p42 maven_versions_1 (cost=0.28..8.30 rows=1 width=10) (actual time=0.023..0.024 rows=1 loops=1)
Index Cond: ((id = '9d8e8381-e507-4673-b8f9-3e2157e435de'::uuid) AND (namespace_id = 'ee94da6a-f4fb-4043-b7a0-3a7800a70d0b'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=95
Planning Time: 1.145 ms
Trigger for constraint maven_files_maven_version_id_maven_package_id_namespace__fkey42 on maven_versions_p42: time=1.775 calls=1
Execution Time: 2.227 msTimings: planning 1.145ms, execution 2.227ms, total 3.372ms.
datastore.reapMavenFilePageStmt.PackageBranch
Summary: The statement prunes to one maven_files partition and returns
exactly the 100 rows the page asked for. At this seed shape the planner takes a
Seq Scan for both the inner id SELECT and the outer DELETE, because the
page predicate matches half the partition. The inner scan discards 5000
version-bound rows before it reaches 100 matches, which is the shape that
shifts to index_maven_files_on_ns_id_ver_id once the predicate is selective.
See the realistic-selectivity block.
Seed shape: namespaces=1, repositories=2, maven_repositories=1, maven_remote_repositories=1, maven_packages=5000, maven_versions=5000, maven_remote_packages=1, maven_remote_versions=1, blob_storage_blobs=20000, blob_storage_attachments=20000, maven_files=10000, maven_remote_files=5000
5000 of the maven_files rows are version-bound and 5000 are version-less under
the bound package.
Rendered SQL:
DELETE FROM public.maven_files
WHERE (maven_files.namespace_id = $1::uuid) AND (maven_files.id IN ((
SELECT maven_files.id AS "maven_files.id"
FROM public.maven_files
WHERE (maven_files.namespace_id = $2::uuid) AND ((maven_files.maven_package_id = $3::uuid) AND (maven_files.maven_version_id IS NULL))
LIMIT $4
)))
RETURNING maven_files.blob_storage_attachment_id AS "maven_files.blob_storage_attachment_id",
maven_files.blob_sha256 AS "maven_files.blob_sha256";Bound args: ['4b2e5ce3-5fd4-4fe0-9c17-977bb9abd6c1', '4b2e5ce3-5fd4-4fe0-9c17-977bb9abd6c1', 'ac2f3e0a-4e6b-41ad-a3ab-ec6e49951ba4', 100]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Delete on maven_files (cost=11.51..476.87 rows=100 width=50) (actual time=0.870..1.639 rows=100 loops=1)
Delete on maven_files_p17 maven_files_1
Buffers: shared hit=679
-> Hash Semi Join (cost=11.51..476.87 rows=100 width=50) (actual time=0.864..1.436 rows=100 loops=1)
Hash Cond: (maven_files_1.id = "ANY_subquery"."maven_files.id")
Buffers: shared hit=479
-> Seq Scan on maven_files_p17 maven_files_1 (cost=0.00..438.00 rows=10000 width=26) (actual time=0.006..0.691 rows=10000 loops=1)
Filter: (namespace_id = '4b2e5ce3-5fd4-4fe0-9c17-977bb9abd6c1'::uuid)
Buffers: shared hit=313
-> Hash (cost=10.26..10.26 rows=100 width=56) (actual time=0.336..0.336 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 17kB
Buffers: shared hit=166
-> Subquery Scan on "ANY_subquery" (cost=0.00..10.26 rows=100 width=56) (actual time=0.297..0.318 rows=100 loops=1)
Buffers: shared hit=166
-> Limit (cost=0.00..9.26 rows=100 width=16) (actual time=0.290..0.303 rows=100 loops=1)
Buffers: shared hit=166
-> Seq Scan on maven_files_p17 maven_files_2 (cost=0.00..463.00 rows=5000 width=16) (actual time=0.289..0.298 rows=100 loops=1)
Filter: ((maven_version_id IS NULL) AND (namespace_id = '4b2e5ce3-5fd4-4fe0-9c17-977bb9abd6c1'::uuid) AND (maven_package_id = 'ac2f3e0a-4e6b-41ad-a3ab-ec6e49951ba4'::uuid))
Rows Removed by Filter: 5000
Buffers: shared hit=166
Planning:
Buffers: shared hit=231 read=1
Planning Time: 1.194 ms
Execution Time: 1.860 msTimings: planning 1.194ms, execution 1.860ms, total 3.054ms.
datastore.reapMavenFilePageStmt.VersionBranch
Summary: The statement prunes to one maven_files partition and returns
exactly the 100 rows the page asked for. Same Seq Scan shape as the package
branch and for the same reason: every seeded version-bound row satisfies the
predicate, so the planner reaches 100 matches in 5 buffers and a Seq Scan
under the LIMIT beats an index seek on cost. See the realistic-selectivity
block for the plan the statement takes when the target version is a small slice
of a large partition.
Seed shape: namespaces=1, repositories=2, maven_repositories=1, maven_remote_repositories=1, maven_packages=5000, maven_versions=5000, maven_remote_packages=1, maven_remote_versions=1, blob_storage_blobs=20000, blob_storage_attachments=20000, maven_files=10000, maven_remote_files=5000
Rendered SQL:
DELETE FROM public.maven_files
WHERE (maven_files.namespace_id = $1::uuid) AND (maven_files.id IN ((
SELECT maven_files.id AS "maven_files.id"
FROM public.maven_files
WHERE (maven_files.namespace_id = $2::uuid) AND ((maven_files.maven_version_id = $3::uuid) AND (maven_files.maven_version_id IS NOT NULL))
LIMIT $4
)))
RETURNING maven_files.blob_storage_attachment_id AS "maven_files.blob_storage_attachment_id",
maven_files.blob_sha256 AS "maven_files.blob_sha256";Bound args: ['ad97c581-10fe-4ab7-bfb2-880d248db284', 'ad97c581-10fe-4ab7-bfb2-880d248db284', 'e53f8652-8a7d-459c-af2b-af64a6f234a1', 100]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Delete on maven_files (cost=20.77..486.13 rows=100 width=50) (actual time=0.059..1.208 rows=100 loops=1)
Delete on maven_files_p13 maven_files_1
Buffers: shared hit=518
-> Hash Semi Join (cost=20.77..486.13 rows=100 width=50) (actual time=0.054..1.172 rows=100 loops=1)
Hash Cond: (maven_files_1.id = "ANY_subquery"."maven_files.id")
Buffers: shared hit=318
-> Seq Scan on maven_files_p13 maven_files_1 (cost=0.00..438.00 rows=10000 width=26) (actual time=0.006..0.754 rows=10000 loops=1)
Filter: (namespace_id = 'ad97c581-10fe-4ab7-bfb2-880d248db284'::uuid)
Buffers: shared hit=313
-> Hash (cost=19.52..19.52 rows=100 width=56) (actual time=0.043..0.043 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 17kB
Buffers: shared hit=5
-> Subquery Scan on "ANY_subquery" (cost=0.00..19.52 rows=100 width=56) (actual time=0.008..0.028 rows=100 loops=1)
Buffers: shared hit=5
-> Limit (cost=0.00..18.52 rows=100 width=16) (actual time=0.002..0.015 rows=100 loops=1)
Buffers: shared hit=5
-> Seq Scan on maven_files_p13 maven_files_2 (cost=0.00..463.00 rows=2500 width=16) (actual time=0.001..0.011 rows=100 loops=1)
Filter: ((maven_version_id IS NOT NULL) AND (namespace_id = 'ad97c581-10fe-4ab7-bfb2-880d248db284'::uuid) AND (maven_version_id = 'e53f8652-8a7d-459c-af2b-af64a6f234a1'::uuid))
Buffers: shared hit=5
Planning:
Buffers: shared hit=425 read=1
Planning Time: 1.757 ms
Execution Time: 1.413 msTimings: planning 1.757ms, execution 1.413ms, total 3.170ms.
Realistic-selectivity probe: reapMavenFilePageStmt.VersionBranch at 200000 rows over 200 versions
Why: the recipe seed puts every version-bound row under one version, so the
page predicate matches the whole partition, and the Seq Scan plan above is an
artifact of that shape rather than a property of the statement. This probe
spreads 200000 maven_files rows over 200 versions of one package, 1000 rows
each, and aims the page at the last version, whose rows sit at the tail of the
heap. That is the worst placement for a Seq Scan and the case the statement
has to survive.
Seed shape: namespaces=1, repositories=1, maven_repositories=1, maven_packages=2, maven_versions=201, blob_storage_blobs=200000, blob_storage_attachments=200000, maven_files=200000
Result: the inner SELECT seeks index_maven_files_on_ns_id_ver_id and the
outer DELETE becomes a Nested Loop over pk_maven_files, 100 loops. One
partition of maven_files, execution 3.060ms. This is the plan the MR
description claims, and it confirms the page does not degrade with the
partition's size.
Delete on maven_files (cost=176.93..1010.51 rows=100 width=50) (actual time=0.451..1.204 rows=100 loops=1)
Delete on maven_files_p07 maven_files_1
Buffers: shared hit=608 read=1
-> Nested Loop (cost=176.93..1010.51 rows=100 width=50) (actual time=0.424..1.047 rows=100 loops=1)
Buffers: shared hit=408 read=1
-> HashAggregate (cost=176.51..177.51 rows=100 width=56) (actual time=0.368..0.392 rows=100 loops=1)
Group Key: "ANY_subquery"."maven_files.id"
Batches: 1 Memory Usage: 32kB
Buffers: shared hit=9
-> Subquery Scan on "ANY_subquery" (cost=0.42..176.26 rows=100 width=56) (actual time=0.202..0.315 rows=100 loops=1)
Buffers: shared hit=9
-> Limit (cost=0.42..175.26 rows=100 width=16) (actual time=0.124..0.205 rows=100 loops=1)
Buffers: shared hit=9
-> Index Scan using maven_files_p07_namespace_id_maven_version_id_blob_sha256_idx on maven_files_p07 maven_files_2 (cost=0.42..1747.09 rows=999 width=16) (actual time=0.121..0.193 rows=100 loops=1)
Index Cond: ((namespace_id = '602d111e-7394-4ce9-8bc9-33e94183759c'::uuid) AND (maven_version_id IS NOT NULL) AND (maven_version_id = '5993e986-ff40-44e6-bde9-fe2bd9a425b3'::uuid))
Buffers: shared hit=9
-> Index Scan using maven_files_p07_pkey on maven_files_p07 maven_files_1 (cost=0.42..8.32 rows=1 width=26) (actual time=0.006..0.006 rows=1 loops=100)
Index Cond: ((id = "ANY_subquery"."maven_files.id") AND (namespace_id = '602d111e-7394-4ce9-8bc9-33e94183759c'::uuid))
Buffers: shared hit=399 read=1
Planning:
Buffers: shared hit=219 read=11
Planning Time: 3.561 ms
Execution Time: 3.060 msThe same probe re-ran the parent-row deletes against a childless version and a
childless package in that 200000-row partition, which is where the
maven_packages referential-integrity figure comes from:
### version row DELETE, childless version, 200000 maven_files in the partition
Trigger for constraint maven_files_maven_version_id_maven_package_id_namespace_i_fkey7 on maven_versions_p07: time=1.586 calls=1
Execution Time: 2.095 ms
### package row DELETE, childless package, 200000 maven_files in the partition
Trigger for constraint maven_versions_maven_package_id_namespace_id_fkey7 on maven_packages_p07: time=0.503 calls=1
Trigger for constraint maven_files_maven_package_id_namespace_id_fkey7 on maven_packages_p07: time=16.286 calls=1
Execution Time: 17.345 msThis is a bot message