chore(datastore): npm remote cache reap (S20-A plan: 10/21)
What this delivers
Step 10 of the S20-A plan: the reap logic for the npm remote cache.
Two Reaper implementations land in a new file, internal/datastore/lifecycle_reap_npm_remote.go.
NpmRemoteVersionReaperreaps the subtree of one tombstonednpm_remote_versionsrow. It removes the version'snpm_remote_filesrows and theblob_storage_attachmentsrow each file holds, then the version row.NpmRemotePackageReaperreaps the subtree of one tombstonednpm_remote_packagesrow. It removes the package'snpm_remote_metadata_filesrows and their attachments, then the package row.
Both reach their cache rows through the bounded holder delete step 8 declares, deleteNpmAttachmentHolderBatch over an npmAttachmentHolder.
That helper removes the holder rows and returns the attachments they held.
Each Reap then releases those attachments itself, in one call on every path, through step 8's BlobStorageAttachmentStore.releaseNpmAttachments and the DeleteIfUnreferencedByNpm guard behind it.
Both take the children first and the parent last.
The release sits before the parent delete, which is where NpmPackageReaper puts its own.
Both remote parents are the row a cache fill locks first.
Taking the parent last is each reap's one leg out of the fill's order.
A chunk that runs out of budget mid-subtree leaves the parent standing for the next chunk.
npm_remote_metadata_files carries no soft_deleted_at column, so it holds no marked state for a scan to find.
Walking the parent package is the only route to those rows and to the attachments behind them.
There is no tag leg, and that absence is schema rather than omission.
No npm_remote_tags table exists, so nothing of that shape can pin a version row.
Neither reaper has a production caller.
Plan step 15 wires the chunk driver that calls a Reaper, and plan step 6 adds the npm_remote_versions discovery scan.
Today the tests are the only callers, so no request and no job in a booted service reaches this code.
The change also appends four query-name constants to internal/datastore/query_names.go, one per new instrumented statement.
The suite is 12 integration tests against a real Postgres, plus an argument-guard table of 8 cases.
The reviewable diff is git diff origin/main...HEAD, which is what GitLab shows against this target.
It carries only step 10's own change set, because !1705 (merged) merged and this merge request now targets main.
Spec coverage
Spec: docs/specs/S20-a-lifecycle-closed-beta.md
Step 10 owns the npm remote reap. Rows owned by another step of docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md name that step.
Acceptance criteria
Purger and discovery
| # | Criterion | Tests |
|---|---|---|
| AC-P1 | Eligible repositories tombstone discovered, newer one not; window zero and non-zero |
Step 5. Not tested here |
| AC-P2 | A soft_deleted_at IS NULL row is never discovered by any scan or walk, every table in the reap inventory |
Scan half: Steps 6 and 7. Walk half for this family: TestNpmRemoteVersionReaper_Reap_RemovesTheVersionSubtree, TestNpmRemoteVersionReaper_Reap_IsIdempotent (both assert the live sibling version, its file, its attachment and the parent package's cache row survive), TestNpmRemotePackageReaper_Reap_RemovesTheMarkedPackageAndItsCache, TestNpmRemotePackageReaper_Reap_IsIdempotent, TestNpmRemotePackageReaper_Reap_BoundsEachChunk (all assert the live sibling package survives) |
| AC-P3 | EXPLAIN on the repositories, npm_packages, container_images scans |
Steps 5 and 7. Not tested here |
| AC-P4 | EXPLAIN on the five version-level scans |
Step 6. Not tested here |
| AC-P5 | Re-run on a drained subtree is a success no-op; a mid-walk abort leaves fewer rows and no per-artifact marker | Re-run half: TestNpmRemoteVersionReaper_Reap_IsIdempotent, TestNpmRemotePackageReaper_Reap_IsIdempotent. No-marker half: TestNpmRemotePackageReaper_Reap_StallsWhileAVersionSurvives, TestNpmRemoteVersionReaper_Reap_RunsEveryStatementOnTheCallersHandle (an aborted chunk rolls back whole and the retry converges). The gofail mid-walk abort is Step 14 |
| AC-P6 | The repository walk reaps live and tombstoned rows in one pass; a marked row is not read as another worker's claim | Walk itself: Step 14. Reaper side for this family: TestNpmRemoteVersionReaper_Reap_IsStateBlindOnTheRowItIsHanded, plus the unmarked-file assertion in TestNpmRemoteVersionReaper_Reap_RemovesTheVersionSubtree |
| AC-P7 | Every purge transaction is bounded; more artifacts than one chunk yields more than one transaction | TestNpmRemoteVersionReaper_Reap_BoundsEachChunk, TestNpmRemotePackageReaper_Reap_BoundsEachChunk, and TestNpmRemotePackageReaper_Reap_TakesTheRowOnceTheBudgetOutrunsTheCache for the other side of the bound: one call whose budget outruns the cache takes the parent row too |
| AC-P8 | A purge job in a namespace that stopped serving writes refuses retryably, per lifecycle column | Step 16. Not tested here |
Per-format reap logic
| # | Criterion | Tests |
|---|---|---|
| AC-F1 | Tombstoned npm version reaped with files and attachments, through npm's own guard | Step 8 (TestNpmVersionReaper_Reap_RemovesTheVersionSubtree) |
| AC-F2 | Unpublish-then-republish leaves no orphan at the shared coordinate | Step 8 (TestNpmVersionReaper_Reap_LeavesTheRepublishedRowUntouched) |
| AC-F3 | Tombstoned npm package reaped with tags, metadata files and attachments, no 23503 |
Step 8 (TestNpmPackageReaper_Reap_RemovesAPackageTombstonedBySingleVersionUnpublish) |
| AC-F4 | DeleteNpmMetadataFiles removes each attachment in the same transaction |
Step 8 |
| AC-F5 | Tombstoned Maven version and package reaped with files and attachments | Step 9. Not tested here |
| AC-F6 | Tombstoned container repository reaped through the existing per-artifact deleters | Steps 13 and 14. Not tested here |
| AC-F7 | Container index and its children reaped parent-first, no MANIFEST_REFERENCED |
Step 13. Not tested here |
| AC-F8 | Tombstoned container_images row under a live repository reaped through the same walk |
Step 13. Not tested here |
| AC-F9 | Container repository walk reaps live and tombstoned images in one pass | Steps 13 and 14. Not tested here |
| AC-F10 | A tombstoned remote-cache subtree is reaped for every remote table carrying the column, plus npm_remote_metadata_files and their attachments walked from the parent. A positive hit per table |
This step, for the npm remote family. npm_remote_versions and npm_remote_files: TestNpmRemoteVersionReaper_Reap_RemovesTheVersionSubtree, TestNpmRemoteVersionReaper_Reap_ReapsAVersionTheEvictorMarked. npm_remote_packages and npm_remote_metadata_files: TestNpmRemotePackageReaper_Reap_RemovesTheMarkedPackageAndItsCache, which walks the parent for the cache rows because that table carries no soft_deleted_at. Maven remote is Step 11 and container_remote_images is Step 12 |
| AC-F11 | container_remote_manifests, container_remote_blobs, container_remote_tags, container_remote_manifest_relationships |
Step 12. Not tested here |
| AC-F12 | After a subtree is reaped every blob it referenced has zero blob_storage_attachments rows, counted per sha256 |
This step, for the npm remote family: the countAttachments assertions in assertNpmRemoteVersionSubtreeGone, TestNpmRemoteVersionReaper_Reap_ReapsAVersionTheEvictorMarked, TestNpmRemoteVersionReaper_Reap_BoundsEachChunk, TestNpmRemotePackageReaper_Reap_RemovesTheMarkedPackageAndItsCache and TestNpmRemotePackageReaper_Reap_BoundsEachChunk. The negative half, an attachment a surviving npm row still holds, is TestNpmRemoteVersionReaper_Reap_LeavesAnAttachmentAnotherNpmRowHolds |
Repository entry point
| # | Criterion | Tests |
|---|---|---|
| AC-R1 | Missing or non-boolean destructive returns 400 |
Step 15. Not tested here |
| AC-R2 | destructive=false on an empty repository returns 204, name reusable |
Step 15. Not tested here |
| AC-R3 | destructive=false on a non-empty repository returns 409 |
Step 15. Not tested here |
| AC-R4 | The 409 message names the remedy the caller has |
Step 15. Not tested here |
| AC-R5 | destructive=true on a non-empty repository returns 202, tombstones, enqueues in the same transaction |
Steps 15 and 17. Not tested here |
| AC-R6 | destructive=true on an empty repository returns 204, no tombstone, no job |
Step 15. Not tested here |
| AC-R7 | Every client route naming the repository returns 404 after the 202 |
Steps 3, 4 and 15. Not tested here |
| AC-R8 | Create at a tombstoned name returns 409, 201 after the finalizer |
Steps 14 and 15. Not tested here |
| AC-R9 | The finalizer cannot succeed early with an artifact left in place | Step 14. Not tested here |
| AC-R10 | The finalizer removes the repository row, format-child row and collection links | Step 14. Not tested here |
Sweep
| # | Criterion | Tests |
|---|---|---|
| AC-S1 | A tombstoned repository with no live purge job is re-queued within one interval | Step 18. Not tested here |
| AC-S2 | Oldest-first page ordering, and EXPLAIN shows no Sort node |
Steps 5 and 18. Not tested here |
| AC-S3 | An orphaned npm_packages row is enumerated on the added index and purged without 23503 |
Steps 7 and 18. Not tested here |
| AC-S4 | A repository with a live purge job is not re-queued | Step 18. Not tested here |
| AC-S5 | A terminal prior job is re-queued, pinning UniqueByArgsWhileLive() |
Step 18. Not tested here |
| AC-S6 | A permanently failing purge stays counted and re-queued at the bounded cadence | Steps 17b and 18. Not tested here |
| AC-S7 | The sweep registers a worker for its periodic kind | Step 18. Not tested here |
| AC-S8 | A frozen namespace has nothing enqueued, and is re-queued once the condition clears | Step 18. Not tested here |
Tombstone visibility
| # | Criterion | Tests |
|---|---|---|
| AC-V1 | ContainerRepositoryStore.FindByNameInNamespace hides a tombstoned repository, docker and oci |
Step 3. Not tested here |
| AC-V2 | RepositoryStore.FindByID returns ErrNotFound |
Step 3. Not tested here |
| AC-V3 | RepositoryStore.Update returns ErrNotFound |
Step 3. Not tested here |
| AC-V4 | Delete, the three counter writes and Missing still act on a tombstoned row |
Step 3. Not tested here |
| AC-V5 | parentRepositoryIsActive refuses a parent tombstoned mid-transaction |
Step 4. Not tested here |
| AC-V6 | The four container_images gates hide a tombstoned image, plus FindByID |
S17 Phase 4. Not tested here |
| AC-V7 | Every /v2/ route resolving through FindByName answers 404 NAME_UNKNOWN |
S17 Phase 4. Not tested here |
| AC-V8 | Blob mount answers 202 when every source image is tombstoned |
S17 Phase 4. Not tested here |
| AC-V9 | A mount whose from= names a marked image still answers 201 |
S17 Phase 4. Not tested here |
| AC-V10 | A mount into a tombstoned destination name answers 201 |
S17 Phase 4. Not tested here |
| AC-V11 | UpsertContainerImage does not resurrect a tombstoned image |
S17 Phase 4. Not tested here |
| AC-V12 | ContainerImageStore.Delete still acts on a tombstoned row |
S17 Phase 4. Not tested here |
Accounting call sites
| # | Criterion | Tests |
|---|---|---|
| AC-A1 | The destructive repository DELETE emits no counter delta at the tombstone write |
Step 19. Not tested here |
| AC-A2 | Δsize and Δcomponents fire at the purger, delta set per level |
Step 19 for the emit. This step pins the reaper's inputs to it: Components is 1 per reaped npm_remote_versions row and 0 for a package reap (TestNpmRemoteVersionReaper_Reap_RemovesTheVersionSubtree, TestNpmRemotePackageReaper_Reap_RemovesTheMarkedPackageAndItsCache), and SizeBytes is zero (see Resolved spec ambiguities) |
| AC-A3 | Every emit site commits its source rows before issuing the delta | Step 19. The reaper-side half, that a rolled-back chunk moves nothing, is TestNpmRemoteVersionReaper_Reap_RunsEveryStatementOnTheCallersHandle |
| AC-A4 | A purge chunk emits from its own committed affected-row aggregate | Step 19. This step pins that ReapTotals counts the chunk's own affected rows: a repeat chunk reports ReapTotals{} (TestNpmRemoteVersionReaper_Reap_IsIdempotent, TestNpmRemotePackageReaper_Reap_IsIdempotent) and the version is counted exactly once across chunks (TestNpmRemoteVersionReaper_Reap_BoundsEachChunk) |
| AC-A5 | A raw foreign-key cascade emits nothing and is not used | Steps 12 and 13. Not tested here; no npm remote foreign key carries an ON DELETE action, which the stall case exercises directly |
Schema and configuration
| # | Criterion | Tests |
|---|---|---|
| AC-C1 | The repositories and npm_packages discovery index migrations apply and roll back cleanly |
Step 1. Not tested here |
| AC-C2 | No migration adds or drops a column, no CHECK pins soft_deleted_at to NULL |
Step 1. Not tested here |
| AC-C3 | unique_container_images_ns_id_cr_id_name is partial on every partition |
S17 Phase 4. Not tested here |
| AC-C4 | Config load accepts, defaults and rejects the lifecycle window and interval | Step 2. Not tested here |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | DELETE omits destructive: 400 |
Step 15. Not tested here |
| E-2 | destructive carries a non-boolean: 400 |
Step 15. Not tested here |
| E-3 | destructive=false on a non-empty repository: 409 with the right message |
Step 15. Not tested here |
| E-4 | An artifact published between the DELETE and its DELETE FROM repositories |
Step 15. Not tested here |
| E-5 | DELETE on a missing or already-tombstoned repository: 404 |
Step 15. Not tested here |
| E-6 | Create at a tombstoned repository's name: 409 |
Step 15. Not tested here |
| E-7 | Any client route naming a tombstoned repository: 404 in its own vocabulary |
Steps 3, 4 and 15. Not tested here |
| E-8 | Blob mount whose from= names a tombstoned repository: 202 |
S17 Phase 4. Not tested here |
| E-9 | Purge job: 23503 on a parent delete aborts the transaction and fails the job; not mapped to ErrRepositoryNotEmpty |
TestNpmRemotePackageReaper_Reap_StallsWhileAVersionSurvives, which asserts the stall reaches the caller as ErrReapParentPinned, that the chunk rolled back whole (package row, every cache row and every attachment back), and that the same call converges once the version is reaped |
| E-10 | Purge job: ContainerManifestDeleter refuses a still-referenced manifest |
Step 13. Not tested here |
| E-11 | Purge job: the finalizer aborts because an artifact remains | Step 14. Not tested here |
| E-12 | Purge job: the target is already hard-deleted; the walk finds nothing and reports success | TestNpmRemoteVersionReaper_Reap_IsIdempotent, TestNpmRemotePackageReaper_Reap_IsIdempotent (reaper half; the job's own success reporting is Step 16) |
| E-13 | Purge job: the retention window changed between enqueue and run | Steps 5 and 16. Not tested here |
| E-14 | Sweep: the enumeration query fails and River retries | Step 18. Not tested here |
| E-15 | Sweep: the interval elapses before enumeration completes | Step 18. Not tested here |
| E-16 | Emit API unavailable at a purge or tombstone site | Step 19. Not tested here |
| E-17 | Parent gate: parent tombstoned between the gate and the child INSERT |
Step 4. Not tested here |
| E-18 | A tombstoned artifact's coordinate is reoccupied before the reap; both rows exist and the purger keys on the tombstone | TestNpmRemoteVersionReaper_Reap_LeavesTheRefilledRowUntouched, which pins that unique_npm_remote_versions_ns_id_pkg_id_version admits the pair and that the reap takes only the marked row |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Authorization is unchanged; the purger performs no authorization decision of its own | Not asserted here and nothing to assert: neither reaper takes a caller identity, and its whole input is a TombstoneRow and a limit. The job's own absence of a request context is Step 16 |
| S-2 | destructive is a confirmation, not an authorization control |
Step 15. Not tested here |
| S-3 | Deletion is irreversible in closed beta and the API says so | Step 15 (OpenAPI). Not tested here |
| S-4 | Input validation: no purger input is user-controlled; the purge job carries UUID identifiers | TestNpmRemoteReapers_Reap_Guards pins that a zero-UUID namespace or row id is rejected before any statement reaches the handle, and that a well-formed call is admitted. The reap takes no name, version string or digest at all |
| S-5 | No injection surface is added; all purger SQL is jet-built or parameterized | Not asserted directly. The suites constrain behavior, not statement construction; the one raw statement this step drives is deleteUnreferencedNpmAttachmentStmt, already fully parameterized and covered in Step 8 |
| S-6 | Error messages must not become an existence oracle | Step 15. Not tested here; the reap answers no caller |
| S-7 | Purge failures are operationally visible; a purge that cannot complete must never report success | TestNpmRemotePackageReaper_Reap_StallsWhileAVersionSurvives asserts the stall surfaces as an error rather than a silent skip. The gauge that makes it visible is Step 17b |
e2e scenario catalogs
No scenario is added, and none is affected.
The step type settles it.
CLAUDE.md's Revisit the e2e scenario catalogs before marking behavior-changing work complete guardrail binds feat and fix work, and step 10 is chore.
The diff adds no route, no handler and no request path, so no catalog scenario changes behavior.
docs/testing/e2e/ holds README.md, docker.md, maven.md, npm.md and oci.md, and this merge request touches none of them.
One correction belongs here, because a reader who opens the plan finds the opposite claim.
The plan's e2e scenario impact paragraph says that no npm or Maven catalog exists.
That sentence was true when the plan merged and went stale a day later, when 571b4e55e added docs/testing/e2e/npm.md and docs/testing/e2e/maven.md.
Step 10 did not falsify it, and CLAUDE.md's Step MRs do not edit the plan file, the Status table included rule leaves a docs(plans) merge request as the only destination for the correction.
Size
CLAUDE.md's Justify a diff past 500 reviewable LOC in the MR description guardrail applies.
The diff against the merge base is 1,966 insertions and 20 deletions over eight files.
| File group | Paths | Insertions | Deletions |
|---|---|---|---|
| Non-test Go | internal/datastore/lifecycle_reap_npm_remote.go (509), internal/datastore/lifecycle_reap_npm.go (14), internal/datastore/blob_storage_attachments.go (10), internal/datastore/npm_remote_eviction.go (4), internal/datastore/query_names.go (4) |
541 | 18 |
| Test Go | internal/datastore/lifecycle_reap_npm_remote_integration_test.go (1,266), internal/datastore/lifecycle_reap_npm_remote_test.go (156) |
1,422 | 1 |
| Spec | docs/specs/S20-a-lifecycle-closed-beta.md |
3 | 1 |
Review round two added 279 of those insertions: two regression pins the hosted suite carries and this one had dropped, TestNpmRemoteReapers_StatementOrder and TestNpmRemotePackageReaper_Reap_IsStateBlindOnTheRowItIsHanded, with the statement-audit rig the first one needs, plus comment corrections in five paragraphs.
The plan forecasts about 200 source lines and about 520 test lines for step 10.
Both halves run at more than twice their forecast.
In absolute lines the test half carries about seven tenths of the overrun, 658 lines against 306.
The total also passes the ~900 working ceiling the plan adopts for this workstream.
The plan's Size forecast and the working ceiling section states that ceiling and asks a reviewer to read these steps against it.
Splitting does not help, and the plan already made the split its own seam analysis found.
Its Seam interrogation outcomes section records that one reap step per format kind forecast 1200 to 1600 lines each.
It records the reason for the smaller unit as well: hosted and remote share no table and no store.
The outcome was six reap steps over the seam step 5 extracted, and this merge request is one of the six.
The two reapers inside it do not separate either. They compose the same helper over two tables, and one argument-guard table covers both. The package reap's foreign-key stall case also needs a surviving version under a marked package. Separating the tests from the code breaks the test-first authorship order and lands production code with no cover.
Merge order
!1705 (merged), step 8
While !1705 (merged) was open, this merge request targeted prozlach/s20a-lifecycle-closed-beta-step-8 rather than main.
!1705 (merged) merged as d6a7a30b0, GitLab deleted its branch, and GitLab retargeted this merge request to main.
The branch carried !1705 (merged)'s pre-squash commits underneath its own, and a plain rebase onto main replays those commits against their own squash.
The rebase used git rebase --onto main 4c2e9c631 instead, where 4c2e9c631 is the pre-squash step 8 tip of this branch, so only the step 10 commits replayed.
One check came first: 4c2e9c631 and d6a7a30b0 have the same tree object, so main carries step 8 byte for byte.
The rebase changed no file content, and every replayed commit kept its original tree object.
Five symbols this step names came from step 8, and main now carries all five: npmAttachmentHolder, npmAttachmentRef, deleteNpmAttachmentHolderBatch, BlobStorageAttachmentStore.releaseNpmAttachments and BlobStorageAttachmentStore.DeleteIfUnreferencedByNpm.
The first four are compiled references, in the reap file and in its integration suite.
DeleteIfUnreferencedByNpm is the guard behind the release, and this step names it in doc comments only.
The required order was !1705 (merged) first, then this merge request, and the merge order followed it.
If this merge request retargets to main before !1705 (merged) merges, main gains code that names symbols it does not carry.
Two step 8 files have a second author
internal/datastore/lifecycle_reap_npm.go and internal/datastore/blob_storage_attachments.go both belong to step 8, which merged as !1705 (merged).
This merge request carries two comment-only hunks in the first and one in the second, under an override the operator granted for those hunks.
The override was granted once for a single hunk and extended in review to cover the rest; it is not a standing licence to edit step 8 files.
Each hunk corrects a claim that step 10's two new callers make stale, and none of them changes a statement.
blob_storage_attachments.go also takes additive edits from step 12 in !1704 (merged), whose only hunk there ends well above the paragraph corrected here, so the two merge in any order.
!1702 (merged), step 9
The Reaper interface doc on main, in internal/datastore/lifecycle_scan.go, reads At most limit rows are deleted per call.
Both reapers here document up to 2*limit, because Rows counts a freed attachment and the budget does not.
The same wording stands in step 8's lifecycle_reap_npm.go, so this step adds no new deviation.
!1702 (merged) rewrites that interface doc, its bound included.
If !1702 (merged) merges first, the contract text is its to state.
If this merge request merges first, main keeps the current wording.
Nothing in this diff changes on either order.
!1740 (merged), spec correction
!1740 (merged), on prozlach/s20a-spec-remote-tombstone-discovery, edits the same region of docs/specs/S20-a-lifecycle-closed-beta.md.
It corrects the same table-header defect by a different route, in the lines that follow the table.
It also rewrites both sections that the new spec sentence in this merge request links to.
git merge-tree reports no conflict, and the two hunks do not overlap.
If !1740 (merged) merges first, the spec then carries the renamed header and a paragraph that states why the old header was wrong.
The result is redundant, not contradictory.
The sentence this merge request adds is count-free, so it stays correct in either merge order.
This note records that redundancy, and it names no required merge order.
Plan Status row 10
This merge request does not fill it, and the branch touches no plan file.
CLAUDE.md's Step MRs do not edit the plan file, the Status table included rule routes the reference to a batch or standing docs(plans) merge request instead.
That same rule defines an empty cell as not yet recorded rather than not started.
Files, against the plan's list
The plan's Files: entry for step 10 names two paths, and the branch carries eight.
Every path is listed here in the shape the plan uses for step 2.
A reviewer who diffs the change set against the plan can then tell an authorized addition from scope creep.
internal/datastore/lifecycle_reap_npm_remote.go(Create). Named by the plan.internal/datastore/lifecycle_reap_npm_remote_integration_test.go(Create). Named by the plan.internal/datastore/lifecycle_reap_npm_remote_test.go(Create). The argument-guard suite. Step 8 shipped the same companion,lifecycle_reap_npm_test.go, against aTests:entry that also named only its integration file. The plan'sTesting Strategysection names<x>_test.goas one leg of this package's three-way split.internal/datastore/query_names.go(Modify, 4 lines). Four query-name constants, appended to the end of their existingnpm_remote_*groups, which is the discipline the plan'sSibling arms in shared filessection prescribes.internal/datastore/queries_test.gorequires one constant per instrumented statement, each used at exactly one call site, so the four new statements cannot borrow step 8's names.internal/datastore/lifecycle_reap_npm.go(Modify, comments only). The count claims described under Corrections carried below, plus the lock-order clause ondeleteNpmAttachmentHolderBatch, under the override the operator granted for those hunks.internal/datastore/blob_storage_attachments.go(Modify, comments only). The bound paragraph onreleaseNpmAttachmentsnamed two of its five callers and one of the two package-arm indexes. Step 10 adds two of the callers it omits, so the correction lands here under the same override. Step 12's arm in !1704 (merged) is additive and sits well above this paragraph.internal/datastore/npm_remote_eviction.go(Modify, comments only). This is a review correction, and it is the seventh path in the change set. The doc comment onNpmRemoteEvictor.EvictNpmRemotePackagesaid that the package's versions, files andnpm_remote_metadata_filesrows stay live for the purger's subtree reap.NpmRemotePackageReaper, which this merge request adds, takes thenpm_remote_metadata_filesrows with the package row and takes no version. It aborts with SQLSTATE23503while one version survives. The two doc comments therefore stated opposite contracts, and this hunk corrects the evictor's half. No open merge request touches this file.docs/specs/S20-a-lifecycle-closed-beta.md(Modify, 3 insertions and 1 deletion). The header of the tableWhat a tombstone scan cannot reach, plus two prose lines above it. The old header readReachable by tombstone scan, which is not what the column enumerates. A table is scan-reachable only when it carriessoft_deleted_atand a tombstone-discovery index. The column lists the tables that carry the column, whether or not an index reaches them. The new header states that column-presence predicate, and the two prose lines name the difference. Those lines point atinternal/datastore/migrations/structure.sqlfor the discovery indexes, and at the inventory for the column list only. No row cell moved, and no format's reap routing is settled here.
Corrections carried
Step 10 adds two callers to deleteNpmAttachmentHolderBatch, so two count claims in internal/datastore/lifecycle_reap_npm.go stop being true.
Both are now count-free rather than raised from two to four.
the two legs write two tables becomes each leg writes its own table, and the two bounded reap legs becomes every caller of deleteNpmAttachmentHolderBatch.
A count-free claim stays true for step 11 and for every format after it, so it ends the class of defect rather than resetting it.
This merge request also corrects a conflation it did not introduce.
The three tables count in internal/datastore/lifecycle_reap_npm.go tracked statements, not tables, and a76fe4918 wrote it.
A bump from three to four makes the sentence accidentally right as a table count and leaves the statement arithmetic wrong.
The go-jet reason needs no count at all, so the sentence now reads more than one table.
ADR-007 divergence, tracked in #739
Two facts this step's code states disagree with ADR-007 as published. Neither divergence is introduced here, and in both the code states the schema the tree ships.
- ADR-007 defines an
npm_remote_tagstable. S15 dropped it.structure.sqlhas no such table, andinternal/datastore/migrations/npm_remote_schema_integration_test.gopins the drop. The version reaper's doc gives that as the reason it carries no tag leg. - ADR-007 gives
npm_remote_metadata_files.kindtwo values. The shippedcheck_npm_remote_metadata_files_kindadmits three. The package reaper'slimit + 3bound rests on three, and the integration suite covers all three kinds as positive hits.
docs/specs/S15-npm-remote.md already lists both, in its own words as divergences pending an ADR-007 amendment.
CLAUDE.md's Do not edit files in docs/adr/ directly guardrail means nothing inside this repository closes either one: the amendment goes to the handbook.
Both divergences are now tracked in #739, with the sites, the evidence, and the size of the amendment. That issue is where an amendment gets written; this merge request needs no change either way, and the code above is correct under the schema the tree ships.
Obligations recorded for plan step 15
The three obligations in this section have no change available in this merge request.
Each one binds the step that wires the chunk driver, which is plan step 15.
They are recorded here because merged policy forbids a step merge request from editing the plan file.
CLAUDE.md states that rule as Step MRs do not edit the plan file, the Status table included, and a docs(plans) merge request is the destination that carries them into the plan.
- The reap metrics belong to the driver.
The chunk driver owns the rows-deleted signal, the per-component signal and the per-format error signal.
metrics.InstrumentQuerycarries no error dimension today. A chunk that fails and a chunk that succeeds therefore produce the same series. The driver also needs annpm_remote_packageslevel, which is what makes a stalled package reap visible. - One deadlock note expires when the driver lands.
internal/datastore/npm_remote_metadata_files.gostates that only one resource is locked today, so nothing can deadlock yet. It adds that a flow that needs both resources takes the package first. That first claim reads false once both reapers have a caller. The file is outside this change set, so the correction belongs to the step that adds the caller. - The driver's page belongs far below the ceiling.
validateNpmRemoteReapArgsrefuses a limit aboveMaxLifecycleReapPageSize, so the high end is bounded rather than left to the caller. That constant is a refusal boundary and not a suggested page size.releaseNpmAttachmentsissues oneDeleteIfUnreferencedByNpmper attachment freed, anddeleteNpmAttachmentHolderBatchrendersLIMIT(int64(budget)). Both the row count and the statement count in one transaction still scale with what the driver passes, up to that ceiling. The spec's chosen option names the chunk bound as the mitigation for the accepted lock-order inversion. The driver's own page size is therefore what that design rests on.
Related issue
This merge request carries no Related to line, and the step type is the reason.
Step 10 is chore, which CLAUDE.md's Link a related issue on every feature MR guardrail exempts alongside the other maintenance types.
Work item #507 is not the link.
It covers the hosted npm whole-package unpublish, in npm_metadata_files and npm_package_unpublish_deleter.go.
That is step 8's subject, and !1705 (merged) carries the link to it.
This step reaps the npm remote cache instead.
Database Review Evidence
Note
Collected with the db-review-prep skill against 9ea6229af, which was the merge base on prozlach/s20a-lifecycle-closed-beta-step-8 while this merge request targeted that branch.
The evidence predates the rebase onto main, and nothing in the rebase or in the later guard commit changes the four statements it covers.
Two of the four are built by a step 8 helper, and each block states its own provenance.
Migration mode did not run: the change set adds no file under internal/datastore/migrations/sql/ and no edit to internal/datastore/migrations/structure.sql, against the step 8 base and against main.
Queries
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.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.NpmRemoteVersionReaper.Reap |
Delete → Index Scan | npm_remote_versions_pNN_pkey |
1 / 1 | 8.30 | 6.334ms | 11 / 0 | 1/64 |
datastore.NpmRemotePackageReaper.Reap |
Delete → Index Scan | npm_remote_packages_pNN_pkey |
1 / 1 | 8.30 | 11.256ms | 17 / 0 | 1/64 |
datastore.npmRemoteVersionFilesHolder.deleteNpmAttachmentHolderBatch |
Delete → Hash Semi Join | npm_remote_files_pNN_namespace_id_npm_remote_version_id_idx |
100 / 100 | 224.82 | 5.698ms | 296 / 0 | 1/64 |
datastore.npmRemotePackageMetadataFilesHolder.deleteNpmAttachmentHolderBatch |
Delete → Nested Loop | npm_remote_metadata_files_pNN_namespace_id_npm_remote_packa_idx |
3 / 3 | 36.85 | 0.778ms | 20 / 0 | 1/64 |
Three readings of that table need a word each.
- The index names carry
pNNhere and a number in the raw plans. Every seed transaction generates a freshnamespace_id, so the hash routes each run to a different partition of the same 64. The index is the partition's copy of the parent index,pk_npm_remote_versions,pk_npm_remote_packages,index_npm_remote_files_on_ns_id_ver_id, andunique_npm_remote_metadata_files_ns_id_pkg_id_kind. - Rows for the first two statements come from the driving
Index Scan. ADELETEwith noRETURNINGreportsrows=0at its root node, so the root count says nothing about what matched. The two holder legs do carryRETURNING, and their root counts are the ones in the table. - Time is the plan's
Execution Timefooter. For the first two statements that number is dominated by a one-off referential-integrity plan compilation, which the first query note covers.
Every one of the four tables is hash-partitioned on namespace_id with a modulus of 64, and every statement binds namespace_id, so all four prune to one partition.
No statement in this change set shows partition fan-out.
Query notes:
datastore.npmRemoteVersionFilesHolder.deleteNpmAttachmentHolderBatch:Seq Scanover the 5000-row partition, under aHash Semi Joinagainst the page of 100 ids. This is a cost crossover at the skill's seed size and not a missing index. A control run with 100,000 rows in the same partition, seeded identically in every other respect, moves the planner toNested Loopovernpm_remote_files_pNN_pkey, and execution decreases from 5.698ms to 0.717ms. Both plans are in the details block below. The inner page select usesindex_npm_remote_files_on_ns_id_ver_idat both sizes.datastore.NpmRemoteVersionReaper.Reapanddatastore.NpmRemotePackageReaper.Reap: each parent delete pays a referential-integrity check against every child table that names it, and each check plans across that child's 64 partitions. The version delete fires one check, onnpm_remote_files. The package delete fires two, onnpm_remote_versionsandnpm_remote_metadata_files. The first call in a backend pays the plan compilation: 5.904ms for the version delete and 6.757ms plus 4.122ms for the package delete, all against empty child sets. The plan is then cached per backend, and repeat calls measured in the same transaction cost 0.401ms and 0.231ms. A chunk driver that reaps many subtrees on one pooled connection pays this once, not once per row.- No statement returns rows whose count grows with the table.
The two parent deletes match at most one row on a primary key.
Both holder legs are bounded by the caller's
LIMIT $4, whichdeleteNpmAttachmentHolderBatchbinds from the reap's remaining budget, and both reapers reject a limit below one before any statement runs.
Two schema facts the reap logic rests on were read back from the migrated container rather than taken from the code.
npm_remote_metadata_filescarries nosoft_deleted_atcolumn.index_npm_remote_versions_on_ns_id_soft_deleted_atis the npm remote family's onlyWHERE soft_deleted_at IS NOT NULLindex. Walking the parent package is therefore the only route to the cache rows, which is what the package reaper does.check_npm_remote_metadata_files_kindadmits0,1, and2, andunique_npm_remote_metadata_files_ns_id_pkg_id_kindadmits one row per kind. Three rows per package is the ceiling, so thelimit + 3bound inNpmRemotePackageReaper.Reap's doc holds.
datastore.NpmRemoteVersionReaper.Reap
Summary: The plan matches the method's intent.
The bound namespace_id prunes to one of 64 partitions and the composite primary key answers the row lookup, with estimate and actual both at one row.
No anomalies in the statement itself.
The Trigger for constraint line is the foreign key from npm_remote_files, and the second query note above gives its cost profile.
Seed shape: namespaces=1, repositories=1, npm_remote_repositories=1, npm_remote_packages=1, npm_remote_versions=5000
Rendered SQL:
DELETE FROM public.npm_remote_versions
WHERE (npm_remote_versions.namespace_id = $1::uuid) AND (npm_remote_versions.id = $2::uuid);Bound args: [123f0aa6-5844-48bd-9181-d04f10ee424a, db20e6da-bfe6-4e33-93cc-3f74847b0500]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Delete on npm_remote_versions (cost=0.28..8.30 rows=0 width=0) (actual time=0.096..0.097 rows=0 loops=1)
Delete on npm_remote_versions_p13 npm_remote_versions_1
Buffers: shared hit=11
-> Index Scan using npm_remote_versions_p13_pkey on npm_remote_versions_p13 npm_remote_versions_1 (cost=0.28..8.30 rows=1 width=10) (actual time=0.025..0.026 rows=1 loops=1)
Index Cond: ((id = 'db20e6da-bfe6-4e33-93cc-3f74847b0500'::uuid) AND (namespace_id = '123f0aa6-5844-48bd-9181-d04f10ee424a'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=190
Planning Time: 1.444 ms
Trigger for constraint npm_remote_files_npm_remote_version_id_namespace_id_fkey13 on npm_remote_versions_p13: time=5.904 calls=1
Execution Time: 6.334 msRepeat-call control (same transaction, three tombstoned versions deleted in sequence, showing the referential-integrity plan cache):
--- call 1 ---
Planning Time: 1.535 ms
Trigger for constraint npm_remote_files_npm_remote_version_id_namespace_id_fkey49 on npm_remote_versions_p49: time=8.170 calls=1
Execution Time: 8.652 ms
--- call 2 ---
Planning Time: 0.207 ms
Trigger for constraint npm_remote_files_npm_remote_version_id_namespace_id_fkey49 on npm_remote_versions_p49: time=0.401 calls=1
Execution Time: 0.490 ms
--- call 3 ---
Planning Time: 0.127 ms
Trigger for constraint npm_remote_files_npm_remote_version_id_namespace_id_fkey49 on npm_remote_versions_p49: time=0.231 calls=1
Execution Time: 0.283 msTimings: planning 1.444ms, execution 6.334ms, total 7.778ms.
datastore.NpmRemotePackageReaper.Reap
Summary: The plan matches the method's intent, and it is the same shape as the version delete.
The bound namespace_id prunes to one of 64 partitions and the composite primary key answers the row lookup.
The two Trigger for constraint lines are the foreign keys from npm_remote_versions and npm_remote_metadata_files, both empty for this seed.
Those two keys are what turns a package delete run before its children are gone into SQLSTATE 23503, which the method's doc describes as the stall.
Seed shape: namespaces=1, repositories=1, npm_remote_repositories=1, npm_remote_packages=5000
Rendered SQL:
DELETE FROM public.npm_remote_packages
WHERE (npm_remote_packages.namespace_id = $1::uuid) AND (npm_remote_packages.id = $2::uuid);Bound args: [d220f6b4-bd4f-4116-a2d8-2f74e1f24239, a4a33138-df29-4407-b068-05c85ea9291b]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Delete on npm_remote_packages (cost=0.28..8.30 rows=0 width=0) (actual time=0.109..0.109 rows=0 loops=1)
Delete on npm_remote_packages_p60 npm_remote_packages_1
Buffers: shared hit=17
-> Index Scan using npm_remote_packages_p60_pkey on npm_remote_packages_p60 npm_remote_packages_1 (cost=0.28..8.30 rows=1 width=10) (actual time=0.017..0.018 rows=1 loops=1)
Index Cond: ((id = 'a4a33138-df29-4407-b068-05c85ea9291b'::uuid) AND (namespace_id = 'd220f6b4-bd4f-4116-a2d8-2f74e1f24239'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=116
Planning Time: 0.918 ms
Trigger for constraint npm_remote_versions_npm_remote_package_id_namespace_id_fkey60 on npm_remote_packages_p60: time=6.757 calls=1
Trigger for constraint npm_remote_metadata_files_npm_remote_package_id_namespac_fkey60 on npm_remote_packages_p60: time=4.122 calls=1
Execution Time: 11.256 msTimings: planning 0.918ms, execution 11.256ms, total 12.174ms.
datastore.npmRemoteVersionFilesHolder.deleteNpmAttachmentHolderBatch
Summary: The inner page select does what the method intends at both seed sizes.
It prunes to one partition and reads exactly LIMIT $4 ids from index_npm_remote_files_on_ns_id_ver_id, and the target version holds 200 files against a budget of 100, so the page bound is what stops it.
The outer delete's join method moves with table size, and the 5000-row plan's Seq Scan is the planner picking a cheaper path over a small partition rather than a missing index.
The 100,000-row control below settles that: the same statement moves to Nested Loop over the primary key and execution decreases from 5.698ms to 0.717ms.
The statement is built by deleteNpmAttachmentHolderBatch, which lands in !1705 (merged).
The holder it is built from, npmRemoteVersionFilesHolder, and its query name, npm_remote_files_delete_reap_batch, land here.
Seed shape (primary): namespaces=1, repositories=1, npm_remote_repositories=1, npm_remote_packages=1, blob_storage_blobs=1, blob_storage_attachments=1, npm_remote_versions=97, npm_remote_files=5000 (200 under the reaped version, 4800 under 96 live siblings)
Seed shape (control): the same, with npm_remote_versions=1997 and npm_remote_files=100000 (200 under the reaped version, 99,800 under 1996 live siblings)
Rendered SQL:
DELETE FROM public.npm_remote_files
WHERE (npm_remote_files.namespace_id = $1::uuid) AND (npm_remote_files.id IN ((
SELECT npm_remote_files.id AS "npm_remote_files.id"
FROM public.npm_remote_files
WHERE (npm_remote_files.namespace_id = $2::uuid) AND (npm_remote_files.npm_remote_version_id = $3::uuid)
LIMIT $4
)))
RETURNING npm_remote_files.blob_storage_attachment_id AS "npm_reaped_attachment.attachment_id",
npm_remote_files.blob_sha256 AS "npm_reaped_attachment.sha256";Bound args: [0e02b473-b2c9-45f5-b260-27c33ff4522a, 0e02b473-b2c9-45f5-b260-27c33ff4522a, 7e6348b5-42b7-4440-bee1-283c99a4008b, 100]
Plan (EXPLAIN (ANALYZE, BUFFERS) output, 5000 rows in the partition):
Delete on npm_remote_files (cost=56.08..224.82 rows=100 width=50) (actual time=0.893..2.953 rows=100 loops=1)
Delete on npm_remote_files_p20 npm_remote_files_1
Buffers: shared hit=296
-> Hash Semi Join (cost=56.08..224.82 rows=100 width=50) (actual time=0.380..2.309 rows=100 loops=1)
Hash Cond: (npm_remote_files_1.id = "ANY_subquery"."npm_remote_files.id")
Buffers: shared hit=96
-> Seq Scan on npm_remote_files_p20 npm_remote_files_1 (cost=0.00..154.50 rows=5000 width=26) (actual time=0.025..1.512 rows=5000 loops=1)
Filter: (namespace_id = '0e02b473-b2c9-45f5-b260-27c33ff4522a'::uuid)
Buffers: shared hit=92
-> Hash (cost=54.83..54.83 rows=100 width=56) (actual time=0.342..0.345 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 17kB
Buffers: shared hit=4
-> Subquery Scan on "ANY_subquery" (cost=6.33..54.83 rows=100 width=56) (actual time=0.084..0.292 rows=100 loops=1)
Buffers: shared hit=4
-> Limit (cost=6.33..53.83 rows=100 width=16) (actual time=0.072..0.250 rows=100 loops=1)
Buffers: shared hit=4
-> Bitmap Heap Scan on npm_remote_files_p20 npm_remote_files_2 (cost=6.33..101.33 rows=200 width=16) (actual time=0.070..0.084 rows=100 loops=1)
Recheck Cond: ((namespace_id = '0e02b473-b2c9-45f5-b260-27c33ff4522a'::uuid) AND (npm_remote_version_id = '7e6348b5-42b7-4440-bee1-283c99a4008b'::uuid))
Heap Blocks: exact=2
Buffers: shared hit=4
-> Bitmap Index Scan on npm_remote_files_p20_namespace_id_npm_remote_version_id_idx (cost=0.00..6.28 rows=200 width=0) (actual time=0.062..0.063 rows=200 loops=1)
Index Cond: ((namespace_id = '0e02b473-b2c9-45f5-b260-27c33ff4522a'::uuid) AND (npm_remote_version_id = '7e6348b5-42b7-4440-bee1-283c99a4008b'::uuid))
Buffers: shared hit=2
Planning:
Buffers: shared hit=49 read=1
Planning Time: 3.148 ms
Execution Time: 5.698 msControl plan (EXPLAIN (ANALYZE, BUFFERS) output, 100,000 rows in the partition):
Delete on npm_remote_files (cost=175.65..988.98 rows=100 width=50) (actual time=0.102..0.394 rows=100 loops=1)
Delete on npm_remote_files_p04 npm_remote_files_1
Buffers: shared hit=604
-> Nested Loop (cost=175.65..988.98 rows=100 width=50) (actual time=0.090..0.341 rows=100 loops=1)
Buffers: shared hit=404
-> HashAggregate (cost=175.23..176.23 rows=100 width=56) (actual time=0.071..0.079 rows=100 loops=1)
Group Key: "ANY_subquery"."npm_remote_files.id"
Batches: 1 Memory Usage: 32kB
Buffers: shared hit=4
-> Subquery Scan on "ANY_subquery" (cost=0.29..174.98 rows=100 width=56) (actual time=0.026..0.049 rows=100 loops=1)
Buffers: shared hit=4
-> Limit (cost=0.29..173.98 rows=100 width=16) (actual time=0.016..0.030 rows=100 loops=1)
Buffers: shared hit=4
-> Index Scan using npm_remote_files_p04_namespace_id_npm_remote_version_id_idx on npm_remote_files_p04 npm_remote_files_2 (cost=0.29..318.14 rows=183 width=16) (actual time=0.015..0.025 rows=100 loops=1)
Index Cond: ((namespace_id = '11c664ee-5b98-412a-a050-fd7e7e3be254'::uuid) AND (npm_remote_version_id = '6dd7966f-398d-4a15-b07f-4c7b5d393cd4'::uuid))
Buffers: shared hit=4
-> Index Scan using npm_remote_files_p04_pkey on npm_remote_files_p04 npm_remote_files_1 (cost=0.42..8.12 rows=1 width=26) (actual time=0.002..0.002 rows=1 loops=100)
Index Cond: ((id = "ANY_subquery"."npm_remote_files.id") AND (namespace_id = '11c664ee-5b98-412a-a050-fd7e7e3be254'::uuid))
Buffers: shared hit=400
Planning:
Buffers: shared hit=50
Planning Time: 0.687 ms
Execution Time: 0.717 msTimings: planning 3.148ms, execution 5.698ms, total 8.846ms. Control: planning 0.687ms, execution 0.717ms, total 1.404ms.
datastore.npmRemotePackageMetadataFilesHolder.deleteNpmAttachmentHolderBatch
Summary: The plan matches the method's intent.
The inner page select prunes to one partition and reads the package's three cache rows from the (namespace_id, npm_remote_package_id, kind) prefix of unique_npm_remote_metadata_files_ns_id_pkg_id_kind, and the outer delete drives a Nested Loop over the primary key.
The page never fills here: the unique index caps a package at three cache rows, which is below any budget the guard admits.
That is why this leg keeps the index path at 5000 rows where the files leg does not.
The statement is built by deleteNpmAttachmentHolderBatch, which lands in !1705 (merged).
The holder it is built from, npmRemotePackageMetadataFilesHolder, and its query name, npm_remote_metadata_files_delete_reap_batch, land here.
Seed shape: namespaces=1, repositories=1, npm_remote_repositories=1, blob_storage_blobs=1, blob_storage_attachments=1, npm_remote_packages=1666, npm_remote_metadata_files=4998 (3 under the reaped package, 4995 under 1665 live siblings)
Rendered SQL:
DELETE FROM public.npm_remote_metadata_files
WHERE (npm_remote_metadata_files.namespace_id = $1::uuid) AND (npm_remote_metadata_files.id IN ((
SELECT npm_remote_metadata_files.id AS "npm_remote_metadata_files.id"
FROM public.npm_remote_metadata_files
WHERE (npm_remote_metadata_files.namespace_id = $2::uuid) AND (npm_remote_metadata_files.npm_remote_package_id = $3::uuid)
LIMIT $4
)))
RETURNING npm_remote_metadata_files.blob_storage_attachment_id AS "npm_reaped_attachment.attachment_id",
npm_remote_metadata_files.blob_sha256 AS "npm_reaped_attachment.sha256";Bound args: [eabf33c6-cecb-4874-aea7-2560ab430fe0, eabf33c6-cecb-4874-aea7-2560ab430fe0, 4eeeb29f-a064-46af-a2db-f1d21b6452d5, 100]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Delete on npm_remote_metadata_files (cost=12.16..36.85 rows=3 width=50) (actual time=0.101..0.145 rows=3 loops=1)
Delete on npm_remote_metadata_files_p58 npm_remote_metadata_files_1
Buffers: shared hit=20
-> Nested Loop (cost=12.16..36.85 rows=3 width=50) (actual time=0.082..0.116 rows=3 loops=1)
Buffers: shared hit=14
-> HashAggregate (cost=11.88..11.91 rows=3 width=56) (actual time=0.053..0.055 rows=3 loops=1)
Group Key: "ANY_subquery"."npm_remote_metadata_files.id"
Batches: 1 Memory Usage: 24kB
Buffers: shared hit=5
-> Subquery Scan on "ANY_subquery" (cost=0.28..11.87 rows=3 width=56) (actual time=0.042..0.048 rows=3 loops=1)
Buffers: shared hit=5
-> Limit (cost=0.28..11.84 rows=3 width=16) (actual time=0.031..0.036 rows=3 loops=1)
Buffers: shared hit=5
-> Index Scan using npm_remote_metadata_files_p58_namespace_id_npm_remote_packa_idx on npm_remote_metadata_files_p58 npm_remote_metadata_files_2 (cost=0.28..11.84 rows=3 width=16) (actual time=0.030..0.034 rows=3 loops=1)
Index Cond: ((namespace_id = 'eabf33c6-cecb-4874-aea7-2560ab430fe0'::uuid) AND (npm_remote_package_id = '4eeeb29f-a064-46af-a2db-f1d21b6452d5'::uuid))
Buffers: shared hit=5
-> Index Scan using npm_remote_metadata_files_p58_pkey on npm_remote_metadata_files_p58 npm_remote_metadata_files_1 (cost=0.28..8.30 rows=1 width=26) (actual time=0.018..0.018 rows=1 loops=3)
Index Cond: ((id = "ANY_subquery"."npm_remote_metadata_files.id") AND (namespace_id = 'eabf33c6-cecb-4874-aea7-2560ab430fe0'::uuid))
Buffers: shared hit=9
Planning:
Buffers: shared hit=52
Planning Time: 1.695 ms
Execution Time: 0.778 msTimings: planning 1.695ms, execution 0.778ms, total 2.473ms.
What this evidence does not cover
BlobStorageAttachmentStore.releaseNpmAttachments and the DeleteIfUnreferencedByNpm statement behind it run in the same transaction and the same chunk as the four statements above.
Both land in !1705 (merged), and neither is in this change set, so neither is planned here.
A reviewer reading the chunk end to end needs !1705 (merged)'s own evidence for that leg.
Review decisions recorded on this merge request
Round 2 of the review raised two findings that this merge request does not act on. Both would reverse a documentation change this branch already landed, and both stay as written.
- The lock-order paragraph at
internal/datastore/lifecycle_reap_npm_remote.go:163-169stays asf2f16e6b9wrote it. The finding argues that no production path hands a fill a tombstoned parent id, which reverses that commit's recorded position. - The account of
namespace_idin the two parentDELETEs, atinternal/datastore/lifecycle_reap_npm_remote.go:227-229and:394-395, stays as25f3df1bcwrote it. The finding argues thatnamespace_idis half the row's identity rather than a pruning hint, which reverses that commit's recorded position.
This is a bot message