feat(npm): dist-tag datastore write layer (step 21, 1/2)

Part 1 of 2 of the npm hosted planStep 21: dist-tag PUT / DELETE.

🗂️ Stacked MRs

Split into 2 stacked MRs to keep each within the review size limit (~600 ideal reviewable LoC). Each part targets the previous one (part 1 → main); review and merge bottom-up.

📦 What this part adds

Lands the datastore write layer the handler (part 2/2) consumes:

  • NpmDistTagTxWriter (SetTag / DeleteTag): composes UpsertNpmTag / DeleteNpmTag with the transactional force-expire under RunInTx, so the npm_tags write and the packument-cache invalidation commit atomically. Mirrors NpmPackageUnpublishDeleter; the pooled ForceExpireNpmMetadata from Step 5 is not tx-capable.
  • ForceExpireNpmMetadataTx: the qrm.DB-accepting force-expire variant the tx envelope needs.
  • Increment / DecrementNpmPackageTagsCount: the buffered npm_packages.tags_count adjusters (decrement clamps at zero via GREATEST).

ℹ️ Known gap — these seams have no caller yet. The handler in part 2 (!772 (merged)) consumes them; production composition-root wiring is deferred to Step 23.

Spec coverage

Acceptance criterion Test
AC 27 — force-expire commits atomically with the tag write TestNpmDistTagTxWriter_SetTag
AC 27 — force-expire commits atomically with the tag removal TestNpmDistTagTxWriter_DeleteTag
In-tx force-expire commits/rolls back all kinds; absent-tag no-op skips it TestNpmMetadataFileStore_ForceExpireNpmMetadataTx
tags_count increment/decrement, decrement clamps at zero TestNpmPackageStore_TagsCountIncrementDecrement
Adjuster argument guards (nil ctx, zero UUIDs) TestNpmPackageStore_TagsCountAdjuster_ArgumentGuards
Constructor rejects a nil client TestNewNpmStores_NilClient

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), seeded with ~2,000 npm_packages and ~2,000 npm_metadata_files rows in one namespace; each UPDATE ran inside BEGIN/ROLLBACK and the container was torn down after the run. Numbers reflect moderate cardinality, not production scale. All three are single-row/small indexed writes; ForceExpireNpmMetadataTx is the same statement as the already-reviewed pooled ForceExpireNpmMetadata (evidence on !713 (merged)), differing only in running on the caller's tx.

Method Plan node Index Rows (plan / actual) Cost Exec time Buffers (hit / read) Partitions
NpmPackageStore.IncrementNpmPackageTagsCount Index Scan npm_packages_pkey 1 / 1 8.30 0.56ms 53 / 0 1 of 64
NpmPackageStore.DecrementNpmPackageTagsCount Index Scan npm_packages_pkey 1 / 1 8.30 0.59ms 57 / 0 1 of 64
NpmMetadataFileStore.ForceExpireNpmMetadataTx Index Scan unique_npm_metadata_files_ns_id_pkg_id_kind 3 / 3 11.85 0.36ms 77 / 0 1 of 64
NpmPackageStore.IncrementNpmPackageTagsCount

Summary: Plan matches intent — the (namespace_id, id) predicate prunes to one of 64 partitions and an Index Scan on the primary key updates exactly one row (1 / 1). All buffers are cache hits (index-scan portion 3), no reads; execution 0.56ms. No anomalies.

Seed shape: npm_packages=2001, npm_metadata_files=2003

Rendered SQL:

UPDATE npm_packages
SET tags_count = tags_count + 1
WHERE namespace_id = $1 AND id = $2;

Bound args: ['11111111-1111-1111-1111-111111111111', '44444444-4444-4444-4444-444444444444']

Plan:

 Update on npm_packages  (cost=0.28..8.30 rows=0 width=0) (actual time=0.333..0.333 rows=0 loops=1)
   Update on npm_packages_p28 npm_packages_1
   Buffers: shared hit=53
   ->  Index Scan using npm_packages_p28_pkey on npm_packages_p28 npm_packages_1  (cost=0.28..8.30 rows=1 width=14) (actual time=0.021..0.022 rows=1 loops=1)
         Index Cond: ((id = '44444444-4444-4444-4444-444444444444'::uuid) AND (namespace_id = '11111111-1111-1111-1111-111111111111'::uuid))
         Buffers: shared hit=3
 Planning:
   Buffers: shared hit=436
 Planning Time: 1.550 ms
 Execution Time: 0.557 ms
NpmPackageStore.DecrementNpmPackageTagsCount

Summary: Same shape as the increment — partition pruning + primary-key Index Scan, one row (1 / 1), all cache hits, 0.59ms. The GREATEST(tags_count - 1, 0) clamp is a scalar expression on the updated row, adding no scan cost. No anomalies.

Seed shape: npm_packages=2001, npm_metadata_files=2003

Rendered SQL:

UPDATE npm_packages
SET tags_count = GREATEST(tags_count - 1, 0)
WHERE namespace_id = $1 AND id = $2;

Bound args: ['11111111-1111-1111-1111-111111111111', '44444444-4444-4444-4444-444444444444']

Plan:

 Update on npm_packages  (cost=0.28..8.30 rows=0 width=0) (actual time=0.286..0.286 rows=0 loops=1)
   Update on npm_packages_p28 npm_packages_1
   Buffers: shared hit=57
   ->  Index Scan using npm_packages_p28_pkey on npm_packages_p28 npm_packages_1  (cost=0.28..8.30 rows=1 width=14) (actual time=0.031..0.033 rows=1 loops=1)
         Index Cond: ((id = '44444444-4444-4444-4444-444444444444'::uuid) AND (namespace_id = '11111111-1111-1111-1111-111111111111'::uuid))
         Buffers: shared hit=5
 Planning:
   Buffers: shared hit=436
 Planning Time: 1.503 ms
 Execution Time: 0.591 ms
NpmMetadataFileStore.ForceExpireNpmMetadataTx

Summary: The (namespace_id, npm_package_id) predicate is the leading prefix of the unique (namespace_id, npm_package_id, kind) index, so the planner prunes to one partition and Index Scans exactly the package's ≤3 kind rows (3 / 3). All cache hits, 0.36ms. Identical plan to the pooled ForceExpireNpmMetadata (reviewed on !713 (merged)). No anomalies.

Seed shape: npm_metadata_files=2003 (3 kind rows for the target package)

Rendered SQL:

UPDATE npm_metadata_files
SET expires_at = NOW()
WHERE namespace_id = $1 AND npm_package_id = $2;

Bound args: ['11111111-1111-1111-1111-111111111111', '44444444-4444-4444-4444-444444444444']

Plan:

 Update on npm_metadata_files  (cost=0.28..11.85 rows=0 width=0) (actual time=0.223..0.223 rows=0 loops=1)
   Update on npm_metadata_files_p28 npm_metadata_files_1
   Buffers: shared hit=77
   ->  Index Scan using npm_metadata_files_p28_namespace_id_npm_package_id_kind_idx on npm_metadata_files_p28 npm_metadata_files_1  (cost=0.28..11.85 rows=3 width=18) (actual time=0.015..0.016 rows=3 loops=1)
         Index Cond: ((namespace_id = '11111111-1111-1111-1111-111111111111'::uuid) AND (npm_package_id = '44444444-4444-4444-4444-444444444444'::uuid))
         Buffers: shared hit=3
 Planning:
   Buffers: shared hit=472
 Planning Time: 1.221 ms
 Execution Time: 0.355 ms

📚 References

Related to #139 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading