chore(datastore): single metadata row reaper (S20-A Maven metadata purge plan: 2/5)

What

Adds an unregistered reaper for one hosted Maven package-level maven-metadata.xml tombstone, with attachment cleanup and freed-byte reporting. It is not wired into discovery, dispatch, or the purge worker yet — Steps 3 and 5 of the plan add those.

  • MavenMetadataFileReaper.Reap deletes the exact addressed file row plus its attachment, on the caller's transaction, and reports ReapTotals{Components: 0, Rows, RepositoryID, Drained: true}. Freed-byte accounting reuses the existing hosted-Maven-family walk in lifecycle_reap_size.go via a new file-keyed route (reapRepositoryRouteMavenFile).
  • A missing row (already reaped by a package or repository purge) is a drained no-op, not an error, per the plan's Acceptance entry.
  • A new mavenMetadataReapAttachmentDeleteFails gofail seam covers the attachment-delete failure rollback; .gitlab-ci.yml/.mise.toml wire it into the lifecycle failpoint selectors.
  • scripts/ci/check-comment-caps.sh widens is_directive() to exempt // gofail: comments, needed for the new failpoint comment to pass the comment-cap ratchet; test coverage for that widening is included.

Size justification

This diff is 779 changed lines at 029373920 (700 insertions, 79 deletions), above the 500 LOC guideline and above the plan's own 350–500 forecast for this step.

Group Files Lines
Integration tests lifecycle_reap_maven_metadata_integration_test.go, lifecycle_reap_maven_integration_test.go 349
Unit tests lifecycle_reap_maven_metadata_test.go, lifecycle_reap_size_test.go 86
Source lifecycle_reap_maven_metadata.go, lifecycle_reap_maven.go, lifecycle_reap_size.go, query_names.go 200
Docs storage-accounting.md, go-testing.md 86
CI/tooling .gitlab-ci.yml, .mise.toml, check-comment-caps.sh, check-comment-caps_test.sh 58

Every figure counts changed lines (insertions plus deletions) against origin/main, so the groups sum to the 779 above. The lifecycle_reap_maven.go and go-testing.md lines are comment and documentation moves, not new logic: the review rounds relocated rationale out of comment blocks the caps charge in full.

Tests are 435 of the 779 lines (56%). The plan's Research Findings cite the precedent fix at "about 5.7 test lines per source line," and Step 2's Tests entry enumerates 11 required scenarios (live rows, version-level metadata, another file name, another namespace, shared attachments, a digest surviving elsewhere, a new live row at the same coordinate, duplicate delivery, two concurrent reapers, the file-keyed resolve guard, and the attachment-failure rollback) plus the CI wiring that backs the last one. None separates cleanly from the reaper's transaction and concurrency contract without leaving part of it unverified.

Related to #1167 (closed)

🤖 Generated with Claude Code

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.Reap Delete maven_files_p18_namespace_id_soft_deleted_at_metadata_idx 1 / 1 8.15 0.013ms 4 / 0 maven_files 1/64
datastore.resolveReapRepository Limit maven_files_p14_pkey 1 / 1 10.35 0.016ms 5 / 0 maven_files 1/64, maven_packages 1/64, maven_repositories 1/64
datastore.Reap

Summary: Plan matches the method's intent: the exact-id, still-tombstoned single-row DELETE prunes to one of 64 hash partitions on the bound namespace_id and rides the metadata discovery index (namespace_id, soft_deleted_at), with the remaining predicate terms as an in-index filter over the one tombstoned row in the partition. Estimate and actual agree (1 / 1) and execution is 0.013ms over 5000 seeded rows in the partition. No anomalies.

Seed shape: namespaces=1, repositories=1, maven_repositories=1, maven_packages=1, blob_storage_blobs=1, blob_storage_attachments=1, maven_files=5000 (one namespace partition; 1 tombstoned maven-metadata.xml target + 4999 live siblings, all sharing the one attachment)

Rendered SQL:

DELETE FROM public.maven_files
WHERE ((((maven_files.namespace_id = $1::uuid) AND (maven_files.id = $2::uuid)) AND (maven_files.maven_version_id IS NULL)) AND (maven_files.file_name = $3::text)) AND (maven_files.soft_deleted_at IS NOT NULL)
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: [446aed8a-362d-72f3-8f01-a2ea48ae451e, a490e017-da81-706e-82b5-aa54d66079aa, maven-metadata.xml]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Delete on maven_files  (cost=0.12..8.15 rows=1 width=10) (actual time=0.012..0.013 rows=1 loops=1)
   Delete on maven_files_p18 maven_files_1
   Buffers: shared hit=4
   ->  Index Scan using maven_files_p18_namespace_id_soft_deleted_at_metadata_idx on maven_files_p18 maven_files_1  (cost=0.12..8.15 rows=1 width=10) (actual time=0.008..0.008 rows=1 loops=1)
         Index Cond: ((namespace_id = '446aed8a-362d-72f3-8f01-a2ea48ae451e'::uuid) AND (soft_deleted_at IS NOT NULL))
         Filter: ((maven_version_id IS NULL) AND (id = 'a490e017-da81-706e-82b5-aa54d66079aa'::uuid) AND (file_name = 'maven-metadata.xml'::text))
         Buffers: shared hit=2
 Planning:
   Buffers: shared hit=294
 Planning Time: 0.648 ms
 Execution Time: 0.175 ms

Timings: planning 0.648ms, execution 0.175ms, total 0.823ms.

datastore.resolveReapRepository (route reapRepositoryRouteMavenFile)

Summary: Plan matches the method's intent: the file-to-repository resolve opens with a primary-key seek on maven_files (id, namespace_id) pruned to one of 64 hash partitions, then walks two nested-loop joins into maven_packages and maven_repositories, each binding the partition key and pruning to one partition. The Seq Scan nodes on the two ancestor partitions scan 1-row tables by seed design, so they are the optimal node, not a missing index. Estimate and actual agree (1 / 1) and execution is 0.016ms. No anomalies.

Seed shape: namespaces=1, repositories=1, maven_repositories=1, maven_packages=1, blob_storage_blobs=1, blob_storage_attachments=1, maven_files=5000 (one namespace partition)

Rendered SQL:

SELECT r.repository_id
FROM maven_files f
JOIN maven_packages p
    ON p.id = f.maven_package_id AND p.namespace_id = f.namespace_id
JOIN maven_repositories r
    ON r.id = p.maven_repository_id AND r.namespace_id = p.namespace_id
WHERE f.namespace_id = $1 AND p.namespace_id = $1 AND r.namespace_id = $1
  AND f.id = $2
LIMIT 1

Bound args: [0c660044-ba16-7cac-a80e-a5fdbae9097a, 6fc23c98-ca54-767e-b3b6-34d664e63582]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.28..10.35 rows=1 width=16) (actual time=0.015..0.016 rows=1 loops=1)
   Buffers: shared hit=5
   ->  Nested Loop  (cost=0.28..10.35 rows=1 width=16) (actual time=0.014..0.015 rows=1 loops=1)
         Join Filter: (r.id = p.maven_repository_id)
         Buffers: shared hit=5
         ->  Nested Loop  (cost=0.28..9.33 rows=1 width=32) (actual time=0.012..0.012 rows=1 loops=1)
               Join Filter: (p.id = f.maven_package_id)
               Buffers: shared hit=4
               ->  Index Scan using maven_files_p14_pkey on maven_files_p14 f  (cost=0.28..8.30 rows=1 width=32) (actual time=0.008..0.008 rows=1 loops=1)
                     Index Cond: ((id = '6fc23c98-ca54-767e-b3b6-34d664e63582'::uuid) AND (namespace_id = '0c660044-ba16-7cac-a80e-a5fdbae9097a'::uuid))
                     Buffers: shared hit=3
               ->  Seq Scan on maven_packages_p14 p  (cost=0.00..1.01 rows=1 width=48) (actual time=0.004..0.004 rows=1 loops=1)
                     Filter: (namespace_id = '0c660044-ba16-7cac-a80e-a5fdbae9097a'::uuid)
                     Buffers: shared hit=1
         ->  Seq Scan on maven_repositories_p14 r  (cost=0.00..1.01 rows=1 width=48) (actual time=0.002..0.002 rows=1 loops=1)
               Filter: (namespace_id = '0c660044-ba16-7cac-a80e-a5fdbae9097a'::uuid)
               Buffers: shared hit=1
 Planning:
   Buffers: shared hit=243
 Planning Time: 0.844 ms
 Execution Time: 0.034 ms

Timings: planning 0.844ms, execution 0.034ms, total 0.878ms.

Edited by Sylvia Shen

Merge request reports

Loading
Loading