chore(datastore): add the maven remote row-locked update
Why
Maven's remote store has the create and details halves but no update path, so the remote settings PATCH (plan Step 23) has no Maven store to dispatch to. This MR completes the store pair with the row-locked update: field-wise patch semantics, the in-store credential clear on URL change returning remote.CredentialAction, and the in-transaction health reset. Plan: 2026-08-13-s17-phase6-virtual-remote-repositories.md, Step 5. Spec: S17-rest-management-api.md. The create half merged in chore(datastore): add the maven remote create a... (!1579 - merged) • Hayley Swimelar • 19.3.
What's non-obvious
- A URL change is a normalized-value change: the supplied URL passes through
remote.ParseUpstreamBasebefore both the comparison and the write, so a respelling of the stored value is a no-op success (Changed=false, no clear, no reset, no audit event). npm's landed update is the precedent. - The update input carries
MetadataCacheValidityHoursandSnapshotMetadataAlwaysRevalidate. npm's landed input now carries the metadata window too:3ef2294fadded it toUpdateNpmRemoteRepositoryInput, toisZero(), and tonpmRemoteUpdateAssignments. The spec marks both fields mutable for Maven. - The store input has no explicit-credential-clear arm: the spec's
credentials: nulltri-state clear is handler-side composition (Step 23), matching npm's landed store shape. The store clears only on URL change, and it reportsremote.CredentialClearon a URL change even when no pair was stored, theremote.CredentialManager.ApplyURLChangesemantics. - The health reset rides the URL change's own UPDATE and is unconditional on the credential arm: a same-request pair keeps the credentials and still resets health. It is not durable against a probe already in flight, because the compare-and-set carries no url. Nulling
last_health_checked_atwidens the exposure to every probe rather than causing it, since a probe stamps itself one line before the call and the guard would not have refused it under the old stamp either. The method doc carries the window and names both twins: container documents it, and npm carries the same reset and guard without a note. The failure-counter reset and the eviction enqueue are handler concerns, both Step 34. - No actor field on the input, matching the landed create. The management audit seam (plan Step 3) has since merged: it added
Actortoremote.CredentialWriteEventand leftremote.RecordCredentialWritewithout an actor parameter, so this emit compiles untouched and no merge order enforces the thread either way. The obligation stays recorded on the seam ininternal/remote/audit.goand in Complete the S03-A/S13 contract for the credent... (#557) • Unassigned. - The URL-change NULLs are built per call with
pg.CAST(pg.NULL)rather thanpg.StringExp(pg.NULL). Those wrappers callsetRooton what they wrap, so wrapping the one sharedpg.NULLmutates a process-global and races under-race. fix(datastore): stop nullable binds from wrappi... (!1710 - merged) • João Pereira • 19.4 converges npm's, container's, and the remainingmainsites onto the same call. - Error wraps are identifier-free per the create's convention, deliberately diverging from npm's grandfathered identifier-carrying wraps.
- The new
FOR UPDATElock statement's partition pruning is pinned byTestMavenRemoteRepositoryStore_LockMavenRemoteRepository_PrunesToOnePartition, standing in for posted EXPLAIN output.
Size
1911 added lines: production 550 (maven_remote_repositories_update.go) and 2 (query_names.go), integration tests 1073, unit tests 247, explain pin 39. The plan scopes the update as one step mirroring npm's landed update, and splitting the suite from the store would sever the npm dropped-subtest diff from the code it reviews.
Test plan
Unit suites (TestUpdateMavenRemoteRepositoryStmt SET-order and binding pins, argument guards) and the integration battery (go test -tags=integration -count=1 ./internal/datastore/ -run 'TestMavenRemoteRepositoryStore_'): 16 behavior subtests (clear and reset only on URL change, same-request pair survival, respelled-URL no-op, the two-updater lock race), 12 row gates (every format and kind as a positive ErrNotFound hit, with a same-namespace decoy so an uncorrelated gate fails the suite), 10 credential-audit cases including the caller-rollback strand, 9 argument guards, and the lock-statement pruning pin. Zero dropped subtests against npm's update suites. golangci-lint 2.12 clean in both modes on the step's files.
Spec coverage
Spec: docs/specs/S17-rest-management-api.md Plan: docs/plans/2026-08-13-s17-phase6-virtual-remote-repositories.md, Step 5
Scope: the internal/datastore Maven remote-repository update. Acceptance
criteria are numbered by position (123 total); the Phase 6 block opens at 68.
Acceptance criteria
| # | Criterion (store-layer share) | Tests |
|---|---|---|
| AC-73 | A create or update with credentials stores them and never the values; a one-sided pair and an over-2048 value are refused | Update share: TestMavenRemoteRepositoryStore_UpdateMavenRemoteRepository ("a new pair supplied with the url change replaces instead of clearing", "replaces the pair without a url change"), ..._RowGates ("rejects an over-cap credential value before the row is touched"), ..._UpdateMavenRemoteRepository_ArgumentGuards (empty pair, half pair, over-cap, all through validateMavenRemoteCredentialPair). The 400 mapping and has_credentials serialization are Steps 11, 12, and 24; the create share is Step 4 |
| AC-75 | PATCH merges settings by field: a field absent from the body is unchanged |
..._UpdateMavenRemoteRepository ("writes an explicit cache validity", "writes an explicit metadata window", "writes the snapshot revalidation flag", "keeps the stored cache validity across a url change", where create-time resolution is not re-run on update), ..._UpdateCredentialWriteAudit ("an update that leaves the credential columns alone emits nothing"), TestUpdateMavenRemoteRepositoryStmt (a window-only patch renders only that column). The credentials: null explicit clear is handler-side composition, Steps 23-24 |
| AC-76 | PATCH changing url without credentials clears the stored pair in the same transaction and emits the audit event; changing both stores the new pair and reports no clear |
..._UpdateMavenRemoteRepository ("clears the credential pair on a url change with no new pair", "a new pair supplied with the url change replaces instead of clearing", "a respelled url is not a change", "reports a clear on a url change even when no pair was stored", "the locked read serializes two updaters onto fresh state" (one clear, not two), "a rolled-back transaction leaves the row untouched"), ..._UpdateCredentialWriteAudit (both clear rows), TestUpdateMavenRemoteRepositoryStmt (the clear shares the url write's statement). has_credentials: false serialization is Steps 12 and 24 and credentials_cleared: true is Step 34; the container auth-memo clear is Step 7 |
| AC-77 | PATCH changing url resets last_health_status to unknown and last_health_checked_at to null in the same transaction |
..._UpdateMavenRemoteRepository ("a url change resets the stored health verdict", "a url change with a same-request pair still resets health", "a non-url patch leaves the health columns untouched", "a respelled url leaves the health columns untouched"), TestUpdateMavenRemoteRepositoryStmt (the reset rides the url change's own SET list). The unprobed response body is Steps 23-24, and the post-commit failure-counter reset is Step 34 |
| AC-79 | Update emits one audit event naming the actor; every credential write emits S13's credential event | Store share (the update's credential events): ..._UpdateCredentialWriteAudit (clear with and without a stored pair, set, replace, empty and half-empty stored pairs read absent so a supply is a set, nothing on a window-only write, nothing on a no-op, nothing on a refusal); the two lock-race subtests pin set-versus-replace under concurrency. The repository-update event with the actor and the previous and new URL values is Steps 3 and 24; the create-side credential events are Step 4's suite |
| AC-83 | An invalid url returns 400 on update |
Store share: ..._UpdateMavenRemoteRepository_ArgumentGuards ("invalid url": a query string is refused through remote.ParseUpstreamBase before the row lock). The reject set is the merged remote suite's; the 400 is Steps 11 and 24 |
| AC-74, AC-84 | Details reads and the create-time Central pin | Step 4 (merged); the update's "keeps the stored cache validity across a url change" subtest pins that the pin is not re-derived here |
| AC-1 to AC-72, AC-78, AC-80 to AC-82, AC-85 to AC-123 | Phases 1-4, the Phase 6 OpenAPI document, creates, the every-kind cap, eviction enqueue on URL change, associations, connection test, remote reads and evictions, and Phase 8 statistics | Not this step. Step 5 is the Maven remote datastore update only; the container twins are Steps 6-7, npm's update backfill (metadata window and health reset) is Step 8, handlers are Steps 10-12 and 23-24 |
Error cases
| Condition | Tests |
|---|---|
Detail, Update, Delete: repository missing returns 404 |
Update share (ErrNotFound): ..._RowGates (missing binding, soft-deleted parent, npm-, docker-, and oci-format parents, hosted and virtual kinds, cross-namespace; every gated row keeps its stored pair, and the parent-gate cases seed a valid same-namespace decoy so an uncorrelated gate fails the suite) |
Update: invalid url, a one-sided or over-long credential value, an out-of-range cache window return 400 |
Store share (the sentinels the handler maps): ..._UpdateMavenRemoteRepository_ArgumentGuards, ..._RowGates ("maps a CHECK refusal without leaking the failing row", "maps a metadata-window CHECK refusal to the same sentinel", "a transient failure is not misreported as ErrNotFound"). The envelope and status are Steps 11 and 24 |
Every other row (auth, routing, list, delete, PATCH body validation, associations, connection test, statistics) |
Not this step: HTTP-layer conditions owned by their route steps |
Security considerations
| Concern | Tests |
|---|---|
| Injection: all queries use the Jet builder with bound parameters | TestUpdateMavenRemoteRepositoryStmt asserts every value binds as an ordered arg and the partition key sits in the predicate |
Tenant isolation: every query scoped by namespace_id, the partition key |
"does not update a binding from a different namespace", the WHERE pin in TestUpdateMavenRemoteRepositoryStmt |
| Upstream credentials: write-only, cleared on URL change, audited on every write, absent from logs and error bodies | The clearing subtests and ..._UpdateCredentialWriteAudit; "maps a CHECK refusal without leaking the failing row" (requireNoPgErrorInChain plus the undisclosed-value assertion); every refused update asserts the stored pair survived |
SSRF: the static covers run at update through remote.ParseUpstreamBase |
..._UpdateMavenRemoteRepository_ArgumentGuards "invalid url" (refused before the row lock); the cover set itself is the merged remote suite's |
| URL retarget and the cache: the eviction pass on a committed URL change | Not this step: the enqueue and its 503 rule are Steps 23-24. The store share is the normalized URL-change comparison the pass keys on, pinned above |
| Auth, existence hiding, write authorization, statistics authorization | Handler and platform layers; not this step |
Context for LLM agents
Rationale
- An explicit-clear arm on the store input. Rejected: npm's landed store routes the tri-state null through the handler (Step 23), and diverging here would fork the per-format store contract the handler dispatches over.
- Re-running the Central-pin resolution when the URL changes to a known-immutable host. Rejected: the spec words the pin as a create-time default, and the update's field-wise contract writes only what the caller supplied.
- An actor field on the update input. Deferred, not rejected:
remote.RecordCredentialWritestill takes no actor parameter, and the merged audit seam did not add one, so threading the actor through every credential write path stays open work rather than something a merge order forces.
Non-goals
- Handler, route, or OpenAPI wiring. Steps 11, 12, 23, 24, and 34 consume this store.
- The failure-counter reset and the eviction enqueue on URL change. Handler-side, Step 34.
- npm's metadata-window backfill and health reset. Step 8.
- The container twins. Steps 6 and 7.
- The plan's Status-table row. A dedicated single-writer MR fills it for the whole fan-out.
- The endemic contextcheck class in pre-existing sibling integration suites. CI-lint-blind, untouched.
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17.10 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.lockMavenRemoteRepositoryStmt |
LockRows | unique_maven_remote_repositories_namespace_id_and_repository_id, index_repositories_on_namespace_id_id_format_remotes |
1 / 1 | 16.64 | 0.024ms | 7 / 0 | 1/64 maven_remote_repositories, 1/64 repositories |
datastore.updateMavenRemoteRepositoryStmt.UrlChangeClear |
Update | n/a (Seq Scan, see notes) | 0 / 0 | 1.75 | 0.085ms | 16 / 0 | 1/64 maven_remote_repositories |
datastore.updateMavenRemoteRepositoryStmt.WindowOnly |
Update | n/a (Seq Scan, see notes) | 0 / 0 | 1.75 | 0.087ms | 16 / 0 | 1/64 maven_remote_repositories |
Query notes:
- Both
Updaterows report0 / 0because the statement returns no rows. The driving scan matched 1 row of the 50 seeded, in the pruned partition. - The Seq Scan on both
Updaterows is the write-target seed size (1 target row plus 49 siblings), not a missing index. Re-running the url-change statement against 5000 seeded rows switches the plan to an Index Scan onpk_maven_remote_repositories(cost 8.30, actual 0.013 ms), still pruned to one partition. - The
Trigger for constraint fk_maven_remote_repositories_*lines in the twoUpdateplans are an artifact of seeding inside the measured transaction. PostgreSQL forces the referential check when the row was inserted by the current transaction, even though this UPDATE changes neitherrepository_idnornamespace_id. Committing the seed first and measuring the UPDATE in its own transaction, which is the production shape, emits no trigger lines and drops execution from 0.852 ms to 0.231 ms. Read the trigger time (0.6 ms to 8.5 ms across runs, dominated by the RI check's own plan cache) as measurement overhead rather than statement cost.
datastore.lockMavenRemoteRepositoryStmt
Summary: Plan matches the method's intent: the FOR UPDATE read resolves through the unique index on (namespace_id, repository_id) and the parent gate runs as a Nested Loop Semi Join over index_repositories_on_namespace_id_id_format_remotes, with both tables pruned to one of 64 partitions. Estimates match reality (1 / 1) and execution is 0.045 ms against 5000 seeded bindings. LockRows sits above the join, so the lock covers the maven_remote_repositories row only, which is what choosing an EXISTS predicate over the reads' JOIN buys. No anomalies.
Seed shape: namespaces=1, repositories=5000, maven_remote_repositories=5000
Rendered SQL:
SELECT maven_remote_repositories.id AS "maven_remote_repositories.id",
maven_remote_repositories.url AS "maven_remote_repositories.url",
((((maven_remote_repositories.tmp_plaintext_username IS NOT NULL) AND (maven_remote_repositories.tmp_plaintext_username != $1::text)) AND (maven_remote_repositories.tmp_plaintext_password IS NOT NULL)) AND (maven_remote_repositories.tmp_plaintext_password != $2::text)) AS "has_credentials"
FROM public.maven_remote_repositories
WHERE ((maven_remote_repositories.namespace_id = $3::uuid) AND (maven_remote_repositories.repository_id = $4::uuid)) AND (EXISTS (
SELECT $5
FROM public.repositories
WHERE ((((repositories.namespace_id = $6::uuid) AND (repositories.format = $7)) AND (repositories.kind = $8)) AND (repositories.soft_deleted_at IS NULL)) AND (repositories.id = maven_remote_repositories.repository_id)
))
FOR UPDATE;Bound args: ['', '', '9065602d-b3a1-422f-8b0e-7010cb193cf7', 'a2d89716-e162-456d-a650-5ed30b84057b', 1, '9065602d-b3a1-422f-8b0e-7010cb193cf7', 1, 2]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
LockRows (cost=0.56..16.64 rows=1 width=95) (actual time=0.023..0.024 rows=1 loops=1)
Buffers: shared hit=7
-> Nested Loop Semi Join (cost=0.56..16.62 rows=1 width=95) (actual time=0.020..0.020 rows=1 loops=1)
Buffers: shared hit=6
-> Index Scan using maven_remote_repositories_p50_namespace_id_repository_id_idx on maven_remote_repositories_p50 maven_remote_repositories (cost=0.28..8.30 rows=1 width=104) (actual time=0.009..0.010 rows=1 loops=1)
Index Cond: ((namespace_id = '9065602d-b3a1-422f-8b0e-7010cb193cf7'::uuid) AND (repository_id = 'a2d89716-e162-456d-a650-5ed30b84057b'::uuid))
Buffers: shared hit=3
-> Index Scan using repositories_p50_namespace_id_id_format_idx on repositories_p50 repositories (cost=0.28..8.31 rows=1 width=26) (actual time=0.008..0.008 rows=1 loops=1)
Index Cond: ((namespace_id = '9065602d-b3a1-422f-8b0e-7010cb193cf7'::uuid) AND (id = 'a2d89716-e162-456d-a650-5ed30b84057b'::uuid) AND (format = '1'::smallint))
Filter: ((soft_deleted_at IS NULL) AND (kind = '2'::smallint))
Buffers: shared hit=3
Planning:
Buffers: shared hit=254
Planning Time: 1.126 ms
Execution Time: 0.045 msTimings: planning 1.126ms, execution 0.045ms, total 1.171ms.
datastore.updateMavenRemoteRepositoryStmt.UrlChangeClear
Summary: Plan matches intent: the partition key prunes to one of 64 partitions, and the url write, the credential zeroing, and the health reset render as one statement over one row. The Seq Scan is the seed size rather than a missing index. A write target is seeded with 50 rows, which fits a single page, and a supplementary 5000-row run of this same statement chose an Index Scan on pk_maven_remote_repositories (cost 8.30, 0.013 ms). No anomalies.
Seed shape: namespaces=1, repositories=50, maven_remote_repositories=50
Rendered SQL:
UPDATE public.maven_remote_repositories
SET url = $1::text,
tmp_plaintext_username = NULL,
tmp_plaintext_password = NULL,
last_health_status = $2,
last_health_checked_at = NULL
WHERE (maven_remote_repositories.namespace_id = $3::uuid) AND (maven_remote_repositories.id = $4::uuid);Bound args: ['https://repo.example.test/maven2', 0, 'aa64f563-08c9-4a6c-8bbd-763301bfe897', '6385d734-e058-4a23-ab06-9d5ccd1b8ee9']
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Update on maven_remote_repositories (cost=0.00..1.75 rows=0 width=0) (actual time=0.085..0.085 rows=0 loops=1)
Update on maven_remote_repositories_p34 maven_remote_repositories_1
Buffers: shared hit=16
-> Seq Scan on maven_remote_repositories_p34 maven_remote_repositories_1 (cost=0.00..1.75 rows=1 width=116) (actual time=0.004..0.006 rows=1 loops=1)
Filter: ((namespace_id = 'aa64f563-08c9-4a6c-8bbd-763301bfe897'::uuid) AND (id = '6385d734-e058-4a23-ab06-9d5ccd1b8ee9'::uuid))
Rows Removed by Filter: 49
Buffers: shared hit=1
Planning:
Buffers: shared hit=45
Planning Time: 0.290 ms
Trigger for constraint fk_maven_remote_repositories_namespace_id_namespaces on maven_remote_repositories_p34: time=0.021 calls=1
Trigger for constraint fk_maven_remote_repositories_repository_id_repositories on maven_remote_repositories_p34: time=0.593 calls=1
Execution Time: 0.852 msTimings: planning 0.290ms, execution 0.852ms, total 1.142ms.
datastore.updateMavenRemoteRepositoryStmt.WindowOnly
Summary: The narrowest SET list the builder produces, rendered against the same WHERE. Identical access path and cost to the url-change arm, which is the point of running both: the dynamic SET list changes what is written, not how the row is found. Pruned to one of 64 partitions. No anomalies.
Seed shape: namespaces=1, repositories=50, maven_remote_repositories=50
Rendered SQL:
UPDATE public.maven_remote_repositories
SET metadata_cache_validity_hours = $1
WHERE (maven_remote_repositories.namespace_id = $2::uuid) AND (maven_remote_repositories.id = $3::uuid);Bound args: [6, '87039fa0-64ec-4b54-8dca-5b8dc8fcad9c', '1f6e2531-5d79-4fd5-8ed9-f2609c6bace9']
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Update on maven_remote_repositories (cost=0.00..1.75 rows=0 width=0) (actual time=0.086..0.087 rows=0 loops=1)
Update on maven_remote_repositories_p33 maven_remote_repositories_1
Buffers: shared hit=16
-> Seq Scan on maven_remote_repositories_p33 maven_remote_repositories_1 (cost=0.00..1.75 rows=1 width=12) (actual time=0.004..0.006 rows=1 loops=1)
Filter: ((namespace_id = '87039fa0-64ec-4b54-8dca-5b8dc8fcad9c'::uuid) AND (id = '1f6e2531-5d79-4fd5-8ed9-f2609c6bace9'::uuid))
Rows Removed by Filter: 49
Buffers: shared hit=1
Planning:
Buffers: shared hit=46
Planning Time: 0.291 ms
Trigger for constraint fk_maven_remote_repositories_namespace_id_namespaces on maven_remote_repositories_p33: time=0.022 calls=1
Trigger for constraint fk_maven_remote_repositories_repository_id_repositories on maven_remote_repositories_p33: time=0.597 calls=1
Execution Time: 0.858 msTimings: planning 0.291ms, execution 0.858ms, total 1.149ms.
Related to #314