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 marked for the same reason). DeleteVersion returns datastore.NpmVersionDeleteOutcome{Marked, TagsRemoved, PackageDeleted} — a struct rather than positional returns, so the two same-type booleans cannot transpose silently. Marked derives 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 makes PackageDeleted exactly-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_all leaving 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: marked pins including a concurrent-deleters race in which exactly one transaction reports the mark.
  • Gates run locally: full go test ./..., -race on the touched packages, and uncapped golangci-lint --build-tags=integration on 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 ms

Timings: planning 1.349ms, execution 0.213ms, total 1.562ms.

Edited by Hayley Swimelar

Merge request reports

Loading
Loading