fix(datastore): report the repository and freed bytes from the package reap

Summary

MavenPackageReaper.Reap now fills ReapTotals.RepositoryID and ReapTotals.SizeBytes, so a hosted Maven package purge decrements repositories.size_bytes at the reap instead of leaving the column high until the next reconciliation pass.

The cause. The arm's three-leg walk — the maven_versions rows with their files, the version-less files, then the package row — freed rows whose bytes the column still counted, and the arm reported neither field: it dropped the digests reapMavenFilePage handed back and made no resolveReapRepository call, so emitChunkCounters issued the component delta alone. The upload side raises the column (dispatchHandler.emitCommittedCounters in internal/format/maven/upload_emit.go), so the drift ran one direction only.

The change.

  • reapRepositoryRouteMavenPackage and its resolve statement, walking maven_packages to maven_repositories — the npm package route's shape. EXPLAIN (ANALYZE, BUFFERS) against the migrated schema: two primary-key index scans (maven_packages_p38_pkey, maven_repositories_p38_pkey), each pruned to one hash partition by namespace_id, nested loop, 4 shared-buffer hits, ~1 ms.
  • The resolve runs before the legs, while the package chain stands; a repository walk's row declines the figure (TombstoneRow.SkipFreedBytes), so that route pays for neither probe statement.
  • One sumFreedRepositoryBytes probe over the digests of both legs, positioned ahead of every return: the version leg's page alone at the not-drained exit, both legs' pages before the package delete. A digest carried by a file of both legs reaches the probe twice; the DISTINCT inside the statement counts it once.
  • The maven_versions/maven_packages level order in DefaultPurgeLevels is recorded as unordered: the walk drains the versions itself (no foreign key orders the pair), and both arms report the figure (no counter does either) — unlike the npm pair, whose order is load-bearing.
  • The storage-accounting doc, the S20-A spec's gap entries, and the e2e counters row stop recording this arm as one that computes no byte figure.

Merge order. Stacked on !2040 (merged) (the maven_packages scan level and the walk this fix builds on); this MR targets that branch and retargets to main when it lands. !2040 (merged) itself rebases onto S20-A step 20b per that plan's merge order, so this branch inherits that rebase. The two new error wraps (resolve and sum failures) are reachable only through statement failures and follow the family's untested-by-convention status: no arm in this family — npm version, npm package, maven version — tests its equivalent wrap.

Governing ADRs

ADR-007 — the figure is the repository-scoped one ADR-007 defines, deduplicated within the repository; the survivor statement walks maven_files through maven_package_id, the route RepositoryReconcileStore.RecomputeSizeBytes takes, so a version-less file counts; the emit pair (SizeBytes != 0 && RepositoryID != uuid.Nil) is what the counter contract makes emittable. Checked against ADR-004 (the entity-count table bounds the probe-cost reasoning) and ADR-025 (blob reclamation unchanged). No deviation.

Testing

  • A new byte-report suite, TestMavenPackageReaper_Reap_ReportsTheRepositoryScopedBytes, covers the arm the way the npm package arm's suite covers its own: the survivor pair (a digest another package's file still carries frees nothing; one nothing carries frees its blob's bytes, through the version-less leg), the cross-repository copy, the arm's own duplicate shape (a version-ful and a version-less file at one digest, counted once), and the version-leg early exit, where the page fills the budget and the figure is over what that page freed. TestArtifactReapers_Reap_NameNoRepositoryOnADrainedSubtree gains the maven package case, pinning uuid.Nil on a gone chain.
  • The pinned zero flips: the drained-whole case asserts the figure over blobs seeded at non-zero sizes and the repository read before the reap; four whole-struct assertions gain the repository; the no-version zero becomes a measured one. The red runs were demonstrated against the branch's parent before the implementation landed (the two test(...) commits).
  • The conservation drive flips to the size half: the chunks' summed SizeBytes equals the blobs the package held.
  • The guard suite's statements table gains the new resolve as a positive hit.
  • Full local runs against a postgres carrying the CI service options (max_locks_per_transaction=1024): internal/datastore/migrations, internal/datastore/schemas, and internal/lifecycle suites fully green; the internal/datastore suite ran 45 minutes with zero test failures and timed out mid TestNpmPackageStore_CascadeSoftDeleteNpmPackage's 5000-row fixture, a test this branch does not touch — CI's test:integration:datastore shards that suite and carries the full gate. The default testcontainers lock table 53200s on cascading cleanup deletes locally, which is why these runs use ARTIFACT_REGISTRY_DATABASE_TEST_DSN against a service-shaped postgres.
  • Lint: golangci-lint run --build-tags=integration internal/datastore/... internal/lifecycle/... --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false reports nothing on the touched lines; check-comment-caps passes.

Post-deploy monitoring

No additional operational monitoring required: the change adds a counter decrement at an existing emit site on a background worker. A wrong figure surfaces as repositories.size_bytes drift against the hourly reconciliation recompute — the same signal that surfaced the gap — and the purge worker's existing purge_outcome metric carries any statement failure the new probe could raise.

Closes #1012 (closed)

Merge request reports

Loading
Loading