authserver/storage: add UpdateDCRCredentialsIfPresent - #6674
Conversation
DCRCredentialStore was create-only: StoreDCRCredentialsIfAbsent silently no-ops on an existing key, so a storage decorator that rewrites a DCR record's persisted representation at rest (re-encoding, compression, a checksum) had no supported write path to persist a changed copy back. Add a presence-gated, CAS-style update symmetric with the existing IfAbsent naming: - Interface method returning wrapped ErrNotFound when no row exists, so an update racing a delete or a not-yet-created record fails loudly rather than silently creating. - MemoryStorage: map overwrite guarded by physical presence. - RedisStorage: WATCH/MULTI compare-and-set reusing the existing marshal/TTL-derivation path, so the rewritten row honors the same ClientSecretExpiresAt TTL contract as StoreDCRCredentialsIfAbsent. Presence is physical, not liveness: unlike the IfAbsent claim (which treats an expired row as absent to reclaim a dead slot), Update accepts an expired-but-present row so the Get->transform->Update round-trip works, matching GetDCRCredentials's own no-expiry-filter semantics. Implements #6673 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address code review feedback on UpdateDCRCredentialsIfPresent: - MEDIUM: cover the WATCH/MULTI retry loop unique to the update path. Unlike the create claim (losers take the read-only branch), every concurrent updater writes inside MULTI, so the new test drives real redis.TxFailedErr contention and asserts the loop retries, converges on one candidate, and never reports a present row as ErrNotFound. - LOW: add a Redis defensive-copy-isolates-caller test mirroring the memory backend's, and use requireRedisNotFoundError in the Redis unit tests for consistency with the rest of the Redis suite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6674 +/- ##
==========================================
+ Coverage 79.11% 79.15% +0.03%
==========================================
Files 785 789 +4
Lines 78460 79048 +588
==========================================
+ Hits 62076 62567 +491
- Misses 16379 16476 +97
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: concurrency, api-design, test-coverage, general-quality
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| 1 | Retry bound reused for Update's heavier contention profile | 9/10 | MEDIUM | Fix |
| 2 | Interface "Defensive copy" doc section not updated | 9/10 | MEDIUM | Fix |
| 3 | Redis GET-error branch untested | 9/10 | MEDIUM | Fix |
| 4 | Presence check uses GET instead of EXISTS | 8/10 | MEDIUM | Fix |
| 5 | Integration test's TTL claim unverified at wire level | 8/10 | MEDIUM | Fix |
| 6 | No backoff/jitter between retries | 7/10 | LOW | Fix (optional) |
| 7 | maxDCRClaimRetries comment names only one caller | 7/10 | LOW | Fix |
| 8 | UpdateTTL test omits past-expiry assertion | 7/10 | LOW | Fix |
| 9 | "Why key embedded" doc section names only one method | 7/10 | LOW | Fix (optional) |
| 10 | PR body mischaracterizes integration test as "miniredis" | 7/10 | LOW | Fix |
| 11 | New method has no production caller yet | 7/10 | INFO | Note |
| 12 | GET result intentionally discarded (confirmed correct) | 7/10 | INFO | Note |
Overall
This PR adds UpdateDCRCredentialsIfPresent to the DCRCredentialStore interface, implementing it in both the in-memory and Redis backends as a presence-gated compare-and-set that never creates a row. The implementation is a faithful, low-risk mirror of the already-shipped StoreDCRCredentialsIfAbsent pattern - same WATCH/MULTI retry structure, same defensive-copy and TTL-derivation contracts, same RFC6749 error wrapping - and its one genuinely new design decision (gating on physical presence rather than TTL-liveness, unlike the sibling method) is well-justified and documented consistently across the interface and both backends.
None of the findings below are blocking. The most substantive one is that the WATCH/MULTI retry bound (maxDCRClaimRetries=3) was copied without re-examining it: StoreDCRCredentialsIfAbsent's losers mostly take a read-only branch and rarely reach MULTI, but every Update caller unconditionally writes, so N concurrent updaters genuinely race the watched key on every call - a materially different contention profile than the one the bound was tuned for. Worth a second look before this ships as the intended write path for a future storage decorator, though not a reason to hold the PR.
The rest are polish: a real, cheap efficiency/consistency fix (the presence check does a full GET where five other call sites in this file use EXISTS), a couple of doc-comment sections that weren't threaded through to mention the new method, a generic Redis-error branch that's untested (true for the sibling Store method too, not unique to this PR), and one integration test whose own comment claims a wire-level TTL check it doesn't actually perform.
Documentation
pkg/authserver/storage/types.go:385-389- the interface-level "Defensive copy" section names Store and Get but not the new Update method.pkg/authserver/storage/types.go:403-414- the "why the key is embedded" rationale applies equally to Update's identical(ctx, creds)shape but isn't mentioned there.pkg/authserver/storage/redis.go:42-maxDCRClaimRetries's doc comment still says it bounds only StoreDCRCredentialsIfAbsent's retry loop.- PR description's Changes table calls the new
redis_integration_test.gocoverage "miniredis integration coverage," but that test runs against a real Redis Sentinel cluster and skips without one.
Generated with Claude Code
Addresses #6674 review comments: - MEDIUM redis.go (4028334849): presence check now uses EXISTS instead of GET, so the check no longer transfers the stored blob over the wire just to discard it — matching the other presence checks in this file. - MEDIUM redis.go (4028334864): document Update's heavier contention profile (every caller writes inside MULTI, unlike the Store losers) and, per that, add jittered backoff between WATCH retries. - LOW redis.go (4028334869): jittered exponential backoff between retry attempts avoids synchronized re-collision on the write-heavy path; ctx cancellation short-circuits the wait. - LOW redis.go (4028334873): maxDCRClaimRetries doc now names both callers (Store and Update). - MEDIUM types.go (4028334888): interface "Defensive copy" section now names Store, Update, and Get. - LOW types.go (4028334895): "why the key is embedded" section now references Update's identical (ctx, creds) shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses #6674 review comments: - MEDIUM redis_test.go (4028334910): add TestRedisStorage_DCRCredentials_UpdateConnectionFailure exercising the generic (non-Nil, non-TxFailedErr) error branch of the update retry loop by closing miniredis mid-call, asserting a wrapped error rather than a spurious not-found. - LOW redis_test.go (4028334921): add an UpdateTTL subtest asserting the bounded pastExpiryDCRTTL is applied when an update's new expiry is in the past, mirroring the Store-side assertion. - MEDIUM redis_integration_test.go (4028334929): back the TTL-refresh claim with a real s.client.TTL() wire-level assertion against the cluster, not just the decoded field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the review-body documentation note: the PR description no longer calls the |
jhrozek
left a comment
There was a problem hiding this comment.
Went through this with a couple of separate passes (a security-focused one and a Go-quality one), plus had the Redis WATCH/MULTI approach specifically checked against plain go-redis semantics. Nothing wrong with correctness, tests are solid, but I think the Redis implementation of UpdateDCRCredentialsIfPresent is more machinery than the job needs. Left the main note inline.
Addresses #6674 review comments: - MEDIUM pkg/authserver/storage/redis.go (4040616946): collapse the WATCH/MULTI retry loop, the jittered backoff helpers, and dcrUpdateIfPresent into a single atomic SET XX. Only the key's bare existence gates the write and Redis evaluates that server-side, so there is no read-check-write window to guard, no lost-update race, and no TxFailedErr to retry. maxDCRClaimRetries stays for the Store path; its doc comment and the "Retry contention profile" section describing a retry loop that no longer exists are dropped. - LOW pkg/authserver/storage/redis.go (4040616951): doc claimed presence was decided by a GET. - LOW pkg/authserver/storage/redis_test.go (4040616957): same stale GET reference in the absent-key test. - LOW pkg/authserver/storage/redis_test.go (4040616965): same stale GET reference in the concurrent test, which now asserts every concurrent update succeeds instead of tolerating retry exhaustion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
DCRCredentialStoreonly exposedGetDCRCredentialsand the create-onlyStoreDCRCredentialsIfAbsent, leaving no supported way to rewrite an existingrecord in place. Calling
StoreDCRCredentialsIfAbsenton a key that alreadyexists is a no-op that silently discards the caller's changes and returns the
old value. This blocks a storage decorator that needs to rewrite a DCR
credential's persisted representation (re-encoding, compression, a checksum,
etc.) without changing its RFC 7591 identity or values — it can
Geta recordbut has nowhere to write the modified copy back.
This PR adds a CAS-style update method, symmetric with the existing
IfAbsentnaming, and implements it in both shipped backends:
UpdateDCRCredentialsIfPresent(ctx, creds)to theDCRCredentialStoreinterface: it replaces the record at
creds.Keyiff one currently exists,and returns a wrapped
ErrNotFoundotherwise — it never creates.MemoryStorageas a presence-gated map overwrite thatstores a defensive copy.
RedisStorageas aWATCH/MULTIcompare-and-set (thesame pattern
StoreDCRCredentialsIfAbsentuses), so an update racing aconcurrent delete or TTL eviction fails with
ErrNotFoundinstead ofre-creating the row.
DCRCredentialStoremock for the new method.Closes #6673
Type of change
Test plan
task test)task test-e2e)task lint-fix)New unit tests cover both backends: update replaces an existing record,
update on an absent key returns
ErrNotFoundwithout creating, anexpired-but-present row is still updatable (presence is physical, not
liveness), invalid input is rejected, and the stored copy is isolated from
later caller mutation. Redis adds TTL-derivation (including the bounded
past-expiry TTL), concurrent-update, and connection-failure tests, plus a
real-Redis (Sentinel) integration test — skipped when no cluster is available —
for the replace-and-refresh-TTL (with a wire-level TTL assertion) and
absent-key paths.
Changes
pkg/authserver/storage/types.goUpdateDCRCredentialsIfPresentto theDCRCredentialStoreinterface with contract docs (presence vs. liveness, TTL, defensive copy)pkg/authserver/storage/memory.goMemoryStorage(presence-gated overwrite storing a defensive copy)pkg/authserver/storage/redis.goRedisStorageviaWATCH/MULTICAS with bounded, jittered-backoff retries; adddcrUpdateIfPresenttransaction body (presence checked withEXISTS)pkg/authserver/storage/mocks/mock_storage.gopkg/authserver/storage/memory_test.gopkg/authserver/storage/redis_test.gopkg/authserver/storage/redis_integration_test.goDoes this introduce a user-facing change?
No. This is an additive storage-interface method with no CLI, API, or operator
surface changes.
Special notes for reviewers
StoreDCRCredentialsIfAbsent,which treats an expired row as absent so a fresh registration can reclaim the
slot,
Updategates on physical presence: any rowGETreturns isupdatable, including one whose
ClientSecretExpiresAthas passed but whoseRedis key has not yet self-evicted. This is intentional —
GetDCRCredentialsdoes not filter on expiry, so gating
Updateon liveness would break theGet → transform → Updateround-trip the method exists for.is derived from the incoming
credsviamarshalDCRCredentialsForStoreexactly as
StoreDCRCredentialsIfAbsentderives it, so an update can extend,shorten, or clear the TTL.
StoreDCRCredentialsIfAbsent's create semantics are unchanged; this changeis purely additive.
production caller in this PR; it is the forward-looking write path for the
storage decorator described in authserver/storage: DCRCredentialStore has no way to update an existing record #6673, and ships implemented, mocked, and
tested. It is not orphaned API surface.
WATCH/MULTIretry bound isshared with
StoreDCRCredentialsIfAbsent, but everyUpdatecaller writesinside
MULTI(Store losers mostly take a read-only branch), so retries backoff with jitter to avoid synchronized re-collision.
WATCHstill guaranteescorrectness regardless of the bound — a losing
EXECwrites nothing — soexhausting retries returns a transient, retryable error, never a torn write.
🤖 Generated with Claude Code