feat(managementapi): serve the maven remote package, version, and file deletes (S17 Phase 6 plan: 28/42)

Why

The Maven package, version, and file DELETE routes serve hosted repositories only. On a kind=remote repository they answer the existence-hiding 404, so nothing can evict a cached upstream artifact through the management API.

This is Step 28 of the S17 Phase 6 plan. The routes now dispatch on the resolved repository's kind and run the eviction composers chore(datastore): add maven remote eviction com... (!1574 - merged) • Hayley Swimelar • 19.4 added. An eviction answers 202, emits no usage event, and sends no upstream request. The next pull through the evicted coordinate re-caches it.

Spec: docs/specs/S17-rest-management-api.md, AC #101 and AC #102 (closed).

What

The chain resolver is the security boundary, and the plan did not list it. Step 28's Files section names only the managementapi handlers. MavenRemoteEvictor's marks and file delete scope on namespace_id and the row id alone, so a request-supplied id reaches any row in the namespace, including one under a sibling remote repository. internal/datastore/maven_remote_eviction_resolve.go holds an addressed id to the repository the URL named before a composer sees it. Without it these routes would be a cross-repository delete primitive. The bulk pass has since landed the repository, package, and version resolves in feat(managementapi): maven remote bulk pass (S1... (!1756 - merged) • Hayley Swimelar • 19.4, so this step now adds the file resolve alone and takes the other three as they stand.

Two miss levels, deliberately different. FindMavenRemoteRepositoryID's miss is a logged 500 rather than a 404, because the kind gate has already admitted a remote repository and a parent carrying no child row is a broken create invariant. The entity resolves' misses are the artifact 404, and they run after the repository-level resolve, so the reported level cannot become a probe for which URLs carry well-formed ids.

The resolver's error strings carry no identifiers. docs/dev/database-query-patterns.md bars a new store method from embedding the (namespace=%s, id=%s) pairs the pre-rule stores carry, so the four resolve wraps name the operation and the entity alone, and each 500 path logs the repository or the addressed row's id as its own structured field beside the namespace_id the shared writer appends. Two commits, call sites first, so no revision in between strips the tenant record. The same conversion landed on the step 31 branch's copy of this file in feat(managementapi): maven remote bulk pass (S1... (!1756 - merged) • Hayley Swimelar • 19.4.

The file arm answers 202 on the composer's miss where the hosted arm answers 404. A hard delete that matched nothing means a concurrent eviction already removed the row, and eviction promises the coordinate stops being readable. An INFO line is the only thing separating that race from a resolve defect, because the status is identical either way.

An unmapped kind never reaches the dispatch. resolveArtifactRepositoryForFormat already answers the logged 500 for a kind outside kindStrings, so each arm's default serves the virtual kind. TestMavenPackageDelete_NonHostedRepositoryIs404 drops its remote row for the reason that row was written: it was the tripwire against a wholesale arm-fill widening the write surface, and eviction is the change it was waiting for. Its guarantee moves to the new kind-dispatch tables, matching the note the version and file suites already carry.

The Maven bulk arms admit a remote repository, and four comments said otherwise. resolveMavenBulkScope gained its remote case when feat(managementapi): maven remote bulk pass (S1... (!1756 - merged) • Hayley Swimelar • 19.4 landed, so a remote Maven bulk delete answers 202 and enqueues a pass rather than resolving a child row. dispatchPackageWrite, the write-route roster in artifact.go, and two sites in packages.go all still described the old behavior. The comment-caps pass has since removed the roster outright and cut dispatchPackageWrite's doc to a single line, so the surviving statement of the corrected behavior is the inline comment on resolvePackageRepositoryID's remote arm in packages.go.

The rebase collided with the remote read fill three ways. feat(managementapi): serve the maven remote pac... (!1749 - merged) • Hayley Swimelar • 19.4 declares its own resolveMavenRemoteRepositoryID over a different store seam, declares two of this step's log keys, and pushes both test Deps builders past funlen once the eviction seams join them. The eviction resolve is resolveMavenRemoteEvictionRepositoryID now, the duplicate keys are gone, and each builder's eviction seams moved behind a helper, the way withRemoteArtifactReadSeams already handles the read ones. That fill also pinned the Maven version delete's remote answer as a 404 in its containment gate. This step is what changes that answer, so the row retired to TestMavenRemoteVersionDelete_KindDispatch.

Spec coverage

Spec: docs/specs/S17-rest-management-api.md

This MR is the Maven slice of the remote eviction surface. The npm and container arms are sibling steps', and the reap of marked subtrees is S20-A's. AC numbers use the spec's auto-numbered list. Plan Step 28 is accepted against AC #101 and AC #102 (closed). The AC-98 and AC-100 rows below are the delete-side slices of read criteria: neither criterion's read half lands at this base, because the remote Maven file list and detail still answer the repository 404.

Acceptance criteria

# Criterion (this MR's slice) Tests
AC-98 (delete-side slice) A package-level Maven metadata row is not an addressable delete target TestMavenRemoteFileDelete_NotFoundMatrix ("package-level row carrying no version" row, survival asserted), TestMavenRemoteEvictionIntegration_PackageLevelRowIsUnaddressable, TestMavenRemoteEvictionResolver_FindMavenRemoteFileInRepository ("the package-level version-less row misses"), TestFindMavenRemoteFileInRepositoryStmt_SQL (the IS NOT NULL leg)
AC-99 Every artifact route on a virtual repository returns 404 (these three DELETE routes' slice) the "virtual kind answers the repository 404" rows of TestMavenRemotePackageDelete_KindDispatch, TestMavenRemoteVersionDelete_KindDispatch, and TestMavenRemoteFileDelete_KindDispatch, plus TestMavenRemoteEvictionIntegration_VirtualKindAnswers404
AC-100 (delete-side slice) An evicted-but-unreaped (marked) row is not re-deletable, so a repeat delete answers 404 TestMavenRemotePackageDelete_SecondDeleteIs404, TestMavenRemoteVersionDelete_SecondDeleteIs404, TestMavenRemoteFileDelete_SecondDeleteIs404, the "already-marked" rows of all three _NotFoundMatrix tests, the second-DELETE steps of the three TestMavenRemoteEvictionIntegration_*EvictWalk tests, and the marked-row and marked-parent misses in TestMavenRemoteEvictionResolver_FindMavenRemote{Package,Version,File}InRepository
AC-101 DELETE on a remote package, version, and file returns 202 with an empty body TestMavenRemotePackageDelete_EvictsAndAnswers202, TestMavenRemoteVersionDelete_EvictsAndAnswers202, TestMavenRemoteFileDelete_EvictsAndAnswers202, the 202 steps of the three TestMavenRemoteEvictionIntegration_*EvictWalk tests, the _ResponsesMatchOpenAPIContract 202 rows
AC-101 The target disappears from reads (the coordinate read is the remote read surface on this base) the LookupRow miss assertions in TestMavenRemoteEvictionIntegration_PackageEvictWalk, _VersionEvictWalk, and _FileEvictWalk (with the sibling and package-level coordinates still resolving where the mark is scoped)
AC-101 The request sends no outbound upstream request the zero-hit counting-upstream assertion (requireNoOutboundAndNoEvents) closing every TestMavenRemoteEvictionIntegration_* walk
AC-101 A subsequent pull of the same coordinate re-caches as a fresh row the re-cache stage of TestMavenRemoteEvictionIntegration_PackageEvictWalk (UpsertCacheEntry succeeds after the mark, LookupRow resolves a fresh live row)
AC-102 Evictions emit no artifact_registry_artifact_deleted event the tracker-empty assertions in all three _EvictsAndAnswers202 tests, the remote rows of the three _KindDispatch tables, requireRemoteEvictRedHerringsAbsent across the miss and failure matrices, and every TestMavenRemoteEvictionIntegration_* walk
AC-102 The hosted deletes' emission is unchanged the hosted rows of the three _KindDispatch tables (exactly one event), TestMavenRemoteEvictionIntegration_HostedEmissionContrast (one hosted event beside zero eviction events, at the production wiring)

Error cases

Condition (this MR's slice) Tests
Artifact routes: repository format differs from the :format segment, 404 the "npm remote repository under the maven segment" rows of the three _NotFoundMatrix tests
Artifact routes: a kind the route does not serve, fail closed the virtual kind answers 404 (the virtual rows of the three _KindDispatch tables, TestMavenRemoteEvictionIntegration_VirtualKindAnswers404). A kind outside kindStrings answers the logged 500 at resolveArtifactRepositoryForFormat, before the dispatch runs (the unmapped-kind rows of the same three tables)
Artifact delete: target missing, non-canonical id, marked, or outside the URL's chain, 404 the three _NotFoundMatrix tests (missing id, malformed and non-canonical spellings, marked row, foreign repository, foreign namespace), TestMavenRemotePackageDelete_RejectsNonCanonicalIDs, the TestMavenRemoteEvictionResolver_* integration misses
Detail/Update/Delete: repository missing, 404 the "absent repository" rows of the three _NotFoundMatrix tests
All: unexpected server failure, 500, cause never in the body the three _FailureMatrix tests (child-row resolve error and miss, unexpected find error, nil-row violation, evictor failure), TestMavenRemoteFileDelete_ContractViolationBackstops, TestMavenRemote{Package,Version}Delete_EvictorFailureLogsThe*ID, TestMavenRemoteFileDelete_EvictorFailureLogsFileAndVersionIDs, TestMavenRemote{Package,Version,File}Delete_ResolveFailuresLogTheirIDs (each resolve's 500 carries the id its string omits, and the child-row break carries none), the _CanceledEvict_WritesNothing tests (cancellation writes nothing, logs no ERROR)
Wiring-bug guards: nil context, zero UUIDs, and an unconstructed store reject before any statement TestNewMavenRemoteEvictionResolver_NilClient, TestMavenRemoteEvictionResolver_ZeroValue, TestMavenRemoteEvictionResolver_FindMavenRemote{RepositoryID,PackageInRepository,VersionInRepository,FileInRepository}_Guards, TestNewHandler_PanicsOnNilDependency (four new rows)
A cancelled resolve surfaces the driver's error rather than the not-found sentinel TestMavenRemoteEvictionResolver_CancelledContext

Security considerations

Concern (this MR's slice) Tests
Tenant isolation and chain verification: an id under another remote repository or namespace answers 404 and the row survives (the evictor predicates scope on namespace and id alone, so the resolve is load-bearing) the foreign-repo and foreign-namespace survival rows of the three _NotFoundMatrix tests, TestMavenRemoteEvictionIntegration_AuthorizationScoping (six wrong-URL addressings against real SQL, rows probed live afterwards), the cross-repository and cross-namespace misses in the three TestMavenRemoteEvictionResolver_Find*InRepository integration tests, the chain-leg assertions in the four TestFindMavenRemote*Stmt_SQL tests
Existence hiding on writes: one static message per level, misses indistinguishable the envelope-message assertions across the three _NotFoundMatrix tests and _SecondDeleteIs404 tests (assertPackageError pins the exact static message, so no failure body varies with input)
A cancellation must not read as an absent row: the entity resolves' ErrNotFound is what the handler answers 404 with, so folding cancellation into it would report a live row as absent on a delete route TestMavenRemoteEvictionResolver_CancelledContext (asserts NotErrorIs(ErrNotFound) on all four reads)
Injection: all statements through the Jet builder with bound parameters the four TestFindMavenRemote*Stmt_SQL tests (placeholder emission and bind order asserted)

Database Review Evidence

No migration is added or modified, so migration mode does not apply. Query mode covers the four query-producing methods in internal/datastore/maven_remote_eviction_resolve.go that these routes run, all of which dispatch through instrumentQuery. Only FindMavenRemoteFileInRepository is new at the current base: the bulk pass landed the other three, and their plans are reported here because this MR is the first caller to run them on the delete routes.

Queries

Collected on PostgreSQL 17.10 against the migrated schema, seeded from the FK graph to 40 namespaces, 200 remote repositories, 68,000 packages, 184,000 versions, and 200,000 files. The addressed namespace's hash partition holds 60,200 packages, 121,600 versions, and 7,383 files, so the planner is not choosing among near-empty relations. Each EXPLAIN (ANALYZE, BUFFERS) ran inside a transaction that was rolled back, bound to rows that resolve, and is reported warm.

Method Plan Index Partitions Planning / Execution
FindMavenRemoteRepositoryID Index Scan unique_maven_remote_repositories_namespace_id_and_repository_id 1/64 0.090 ms / 0.021 ms
FindMavenRemotePackageInRepository Index Scan pk_maven_remote_packages 1/64 0.166 ms / 0.047 ms
FindMavenRemoteVersionInRepository Nested Loop over two Index Scans unique_maven_remote_versions_id_pkg_id_ns_id, pk_maven_remote_packages 1/64 anchor, 1/64 joined 0.634 ms / 0.072 ms
FindMavenRemoteFileInRepository Nested Loop over three Index Scans pk_maven_remote_files, unique_maven_remote_versions_id_pkg_id_ns_id, pk_maven_remote_packages 1/64 on all three 0.889 ms / 0.114 ms

Static pruning reaches the joined side, which is the part the statements do not bind directly. findMavenRemoteVersionInRepositoryStmt and findMavenRemoteFileInRepositoryStmt bind a literal namespace_id on the anchor table only, and the joined tables take their partition key from the equijoin column. The planner closes that gap on its own: from maven_remote_packages.namespace_id = maven_remote_versions.namespace_id and maven_remote_versions.namespace_id = <literal>, its equivalence class derives the literal for the joined side and prunes at plan time. Every scan node in all four plans names exactly one partition, so no plan touches more than one of the 64 per table. This is the mechanism the version statement's namespace_id join leg was written for, and it is now measured rather than assumed.

Independently reproduced, and the parameter-binding gap closed. A second collection run, seeded differently (one row per ancestor plus 5,000 rows in the read target), reached the same verdict: 1 of 64 on every side, and the joined table always resolving to the same partition number as its anchor. Two details it pins that the plans above do not. No Append node appears in any of the four plans, so the other 63 partitions are excluded at plan time rather than skipped at run time as never executed siblings. And the pruning survives real parameter binding: instrumentQuery dispatches through jet's stmt.QueryContext on database/sql's unnamed-statement path, which always plans with the bound values, so production gets a custom plan and never a generic one. That matters because the plans above were captured with the values inlined, and a generic plan would prune at run time instead. enable_partitionwise_join is off in both runs, so this is equivalence-class propagation rather than partitionwise join.

Where the two runs' plans differ they agree on cause: at a one-row ancestor volume the joined side of the version and file queries comes back as a sequential scan over a single-row partition, a cost choice rather than weaker pruning, and it returns to an index scan once the ancestor tables carry realistic volume, which is the state the table above measures.

Every plan is a bounded index lookup ending in LIMIT 1, with 1 to 11 shared buffer hits and no sequential scan, sort, or bitmap heap scan. Each of the three DELETE routes runs exactly two before its composer: the shared FindMavenRemoteRepositoryID child-row resolve, then its own entity resolve.

Test plan

Every gate below was run on the final tree and is green.

  • gofmt, go build ./..., go vet ./..., and go vet -tags=integration ./...
  • golangci-lint 2.12.2 plain on the touched packages: 0 issues. Integration-tagged with --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false over ./internal/managementapi/... ./internal/datastore/... ./cmd/artifact-registry/..., which CI lint cannot see: 0 findings across all 32 files this MR changes, on the branch tree and on the tree it merges into. Reported paths were resolved and anything outside the worktree dropped before counting, because the walker escapes into sibling worktrees through an untracked symlink and a raw repo-wide total is not reproducible from a worktree.
  • Full unit suite, go test ./... -count=1: 65 packages, 0 failures, with internal/storage/driver/s3 excluded because its conformance suite needs an S3 endpoint.
  • go test -tags=integration ./internal/managementapi/ (55s), ./internal/datastore/ (479s), and ./cmd/artifact-registry/ -run 'Boot|Wire|Wiring' (19s), against PostgreSQL 17.10 tuned with max_locks_per_transaction=4096. The stock setting fails roughly 95 pre-existing tests with SQLSTATE 53200, tracked in Local testcontainers Postgres needs max_locks_p... (#657) • Unassigned.
  • TestEveryStatementIsInstrumented, which covers the four new query names.
  • The run recipe's driver.sh smoke against a real Postgres, MinIO, and Redis: 25 checks passed, 0 failed. Run from this branch's own copy of the script, because the driver resolves its repository root from the script's location and a copy outside the worktree builds another checkout's code.

No e2e scenario is added, and none is affected. docs/testing/e2e/maven.md's Lifecycle group covers hosted deletes, and every row in it is already blocked on surfaces landing across this phase. Remote eviction is blocked end to end upstream of the read path: internal/managementapi/create.go answers 422 for any kind but hosted (kind must be hosted; virtual and remote repositories are not yet supported), so no remote repository can be created to evict from, and a row added now would be born blocked beside the others. The read half is further along than that framing suggests. all three Maven remote read arms share one predicate, remoteFillWired, and buildMavenDispatcher sets its seams, so the artifact, maven-metadata.xml, and checksum-sidecar fills all serve in production and an evicted coordinate re-caches on the next pull. internal/format/maven/remote_stub.go now holds the partially-wired guards' 501 detail strings and writeNotImplemented, the shared 501 writer the virtual branch of serveDownload calls. The integration walks in this MR assert that claim at the datastore and handler level meanwhile.

Conformance suites are unaffected. This MR changes management API routes under /api/v1/, not the Maven wire protocol.

The //nolint tokens this branch adds or inherits were measured rather than assumed: dupl on the three mirrored suites now carries 13 directives and reports 13 findings when all 13 are stripped. Two directives on the cancellation tests suppressed nothing, because the directives on the functions above them already silence the reported spans, and both are gone. A thelper token copied in from a sibling suppressed nothing and was removed. requireDeps takes maintidx alongside its four existing exemptions, and all five were dropped one at a time and re-run: each fired. requireArtifactWriteDeps takes main's cyclop,funlen,gocognit,gocyclo, and each of the four was dropped in turn and re-run: each fired. The nil-sweep test takes a new maintidx, because main's container remote eviction seams and this branch's Maven ones together put its maintainability index at 19 against the bound of 20. The rebase also paired the Maven package and container image not-found matrices, which duplicate only once both sit in one tree, so each side takes a dupl directive.

Diff size

4410 added lines, 4909 counting removals, past the 500 docs/dev/development-model.md asks a justification for.

Splitting by route does not help. The three arms share one chain resolver, one Deps block, and one wiring value, so a per-route split lands the resolver with whichever arm goes first and leaves the other two reviewing a security boundary they cannot exercise. Splitting the resolver out on its own lands a store with no caller, which is the shape the plan's step boundaries already avoid. This step is the smallest unit that compiles with its security boundary attached.

Group Added Removed
Production 626 350
Unit tests 2720 140
Integration tests 1064 9

Tests are 86% of the added lines. The production half is 626 added lines across 14 files. It shrank when the bulk pass merged, because the resolver and its guard tests are now main's and this step adds the file arm on top of them. Six of the 14 are comment-only corrections in files this step otherwise leaves alone, made under CLAUDE.md guardrail 17.

Related to #314

Edited by Hayley Swimelar

Merge request reports

Loading
Loading