Skip to content

AUT-1477 Close the re-claim race flagged in smartling-express PR #59 review - #32

Open
DmitryMasley wants to merge 7 commits into
masterfrom
AUT-1477-2
Open

AUT-1477 Close the re-claim race flagged in smartling-express PR #59 review#32
DmitryMasley wants to merge 7 commits into
masterfrom
AUT-1477-2

Conversation

@DmitryMasley

@DmitryMasley DmitryMasley commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Follow-up to AUT-1477 / #27 (merged), addressing a review comment from smartling-express PR #59 on the refreshCoordinator mechanism that PR introduced downstream.

The original gap: _coordinated()'s fallback, when a follower's coordinator.await() resolved with no result (e.g. the leader's claim TTL expired while the follower was waiting), fell straight through to calling Keycloak directly - unconditionally. Two followers waiting on the same expired lock could both independently call Keycloak at once, reproducing the exact race the coordinator exists to prevent.

First fix (superseded within this PR): a bounded retry - re-attempt claim() a few times before falling back. This closed the race, but a claim's TTL expiring is inherently ambiguous: it can mean "the leader crashed" or "the leader is just slow," and bounded retry can only guess, at the cost of up to ~15-20s of added latency on a real Keycloak slowdown.

Current fix - heartbeat-based lease renewal: the claim holder now renews its own claim (coordinator.renew, gated by the same CAS-on-token check publish() already uses) on a timer while doFetch is in flight, via GrantManager#_leadWithHeartbeat. A live leader's claim can no longer lapse mid-operation - it only expires once the leader has genuinely stopped renewing (crashed, or finished without publishing). A follower seeing the claim disappear now has one unambiguous meaning to act on: re-run the claim/await cycle. Redis's atomic claim still arbitrates a single new leader among any followers re-claiming at once. The recursive re-run keeps a small depth cap + jitter, now purely as a defensive bound against pathological coordinator flakiness, not a "how many times do we guess wrong" budget.

Companion change in smartling-express: RedisRefreshCoordinator.renew(), same CAS-Lua pattern as the existing publish().

Test plan

  • New tests for the heartbeat mechanism itself (test/unit/grant-manager-unit-test.js): renews on each tick while doFetch is pending, stops on success/failure, stops the moment a renewal reports preemption, tolerates a transient renew() rejection as a skipped beat rather than a lost lease - using a monkey-patched setInterval/clearInterval harness (no real timers, no flaky wall-clock waits).
  • Depth-cap self-heal test: a coordinator whose claim()/await() never resolve to a claim still gives up and calls Keycloak directly after the bounded depth limit.
  • The existing re-claim-race regression test (two followers, a lock that disappears mid-wait) still passes unchanged - it already modeled exactly the trigger this design expects (a claim that's claimed and then abandoned without renewal).
  • Full existing unit test suite green (70/70, node test/unit/grant-manager-unit-test.js).
  • npm run lint clean.

🤖 Generated with Claude Code

DmitryMasley and others added 5 commits July 23, 2026 18:45
…xchange

Keycloak's single-use enforcement for both the authorization_code grant and
refresh_token grant is correct, but this adapter had no way to prevent two
concurrent requests for the same session (e.g. two browser tabs, or a
duplicate/racing callback request) from independently calling Keycloak at
the same time. `_pendingRefreshes` only deduplicated within a single Node
process, so with multiple app instances behind a load balancer, concurrent
requests routed to different instances would each hit Keycloak
independently - producing "Code already used for userSession" at login and
transient "Token is missing or session invalid" failures during normal use
as sessions aged toward their access-token refresh cycle.

Add an optional `refreshCoordinator` config (claim/publish/await, backed by
a shared store such as Redis) so only one instance performs the actual
Keycloak call while others share its result. Falls back to today's
per-process-only behavior when not configured.
RefreshCoordinator.claim() now returns a per-claim token (or null) instead
of a boolean, and publish() takes that token as a 4th argument so it can
CAS-guard the write against a stale, previously-expired claim holder.
Matches the updated RedisRefreshCoordinator contract in smartling-express.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tract

publish() gained a required token argument and claim() now resolves to an
ownership token (or null) rather than a boolean, per cadd96d. The
public-facing contract doc here still described the old 3-arg shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r itself fails

If refreshCoordinator.claim() or await() rejects (e.g. Redis unreachable),
_coordinated() previously propagated that rejection and failed the caller
even when Keycloak itself was healthy. The coordinator is an optional
dedup optimization, so a coordinator failure now degrades to calling
Keycloak directly instead of breaking auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…expires mid-wait

Addresses smartling-express PR keycloak#59 review comment (discussion_r3664941181):
when two followers both see await() resolve null - e.g. the leader's claim
TTL expires while they're waiting - _coordinated used to fall through to
doFetch() unconditionally, so both followers could independently hit
Keycloak at once, reproducing the exact race the coordinator exists to
prevent.

_coordinated now re-attempts claim() (bounded by
COORDINATION_RECURSION_LIMIT) before falling back to a direct fetch.
Redis's atomic SET...NX still arbitrates a single winner among the
re-claiming followers, so only one of them ever reaches Keycloak.

Added a regression test that reproduces the scenario with an expiring-lock
fake coordinator - confirmed it fails against the pre-fix naive fallback
(second follower's real Keycloak call has no matching interceptor left)
and passes with the re-claim loop in place.
…newal

The previous commit's bounded retry couldn't distinguish "the leader crashed"
from "the leader is just slow" - a claim's TTL expiring meant either, and a
follower could only guess by retrying a fixed number of times, at the cost
of up to ~15-20s of added latency on a real Keycloak slowdown.

Replace it with heartbeating: the claim holder now renews its own claim
(coordinator.renew, gated by the same CAS-on-token check publish() already
uses) on a timer while doFetch is in flight, via the new
GrantManager#_leadWithHeartbeat. A live leader's claim can no longer lapse
mid-operation - it can only expire once the leader has genuinely stopped
renewing (crashed, or finished without publishing). A follower that sees the
claim disappear now has one unambiguous meaning to act on: re-run the whole
claim/await cycle. Redis's atomic claim still arbitrates a single new leader
among any followers re-claiming at once.

The recursive re-run keeps a small depth cap (COORDINATION_RECLAIM_DEPTH_LIMIT)
and jitter, but now purely as a defensive bound against pathological
coordinator flakiness (e.g. claim() never succeeding) - not as a "how many
times do we guess wrong" budget, since the semantics are no longer ambiguous.

Adds tests for the heartbeat mechanism itself (renews while pending, stops on
settle/preemption, tolerates a transient renew() rejection as a skipped beat)
using a monkey-patched setInterval/clearInterval harness, plus a depth-cap
self-heal test. The existing re-claim-race test continues to pass unchanged -
it already modeled a leader that claims and disappears without renewing,
which is exactly the (now well-defined, not ambiguous) trigger this design
expects.

Companion change: smartling-express adds RedisRefreshCoordinator#renew (CAS
Lua PEXPIRE, same pattern as the existing CAS publish).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant