feat(managementapi): serve the maven arm of the package delete

Why

S17's Phase 4 write surface owes a delete for every addressable artifact. This MR fills the Maven arm of DELETE /api/v1/{slug}/repositories/{repository_name}/{format}/packages/{package_id}: chain-resolve the package, write the tombstone marker, answer 202, and emit one artifact-deleted event after the response. The mark initiates the subtree's removal and the lifecycle purger completes it at reap. The npm arm keeps its 501 until its own step lands.

The MR also closes the upload-liveness race the markers open. As the first caller of the Maven tombstone markers, it re-verifies the parent repository, package, and version inside the upload commit transaction, so a mark landing mid-upload fails that commit instead of seating a live child under a marked parent. The race close is declared once here and inherited by the sibling Maven write steps.

This is Step 20 of the S17 Phase 4 plan. Builds on the merged Maven tombstone markers, chore(datastore): add the maven package and ver... (!1443 - merged) • Hayley Swimelar • 19.3.

What (non-obvious)

  • The re-verification runs inside the commit transaction, immediately after the test rendezvous hook and before the file upsert, parent before child: repository FOR SHARE, package FOR UPDATE, version FOR UPDATE when the file binds a version. Errors surface as the upload's existence-hiding 404.
  • Deliberate lock-strength acceptance: package and version take FOR UPDATE where FOR SHARE would close the race, so same-package upload commits serialize. Measure first, documented at the UploadLivenessStore seam. Real-world same-package concurrency is a single client's sequential deploy.
  • The pre-existing concurrency suites now prove lock-queue serialization at the package lock rather than concurrent arbiter arrival (comments updated to match). A post-lock hook point could restore concurrent-arbiter coverage. We left it out deliberately.
  • Racing deletes of one package both answer 202 and the mark applies once. Emission is gated on the applied mark, so only the committed deletion emits, matching the spec's committed-deletions-only rule. The same conversion for versions, files, and npm is tracked in Gate single-delete usage events on applied mark... (#634) • Unassigned.
  • LOC: 2079 reviewable, 511 production and 1568 test, past the 500-LOC guardrail. By file group: the handler arm and wiring (internal/managementapi, cmd), the upload re-verification and its seam (internal/format/maven), and one new datastore statement plus suites (internal/datastore). The test files dominate. Splitting would sever the staged-race tests from the window they close and split the cross-package edit the plan deliberately bundles into this step.

Test plan

Unit suites cover the 202/404 matrix, pin the npm arm at 501, and prove event ordering with a panicking tracker. Integration: seed through the Maven write stores, delete, walk every Maven read route beneath the marked package for invisibility, confirm the sibling package stays readable, and confirm a second delete answers 404 with no second event. Four staged-race tests drive the rendezvous hook: the package, version, and repository arms (the repository arm mutation-verified), plus the repeat-upload arm proving the upload does not rewrite the hidden row. Delta-scoped lint is clean (two adjudicated contextcheck findings on shared seed helpers).

Spec coverage (S17-rest-management-api.md, scoped to this arm), pasted from the test commit:

Spec item Tests
AC #25 (marked artifact and subtree absent from lists, 404 on detail; Maven slice, mark onward) TestMavenPackageDeleteIntegration_MarksAndHidesTheSubtree; TestMavenPackageDelete_SecondDeleteIs404; TestMavenPackageDelete_NotFoundPaths/already-marked package answers the artifact 404
AC #31 (DELETE package answers 202; target absent once removal has run; missing target 404) TestMavenPackageDelete_MarksAndAnswers202; TestMavenPackageDeleteIntegration_MarksAndHidesTheSubtree; TestMavenPackageDelete_NotFoundPaths/missing package answers the artifact 404
AC #32 (delete removes the full subtree, parent last) Mark-onward invisibility only: the integration walk covers every read level; the removal itself is the S20-A purger's, verified there. SQL probes pin the mark on the package row alone, version-less file included
AC #50 (one event per named artifact; deletion_type=manual; cascade emits one) TestMavenPackageDelete_EmitsArtifactDeletedEvent; TestMavenPackageDelete_FailurePaths_EmitNoEvent; TestMavenPackageDelete_SecondDeleteEmitsNoSecondEvent
AC #65 (method fallthrough: DELETE on an identifier route with the literal bulk_delete id answers 404) TestHandler_BulkDeletePaths_MethodFallthrough/packages DELETE falls through to package delete (row updated from the placeholder 501 to the served arm's 404)
AC #67 (closed) (format mismatch 404; non-UUID path id 404; Maven non-digest slices) TestMavenPackageDelete_NotFoundPaths/maven URL addressing the npm repository; TestMavenPackageDelete_RejectsNonCanonicalIDs; out-of-family segment pinned by the pre-existing TestHandler_PackageWriteRoutes_ContainerFormatIs404
Error cases: artifact routes (format mismatch; id missing, invalid, soft-deleted, or outside the chain; non-hosted kind) and artifact delete (target missing) all 404 TestMavenPackageDelete_NotFoundPaths (all rows); TestMavenPackageDelete_RejectsNonCanonicalIDs
Delete semantics: 202 with empty body; emission after the response TestMavenPackageDelete_MarksAndAnswers202; TestMavenPackageDelete_EmitsAfterTheResponse (panicking tracker)
Security: existence hiding on writes (uniform 404, no syntax oracle, level fixed by resolve order) TestMavenPackageDelete_NotFoundPaths/non-hosted repository with a malformed id; TestMavenPackageDelete_RejectsNonCanonicalIDs
Security: tenant isolation (namespace-scoped resolve and mark) TestMavenPackageDelete_MarksAndAnswers202 (recorded call scoping); TestMavenPackageDelete_NotFoundPaths/package under another namespace; TestMavenVersionStore_ReVerifyVersionAlive/returns ErrNotFound for a version under another namespace
Plan research finding: the upload commit path re-verifies parent liveness inside its transaction TestUpload_PackageMarkedMidCommit_FailsTheCommit; TestUpload_VersionMarkedMidCommit_FailsTheCommit; TestUpload_RepeatUploadAgainstMarkedPackage_DoesNotRewriteTheHiddenRow; TestMavenVersionStore_ReVerifyVersionAlive (+ _ArgumentGuards)
npm arm untouched (placeholder until its own change) TestNpmPackageDelete_StillAnswersThePlaceholder; the retained deletePackage/npm pending entry

This MR adds no docs/testing/ scenario: the plan's Testing Strategy records Maven management journeys as UI-driven, landing with the monolith slices that consume these endpoints.

Context for LLM agents

Rationale

  • Lock strength: the upload re-verification takes FOR UPDATE on package and version rows. Rejected FOR SHARE, which closes the race without serializing same-package upload commits: it adds a second locking mode to document and test, and no measured contention justifies it. The acceptance is recorded at the UploadLivenessStore seam.
  • Seam shapes: MavenPackageDeleter carries no qrm.DB, the pool-binding adapter lives in cmd, and the upload liveness checks ride an embedded UploadLivenessStore rather than a parallel interface.

Non-goals

  • The npm delete arm. Its own step lands it, and the 501 placeholder stays until then.
  • The bulk-delete path and the purger's reaping of marked subtrees.
  • The pre-existing 409-arm blob-stranding, a follow-up candidate rather than this MR's concern.

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.MavenPackageStore.FindMavenPackageInRepository Limit maven_packages_p05_pkey 1 / 1 8.30 0.015ms 3 / 0 1/64
datastore.MavenPackageStore.ReVerifyPackageAlive Limit maven_packages_p22_pkey 1 / 1 8.31 0.024ms 4 / 0 1/64
datastore.MavenPackageStore.SoftDeleteMavenPackage Update maven_packages_p63_pkey 0 / 0 (scan: 1 / 1) 8.30 0.149ms 23 / 2 1/64
datastore.MavenRepositoryStore.ReVerifyRepositoryAlive Limit maven_repositories_p63_pkey, repositories_p63_pkey 1 / 1 16.63 0.034ms 8 / 0 1/64, 1/64
datastore.MavenVersionStore.ReVerifyVersionAlive Limit maven_versions_p44_id_maven_package_id_namespace_id_idx 1 / 1 8.31 0.015ms 4 / 0 1/64
datastore.MavenPackageStore.FindMavenPackageInRepository

Summary: Plan matches the read's intent: a single-row seek on the partition child of pk_maven_packages, with the namespace_id bind pruning to 1 of 64 partitions and the repository and soft-delete legs applied as filters on the seeked row. Estimate and actual agree (1 / 1) and execution stays at 0.030ms against ~6,600 packages seeded in the probed partition. No anomalies.

Seed shape: namespaces=3, repositories=7200, maven_repositories=6000, maven_packages=7601, maven_versions=7601 (6,601 packages in the probe namespace's partition: 6,000 live, 600 soft-deleted, 1 target; bystander rows in two other namespaces)

Rendered SQL:

SELECT maven_packages.namespace_id AS "maven_packages.namespace_id",
     maven_packages.id AS "maven_packages.id",
     maven_packages.maven_repository_id AS "maven_packages.maven_repository_id",
     maven_packages.last_downloaded_at AS "maven_packages.last_downloaded_at",
     maven_packages.soft_deleted_at AS "maven_packages.soft_deleted_at",
     maven_packages.group_id AS "maven_packages.group_id",
     maven_packages.artifact_id AS "maven_packages.artifact_id"
FROM public.maven_packages
WHERE (((maven_packages.namespace_id = $1::uuid) AND (maven_packages.id = $2::uuid)) AND (maven_packages.maven_repository_id = $3::uuid)) AND (maven_packages.soft_deleted_at IS NULL)
LIMIT $4;

Bound args: [1b0d8e26-b757-4725-b12a-de427cb160be, 3755e878-b777-42ef-983a-aea78692d9f1, 000e973c-5130-436b-a958-632f683d7d67, 1]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.28..8.30 rows=1 width=94) (actual time=0.015..0.015 rows=1 loops=1)
   Buffers: shared hit=3
   ->  Index Scan using maven_packages_p05_pkey on maven_packages_p05 maven_packages  (cost=0.28..8.30 rows=1 width=94) (actual time=0.014..0.014 rows=1 loops=1)
         Index Cond: ((id = '3755e878-b777-42ef-983a-aea78692d9f1'::uuid) AND (namespace_id = '1b0d8e26-b757-4725-b12a-de427cb160be'::uuid))
         Filter: ((soft_deleted_at IS NULL) AND (maven_repository_id = '000e973c-5130-436b-a958-632f683d7d67'::uuid))
         Buffers: shared hit=3
 Planning:
   Buffers: shared hit=76
 Planning Time: 0.426 ms
 Execution Time: 0.030 ms

Timings: planning 0.426ms, execution 0.030ms, total 0.456ms.

datastore.MavenPackageStore.ReVerifyPackageAlive

Summary: Plan matches the guard's intent: LockRows above a single-row seek on the partition child of pk_maven_packages, pruned to 1 of 64 partitions, with the soft-delete leg as a filter on the seeked row. Exactly one row is locked (LockRows actual rows=1), so the FOR UPDATE cannot reach past the target package. No anomalies.

Seed shape: namespaces=3, repositories=7200, maven_repositories=6000, maven_packages=7601, maven_versions=7601 (6,601 packages in the probe namespace's partition: 6,000 live, 600 soft-deleted, 1 target; bystander rows in two other namespaces)

Rendered SQL:

SELECT maven_packages.id AS "maven_packages.id"
FROM public.maven_packages
WHERE ((maven_packages.namespace_id = $1::uuid) AND (maven_packages.id = $2::uuid)) AND (maven_packages.soft_deleted_at IS NULL)
LIMIT $3
FOR UPDATE;

Bound args: [8bbffcd9-f797-4661-acc1-57c59cd89bd3, acd82148-8cf5-40da-a9b5-947429cb2b7a, 1]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.28..8.31 rows=1 width=26) (actual time=0.023..0.024 rows=1 loops=1)
   Buffers: shared hit=4
   ->  LockRows  (cost=0.28..8.31 rows=1 width=26) (actual time=0.023..0.023 rows=1 loops=1)
         Buffers: shared hit=4
         ->  Index Scan using maven_packages_p22_pkey on maven_packages_p22 maven_packages  (cost=0.28..8.30 rows=1 width=26) (actual time=0.018..0.018 rows=1 loops=1)
               Index Cond: ((id = 'acd82148-8cf5-40da-a9b5-947429cb2b7a'::uuid) AND (namespace_id = '8bbffcd9-f797-4661-acc1-57c59cd89bd3'::uuid))
               Filter: (soft_deleted_at IS NULL)
               Buffers: shared hit=3
 Planning:
   Buffers: shared hit=66
 Planning Time: 0.724 ms
 Execution Time: 0.047 ms

Timings: planning 0.724ms, execution 0.047ms, total 0.771ms.

datastore.MavenPackageStore.SoftDeleteMavenPackage

Summary: Update over a single-row seek on the partition child of pk_maven_packages, pruned to 1 of 64 partitions; the scan matched exactly one row (1 / 1), so the marker writes and row-locks only the target package (the root Update reports rows=0 by ModifyTable convention). The two trigger lines are the per-row foreign-key re-checks against indexed parents, 0.25ms combined. No anomalies.

Seed shape: namespaces=3, repositories=7200, maven_repositories=6000, maven_packages=7601, maven_versions=7601 (6,601 packages in the probe namespace's partition: 6,000 live, 600 soft-deleted, 1 target; bystander rows in two other namespaces)

Rendered SQL:

UPDATE public.maven_packages
SET soft_deleted_at = NOW()
WHERE ((maven_packages.namespace_id = $1::uuid) AND (maven_packages.id = $2::uuid)) AND (maven_packages.soft_deleted_at IS NULL);

Bound args: [4c5c81d7-71fb-4bf4-b5c1-790d37f42622, 4a20ed96-cbbf-4bdd-9073-2291ec4e2465]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Update on maven_packages  (cost=0.28..8.30 rows=0 width=0) (actual time=0.148..0.149 rows=0 loops=1)
   Update on maven_packages_p63 maven_packages_1
   Buffers: shared hit=23 read=2
   ->  Index Scan using maven_packages_p63_pkey on maven_packages_p63 maven_packages_1  (cost=0.28..8.30 rows=1 width=18) (actual time=0.013..0.013 rows=1 loops=1)
         Index Cond: ((id = '4a20ed96-cbbf-4bdd-9073-2291ec4e2465'::uuid) AND (namespace_id = '4c5c81d7-71fb-4bf4-b5c1-790d37f42622'::uuid))
         Filter: (soft_deleted_at IS NULL)
         Buffers: shared hit=3
 Planning:
   Buffers: shared hit=67
 Planning Time: 0.385 ms
 Trigger for constraint fk_maven_packages_maven_repository_id_maven_repositories on maven_packages_p63: time=0.234 calls=1
 Trigger for constraint fk_maven_packages_namespace_id_namespaces on maven_packages_p63: time=0.018 calls=1
 Execution Time: 0.596 ms

Timings: planning 0.385ms, execution 0.596ms, total 0.981ms.

datastore.MavenRepositoryStore.ReVerifyRepositoryAlive

Summary: Plan matches the guard's intent: a nested-loop join of two single-row seeks on the partition children of pk_maven_repositories and pk_repositories, each pruned to 1 of 64 partitions, with the format, kind, and soft-delete legs as filters on the one joined repositories row. The FOR SHARE locks the matched pair, the maven_repositories row and its parent repositories row, which is the guard's purpose, and no sibling rows (LockRows actual rows=1). No anomalies.

Seed shape: namespaces=3, repositories=7200, maven_repositories=6000, maven_packages=7601, maven_versions=7601 (6,200 repositories and 5,000 maven_repositories in the probe namespace's partition, including 1,000 mixed-format/kind and 200 soft-deleted repository bystanders; more in two other namespaces)

Rendered SQL:

SELECT maven_repositories.id AS "maven_repositories.id"
FROM public.maven_repositories
     INNER JOIN public.repositories ON ((repositories.id = maven_repositories.repository_id) AND (repositories.namespace_id = maven_repositories.namespace_id))
WHERE ((((maven_repositories.namespace_id = $1::uuid) AND (maven_repositories.id = $2::uuid)) AND (repositories.format = $3)) AND (repositories.kind = $4)) AND (repositories.soft_deleted_at IS NULL)
LIMIT $5
FOR SHARE;

Bound args: [edbfb07f-5f13-4a16-9a0e-10de6f8cbf27, 0003f07f-0659-4d73-85e6-80f87dbe688f, 1, 0, 1]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.56..16.63 rows=1 width=36) (actual time=0.033..0.034 rows=1 loops=1)
   Buffers: shared hit=8
   ->  LockRows  (cost=0.56..16.63 rows=1 width=36) (actual time=0.033..0.033 rows=1 loops=1)
         Buffers: shared hit=8
         ->  Nested Loop  (cost=0.56..16.62 rows=1 width=36) (actual time=0.029..0.029 rows=1 loops=1)
               Buffers: shared hit=6
               ->  Index Scan using maven_repositories_p63_pkey on maven_repositories_p63 maven_repositories  (cost=0.28..8.30 rows=1 width=58) (actual time=0.009..0.010 rows=1 loops=1)
                     Index Cond: ((id = '0003f07f-0659-4d73-85e6-80f87dbe688f'::uuid) AND (namespace_id = 'edbfb07f-5f13-4a16-9a0e-10de6f8cbf27'::uuid))
                     Buffers: shared hit=3
               ->  Index Scan using repositories_p63_pkey on repositories_p63 repositories  (cost=0.28..8.31 rows=1 width=42) (actual time=0.018..0.018 rows=1 loops=1)
                     Index Cond: ((id = maven_repositories.repository_id) AND (namespace_id = 'edbfb07f-5f13-4a16-9a0e-10de6f8cbf27'::uuid))
                     Filter: ((soft_deleted_at IS NULL) AND (format = 1) AND (kind = 0))
                     Buffers: shared hit=3
 Planning:
   Buffers: shared hit=243 read=1
 Planning Time: 1.193 ms
 Execution Time: 0.059 ms

Timings: planning 1.193ms, execution 0.059ms, total 1.252ms.

datastore.MavenVersionStore.ReVerifyVersionAlive

Summary: Plan matches the guard's intent: LockRows above a single-row seek on the partition child of unique_maven_versions_id_pkg_id_ns_id (the planner's pick over the equivalent primary-key seek; both lead with id), pruned to 1 of 64 partitions, with the soft-delete leg as a filter on the seeked row. Exactly one row is locked (LockRows actual rows=1), so the FOR UPDATE cannot reach past the target version. No anomalies.

Seed shape: namespaces=3, repositories=7200, maven_repositories=6000, maven_packages=7601, maven_versions=7601 (6,601 versions in the probe namespace's partition: 6,000 live, 600 soft-deleted, 1 target; bystander rows in two other namespaces)

Rendered SQL:

SELECT maven_versions.id AS "maven_versions.id"
FROM public.maven_versions
WHERE ((maven_versions.namespace_id = $1::uuid) AND (maven_versions.id = $2::uuid)) AND (maven_versions.soft_deleted_at IS NULL)
LIMIT $3
FOR UPDATE;

Bound args: [b549c55d-ae59-49d3-b6af-d1edc166e30e, 5aeab6e8-c219-4fd5-b405-9997943746dc, 1]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.28..8.31 rows=1 width=26) (actual time=0.014..0.015 rows=1 loops=1)
   Buffers: shared hit=4
   ->  LockRows  (cost=0.28..8.31 rows=1 width=26) (actual time=0.014..0.014 rows=1 loops=1)
         Buffers: shared hit=4
         ->  Index Scan using maven_versions_p44_id_maven_package_id_namespace_id_idx on maven_versions_p44 maven_versions  (cost=0.28..8.30 rows=1 width=26) (actual time=0.011..0.011 rows=1 loops=1)
               Index Cond: ((id = '5aeab6e8-c219-4fd5-b405-9997943746dc'::uuid) AND (namespace_id = 'b549c55d-ae59-49d3-b6af-d1edc166e30e'::uuid))
               Filter: (soft_deleted_at IS NULL)
               Buffers: shared hit=3
 Planning:
   Buffers: shared hit=461
 Planning Time: 1.436 ms
 Execution Time: 0.034 ms

Timings: planning 1.436ms, execution 0.034ms, total 1.470ms.

Edited by Hayley Swimelar

Merge request reports

Loading
Loading