chore(managementapi): apply npm bulk deletes for versions
Why
Step 36 of the S17 Phase 4 artifact-writes plan. The npm bulk worker applies the packages and files collections and answers a typed sentinel for versions, so a versions batch can be enqueued but never drains (the worker landed in chore(managementapi): apply npm bulk deletes fo... (!1550 - merged) • Hayley Swimelar • 19.3). This MR fills that arm through the S12 version composer, which is what makes packument expiry and the orphan rule ride each entry's own transaction (spec AC #36, #37): delete_all on a package's versions leaves no zero-version package behind.
Plan: Step 36. Spec: Delete semantics (Phase 4).
What (the non-obvious parts)
- Counter settlement is aggregated per pass, not dispatched per entry. The plan records this as a research correction: the buffered counter dispatcher's in-flight cap is per process, so a per-entry burst from one worker sheds its own tail and every concurrent handler's update with it. The accumulated tags_count delta is dispatched on every exit from the pass, the aggregate-error and panic-unwind paths included, because the amounts were committed by entry transactions that already ran. Entries a retry re-applies report unmarked and contribute nothing.
- The version composer now reports what its transaction did. Its old no-op tuple could not distinguish an applied entry from a skip, so the worker could not gate events the way the packages arm does (!1550 (merged) widened the packages composer with
markedfor the same reason).DeleteVersionreturnsdatastore.NpmVersionDeleteOutcome{Marked, TagsRemoved, PackageDeleted}— a struct rather than positional returns, so the two same-type booleans cannot transpose silently.Markedderives from re-running the existing active-version probe after the rotation takes the package row lock, the lock every same-package writer serializes on, so no store-method statement shape changes. It also makesPackageDeletedexactly-once, raced directly in the composer suite on both interleavings, and the composer docs that promised otherwise are trued up. - versions_count does not move. Per ADR-007 the counter includes soft-deleted versions and decrements only when GC hard-deletes a row (the anti-gaming rationale), which is also what the merged S11 unpublish path does. The stale composer comment claiming a caller would settle it is corrected here. The emptied package settles tags_count by reset, mirroring the unpublish cascade arm: zero is the exact recompute of the terminal state.
Test plan
- Unit: versions subset and drain outcomes, stall and cancellation arms, applied-gated events, and the settlement suite: one dispatch per pass (a per-entry implementation fails on the dispatch count), the failing-pass delta, the panic-unwind pin, the zero-delta skip, and the zero-cursor retry restart.
- Integration: a staged two-batch drain with packument-expiry reads between stages,
delete_allleaving no zero-version package with tags_count reset on the tombstone, and a fault-injected partial pass whose delta still settles, retried to completion with no double count. Composer:markedpins including a concurrent-deleters race in which exactly one transaction reports the mark. - Gates run locally: full
go test ./...,-raceon the touched packages, and uncappedgolangci-lint --build-tags=integrationon the touched packages (only the pre-existing contextcheck class remains, verified unchanged against a main-baseline run).
LOC
1,844 insertions: 474 production (the versions pass in bulk_npm_worker_versions.go, the composer outcome report and doc corrections, the scope-page reader, wiring), 1,370 test. Splitting would separate the composer's outcome widening from the only caller that needs it, so the diff carries both with the tests that pin them.
No e2e catalog change: delete-family rows ride E2e lifecycle catalogs: one delete-family pass ... (#638) • Unassigned.
Related to #313 (closed)
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17 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.NpmVersionStore.ListNpmVersionIDsForDeleteAll |
Limit | npm_versions_p48_pkey |
500 / 500 | 58.38 | 0.190ms | 500 / 0 | 1/64 |
NpmVersionManagementDeleter is the diff's other datastore file and adds no new or changed statement: its delta is a second execution of the unchanged ActiveNpmVersionExistsTx probe as the re-check inside the existing transaction, so there is nothing new to EXPLAIN.
datastore.NpmVersionStore.ListNpmVersionIDsForDeleteAll
Summary: Plan matches the keyset intent: an Index Scan over the partition pk (id, namespace_id) delivers ORDER BY id directly, so the LIMIT stops the scan at 500 rows with no Sort node, and the bound namespace_id prunes to 1 of 64 partitions. The root estimate matches actuals (500 / 500) with every buffer a shared hit. The partial active index index_npm_versions_on_ns_id_pkg_id_created_at_id covers the full predicate set for a partition holding many packages; at this single-package seed the pk scan's filter discards zero rows. No anomalies.
Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=1, npm_versions=5000
Rendered SQL:
SELECT npm_versions.id AS "npm_versions.id"
FROM public.npm_versions
WHERE ((((npm_versions.namespace_id = $1::uuid) AND (npm_versions.npm_package_id = $2::uuid)) AND (npm_versions.soft_deleted_at IS NULL)) AND (npm_versions.created_at <= $3::timestamp with time zone)) AND (npm_versions.id > $4::uuid)
ORDER BY npm_versions.id ASC
LIMIT $5;Bound args: [bbe04b70-a95f-414b-bc3d-2edd38787cf8, 8633acd4-a7e3-44ad-87c0-206e48ecf697, 2026-08-18 00:10:49.269157+00, 00000000-0000-0000-0000-000000000000, 500]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..58.38 rows=500 width=16) (actual time=0.009..0.190 rows=500 loops=1)
Buffers: shared hit=500
-> Index Scan using npm_versions_p48_pkey on npm_versions_p48 npm_versions (cost=0.28..581.28 rows=5000 width=16) (actual time=0.008..0.169 rows=500 loops=1)
Index Cond: ((id > '00000000-0000-0000-0000-000000000000'::uuid) AND (namespace_id = 'bbe04b70-a95f-414b-bc3d-2edd38787cf8'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (created_at <= '2026-08-18 00:10:49.269157+00'::timestamp with time zone) AND (npm_package_id = '8633acd4-a7e3-44ad-87c0-206e48ecf697'::uuid))
Buffers: shared hit=500
Planning:
Buffers: shared hit=453
Planning Time: 1.349 ms
Execution Time: 0.213 msTimings: planning 1.349ms, execution 0.213ms, total 1.562ms.