feat(maven): wrap metadata reconciler in a River background job
What
Step 10 of the S10 Maven hosted plan: the async layer over the Step 9
MetadataReconciler. Wraps Reconcile in a River background job so a version
soft-delete (S20, forthcoming) can enqueue package-level maven-metadata.xml
reconciliation that survives a process crash and dedupes per Maven package.
Ships the maven-package seam — four symbols in
internal/format/maven/reconciler_job.go:
ReconcileArgs{NamespaceID, RepositoryID, PackageID uuid.UUID}— the job payload;Kind() == "MavenMetadataReconciler"(stable routing / dedup key).reconcilerWorker—river.Worker[ReconcileArgs]; delegates toReconcile, recovers a panic (logsargs/panic/stackvia the request-scoped logger, then cancels) so one poisoned package cannot crash the worker pool.EnqueueReconcile(ctx, *jobsriver.Client, *sql.Tx, ReconcileArgs)— the enqueue helper S20 calls inside its soft-delete transaction; atomic with the caller's*sql.Tx, deduped viaWithUnique(UniqueByArgs()).RegisterMavenReconcilerJob(*jobsriver.Client, MetadataReconciler)— worker registration for the composition root.
Deferred to S20 (surfaced, not built here)
The plan's Files: for Step 10 listed cmd/artifact-registry/main.go (register
the worker) and docs/dev/background-jobs.md. Both are deferred:
- Maven is not production-mounted until Step 10b (!916 (merged), open), whose
buildMavenDispatcherconstructs only the HTTP dispatcher — nomaven.Reconciler. wire.gocallswireJobs(whereRegisterWorkermust run, beforeRiver.Start) before the Maven store / BlobStore dependencies exist, so registering the worker needs composition-root restructuring — and there is no enqueuer (S20) yet. Wiring it now would be speculative plumbing that conflicts with the open Step 10b.
RegisterMavenReconcilerJob is ready for S20 to call; S20 owns resolving the
registration ordering and the background-jobs.md entry.
Decisions
- depguard /
*sql.Tx.EnqueueReconcile's plan-mandated*sql.Tx(required byjobsriver.EnqueueTx, atomic with the caller's transaction per S27 AC #5) forces adatabase/sqlimport that ADR-023'sno-direct-dbrule blocks in format packages. Resolved with a file-scoped.golangci.yamlexception — matching this repo's convention of file-globbed depguard rules (as with theauth-bootstrapexemption onmain.go), not an inline nolint. The import threads a transaction handle to the jobs framework; it issues no SQL. - Worker sentinel → River result mapping.
nil/ErrNoMetadata→ complete (nothing to reconcile is a benign no-op);ErrInvalidMetadata/ErrEmptyVersionSet→river.JobCancel(terminal — a DOCTYPE rejection is deterministic and an empty set will not grow on retry); anything else → returned so River retries. TheErrEmptyVersionSetorphan-row cleanup stays S20's soft-delete-cascade concern; the worker holds only aMetadataReconciler(no store) by design.
Testing
3 unit tests + 7 integration tests (River backend via testcontainers). The full
Maven integration suite passes locally
(go test -tags integration ./internal/format/maven/..., green), and the Step 10
job tests run against a real River/PostgreSQL backend.
## Spec coverage
Spec: docs/specs/S10-maven-hosted.md (Async work; Background-job amplification; Reconciliation on deletion)
Plan: docs/plans/2026-05-12-maven-hosted.md (Step 10)
Step 10 wraps the Step 9 MetadataReconciler in a background job. It owns the
async delivery contract (enqueue, dedup, idempotent execution, panic
resilience, drain); the reconcile logic and its error sentinels are Step 9's,
verified in reconciler_test.go. Tests marked (integration) require
`-tags integration` + a live PostgreSQL (testcontainers).
### Acceptance criteria
| # | Criterion | Tests |
|--------|-----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| AC 31 | `Reconcile` after `SoftDeleteVersion` rewrites metadata to live set | Rewrite logic owned by Step 9 (`TestReconciler_Reconcile_RewritesDroppedVersion`); async delivery to `Reconcile` with exact coordinates re-verified by `TestReconcileJob_EnqueueAndProcess` (integration) |
| AC 32 | No-op fast path on coherent metadata writes no blob | Step 9 (`TestReconciler_Reconcile_CoherentMetadataNoOp`); re-verified via the async path (duplicate delivery, zero blob write, zero row mutation) by `TestReconcileJob_IdempotentNoOpOnCoherentState` (integration) |
| AC 33 | No metadata row returns `ErrNoMetadata`, no row creation | Owned by Step 9 (`TestReconciler_Reconcile_NoMetadataRow`); worker-level sentinel handling is a reconcile-service concern, not asserted here |
| AC 1–30| Download / upload / sidecar / path-traversal / envelope behavior | Owned by Steps 5–8b; not in Step 10 scope |
### Async-work contract (spec "Async work")
| Property | Tests |
|--------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
| Stable job kind `MavenMetadataReconciler` (durable routing/dedup key) | `TestReconcileArgs_Kind` |
| Worker unmarshals args, delegates to `Reconcile` with exact coords; transient errors retry | `TestReconcilerWorker_DelegatesToReconciler`; `TestReconcileJob_EnqueueAndProcess` (integration) |
| Enqueue atomic with the caller's `*sql.Tx` (rolled-back tx enqueues nothing) | `TestReconcileJob_EnqueueRollbackNotProcessed` (integration); `EnqueueTx` atomicity is jobsriver-owned |
| Idempotent execution under at-least-once delivery (no-op fast path on re-run) | `TestReconcileJob_IdempotentNoOpOnCoherentState` (integration) |
| Panic in one reconcile recovered + logged (args, panic, stack); pool survives | `TestReconcilerWorker_PanicRecovered`; `TestReconcileJob_PanicDoesNotKillPool` (integration) |
| In-flight reconcile drains within the framework deadline on shutdown | `TestReconcileJob_DrainCompletesInFlight` (integration) |
### Security considerations
| Concern | Tests |
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
| Background-job amplification (T-06): per-package enqueue dedup bounds worker-pool amplification | `TestReconcileJob_PerPackageDedup` (integration) — two enqueues for the same package collapse to one; a distinct package is not deduped |
| Background-job amplification (T-06): `max_metadata_size` (10 MB) + `max_versions_per_package` (25,000) | Enforced in the reconcile service (Step 9): `TestReconciler_Reconcile_OversizedMetadataRejected`, `TestReconciler_Reconcile_TooManyLiveVersionsRejected` |
| `maven-metadata.xml` DOCTYPE / billion-laughs rejection on the reconcile parse path | Owned by Step 9 (`TestReconciler_Reconcile_DoctypeRejected`) |
| Path traversal, coordinate validation, `detail` hygiene, 404 info disclosure, body size, slowloris, sidecar bounds, FIPS MD5, delivery-mode dispatch, storage tenancy, audit trail | Owned by Steps 5–9; not in Step 10 scope |
### Error cases
| Condition | Tests |
|------------------------------------------------------------------------------------|------------------------------------------------------------------------------|
| Enqueue when the backend is unavailable → error surfaced to caller | jobsriver-owned (`ErrBackendUnavailable`); inherited by `EnqueueReconcile` |
| Handler error cases (400/401/403/404/405/409/413/416/422/429/500 envelopes) | Owned by Steps 5–8b; not in Step 10 scope |Related to #21 (closed)