feat: Synchronous re-anchor of the upstream SDK client#739
Conversation
A re-anchor that grace-demotes the previous anchor and then rolls back (new client init failed) left the still-authoritative anchor carrying its grace expiry. StepTime had no anchor guard, so the cleanup ticker later expired the current anchor and closed the environment's only SDK client -- a silent upstream outage with no self-recovery. StepTime now skips the current anchor (key == r.anchorKey), mirroring the guard already in DeprecatedCredentials, so the anchor is never expired while it is the anchor. A properly demoted former anchor (after CommitAnchor moved the pointer) still expires normally. Tests: a rotator unit test that the current anchor survives a stale expiry and that a demoted former anchor still expires after a successful re-anchor; an env-level regression driving the real reconcile->reanchor rollback + cleanup ticker; and a ticker assertion on the immediate-revocation rollback path. Found by multi-agent review of #739.
|
|
||
| for key, info := range r.acceptedSDKKeys { | ||
| if key == r.anchorKey { | ||
| // The anchor is never expired while it is the anchor, even if an entry left a stale expiry on |
There was a problem hiding this comment.
This comment feels much more complex than what it is trying to convey.
There was a problem hiding this comment.
The comments overall may need a second pass. I find them reasonably inscrutable.
There was a problem hiding this comment.
Good call. I cut that StepTime comment down to two lines ("Never expire the current anchor, even if a stale expiry was left on its entry (a rolled-back re-anchor can). Same guard as DeprecatedCredentials.") and did a readability pass on the densest re-anchor comments while I was in there — the reconcileCredentials header, the rollback block, and the two-signals block in reanchor. Left this thread open for you to judge whether they read better now; happy to keep trimming any that are still murky.
|
Bug: a late-completing initial If the initial The existing suite never hits this because every re-anchor test waits for the initial client before rotating. Recommended fix: in Repro (fails on this branch at the // TestReanchor_InitialClientMustNotClobberInitErrAfterReanchor: the initial startSDKClient for the
// OLD anchor A is still in flight when a synchronous re-anchor to healthy B commits (initErr=nil).
// A's build then fails, and startSDKClient's unconditional `c.initErr = err` clobbers the nil —
// 401ing a healthy env at the middleware even though B is the anchor and serving.
func TestReanchor_InitialClientMustNotClobberInitErrAfterReanchor(t *testing.T) {
envConfig := st.EnvMain.Config
mockLog := ldlogtest.NewMockLog()
defer mockLog.DumpIfTestFailed(t)
clientCh := make(chan *testclient.FakeLDClient, 10)
healthy := testclient.FakeLDClientFactoryWithChannel(true, clientCh)
gate := make(chan struct{}) // released to let A's initial build complete
entered := make(chan struct{}, 1) // signals A's initial build has reached the factory
factory := func(sdkKey config.SDKKey, cfg ld.Config, timeout time.Duration) (sdks.LDClientContext, error) {
if sdkKey == envConfig.SDKKey {
// The ORIGINAL anchor A: block until released, then fail the way a 401 does.
entered <- struct{}{}
<-gate
return nil, ld.ErrInitializationFailed
}
// The new anchor B builds fine.
return healthy(sdkKey, cfg, timeout)
}
readyCh := make(chan EnvContext, 1)
env := makeBasicEnv(t, envConfig, factory, mockLog.Loggers, readyCh)
defer env.Close()
envImpl := env.(*envContextImpl)
// The initial startSDKClient(A) is now blocked in the factory; the anchor is still A.
<-entered
// Re-anchor A -> B (B is brand new; A is grace-demoted with a +1h expiry). B builds, commits,
// and clears initErr.
now := time.Unix(2000, 0)
expiry := now.Add(time.Hour)
set, err := credential.NewAcceptedSetBuilder().
WithAnchor(credential.SDKKeyParams{Value: reanchorSyncTestKey2}).
WithSDKKey(credential.SDKKeyParams{Value: envConfig.SDKKey, Expiry: &expiry}).
WithPrimaryMobileKey(credential.MobileKeyParams{Value: envConfig.MobileKey}).
WithEnvironmentID(envConfig.EnvID).
Build()
require.NoError(t, err)
envImpl.reconcileCredentials(set, now)
// Post-commit sanity: B is the healthy anchor and initErr has been cleared.
require.Equal(t, reanchorSyncTestKey2, envImpl.keyRotator.AnchorKey())
require.NoError(t, env.GetInitError(), "sanity: after a successful re-anchor initErr is nil")
// Release the OLD anchor's build; it returns ld.ErrInitializationFailed. startSDKClient(A)
// sends env on readyCh once it finishes (after writing initErr).
close(gate)
<-readyCh
// DESIRED: a healthy re-anchored env must NOT be marked failed by the old anchor's late failure.
assert.NoError(t, env.GetInitError(),
"initErr of a healthy re-anchored env must not be clobbered by the old anchor's late build failure")
assert.NotEqual(t, ld.ErrInitializationFailed, env.GetInitError(),
"the request middleware would 401 the whole env if GetInitError() is ErrInitializationFailed")
// The env genuinely IS healthy: B is the anchor and its client serves.
assert.NotNil(t, env.GetClient(), "the new anchor's client serves")
} |
When sdkKey.value changes, relay now swaps its single upstream SDK client to the new anchor synchronously, preserving downstream connections and rolling back cleanly on failure. reconcileCredentials runs add -> reanchor -> remove under a dedicated reconcileMu (also held by the cleanup ticker). reanchor builds the new anchor's client (Case A) or reuses an existing one (Case B / offline), waits for Initialized, commits the anchor, then repoints event/metrics forwarding; on init failure it rolls back without poisoning initErr, leaving the previous anchor authoritative. Store handover means the new client serves the populated store with no empty-store window. Completes the rotator API: Reconcile strips a brand-new anchor from additions so the async startSDKClient can't race the synchronous build; RevertAnchorChange backs out the accepted-set change on rollback; MobilePrimaryRepoint handles a primary-mobile switch to an already-accepted key. Inverts PoC H6/H7 to assert the fix (no nil swap-window; rollback keeps the old anchor). Big-segment re-wire remains a documented no-op stub for follow-up work.
A re-anchor that grace-demotes the previous anchor and then rolls back (new client init failed) left the still-authoritative anchor carrying its grace expiry. StepTime had no anchor guard, so the cleanup ticker later expired the current anchor and closed the environment's only SDK client -- a silent upstream outage with no self-recovery. StepTime now skips the current anchor (key == r.anchorKey), mirroring the guard already in DeprecatedCredentials, so the anchor is never expired while it is the anchor. A properly demoted former anchor (after CommitAnchor moved the pointer) still expires normally. Tests: a rotator unit test that the current anchor survives a stale expiry and that a demoted former anchor still expires after a successful re-anchor; an env-level regression driving the real reconcile->reanchor rollback + cleanup ticker; and a ticker assertion on the immediate-revocation rollback path. Found by multi-agent review of #739.
Address review on #739. startSDKClient built its client before taking c.mu and then wrote c.initErr unconditionally. If the initial build for the old anchor A was still in flight when a synchronous re-anchor to a healthy B committed (clearing initErr), A's eventual ErrInitializationFailed clobbered the nil -- 401ing a healthy env at the request middleware while B served fine, with nothing to re-clear it. Gate the install and the initErr write on the key still being the current anchor (which subsumes the old revoked-key guard, so sdkKeyIsActive is gone): a build whose key is no longer the anchor is stale -- close it and leave initErr untouched, matching buildNewAnchorClient's contract in the re-anchor direction. Adds the reviewer's repro test (verified failing without the gate). Also a readability pass on the densest re-anchor comments (StepTime anchor guard, reconcileCredentials header, the rollback and two-signals blocks).
49d9580 to
00d53a3
Compare
|
Fixed in |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 00d53a3. Configure here.
Address review on #739. startSDKClient built its client before taking c.mu and then wrote c.initErr unconditionally. If the initial build for the old anchor A was still in flight when a synchronous re-anchor to a healthy B committed (clearing initErr), A's eventual ErrInitializationFailed clobbered the nil -- 401ing a healthy env at the request middleware while B served fine, with nothing to re-clear it. Gate the initErr write on sdkKey still being the current anchor: a build whose key is no longer the anchor leaves initErr untouched, matching buildNewAnchorClient's contract in the re-anchor direction. The client install is deliberately NOT gated on the anchor: a still-accepted grace-demoted key keeps its late-built client, the same as a normally-demoted former anchor (removeCredential tears it down at grace expiry). Gating the install would drop that grace client (raised in review). Tests: the reviewer's initErr repro (verified failing without the gate), plus a regression that a late *successful* build for a grace key keeps its client without disturbing the anchor or initErr. Also a readability pass on the densest re-anchor comments (StepTime anchor guard, reconcileCredentials header, the rollback and two-signals blocks).
00d53a3 to
e81b0a4
Compare
|
Small correction to my note above: Bugbot flagged that gating the client install on the anchor would drop a grace-demoted key's late-built client. Since your actual bug is only the |
Address review on #739. startSDKClient builds its client before taking c.mu, so a slow build for the initial anchor can finish after a re-anchor has already committed a fresh anchor client. A failed SDK build returns a NON-NIL, uninitialized client (not nil), so such a stale build would be installed -- closing the healthy anchor client and swapping in a dead one -- and its late ErrInitializationFailed would set initErr, 401ing a healthy environment. Add an anchorClientGen counter, bumped in commitReanchor when a new anchor client becomes authoritative. startSDKClient captures it at launch and, on completion under c.mu, discards its build (closes it, does not install, leaves initErr untouched) if the generation advanced. A genuine failure of the current anchor -- no re-anchor since launch -- still records initErr, so a broken env still 401s. Tests use realistic non-nil uninitialized failing clients: a superseded failing build, a superseded successful build (discarded, healthy client untouched), and the A->B->A re-anchor-back case; all verified to fail without the generation guard. Also a readability pass on the densest re-anchor comments.
e81b0a4 to
d771b85
Compare
|
Update on the initErr/re-anchor thread: the client-identity gate I described earlier turned out insufficient (a review pass showed a failed SDK build returns a non-nil, uninitialized client, so on an A→B→A re-anchor the stale initial build would be installed — closing the healthy re-anchored client — before any initErr gate ran). Reworked to the root cause: |

Summary
Final slice of #720 (the re-anchor mechanism, SDK-2542): the synchronous upstream SDK-client swap itself. When
sdkKey.valuechanges, relay swaps its single upstream client to the new anchor synchronously, preserving downstream connections and rolling back cleanly on failure.What it does
reconcileCredentialsruns add -> reanchor -> remove under a dedicatedreconcileMu(also held by the cleanup ticker'striggerCredentialChanges, so a credential expiry can't interleave a build).reanchorbuilds the new anchor's client (Case A) or reuses an existing/offline one (Case B), waits forInitialized,CommitAnchors, then repoints event + metrics forwarding. Store handover (from feat: Hand over the data store to the new client on re-anchor #736) means the new client serves the populated store with no empty-store window.initErr(which would 401 a healthy env).RevertAnchorChangerealigns the accepted set.Reconcilestrips a brand-new anchor fromadditionsso the asyncstartSDKClientcan't race the synchronous build;MobilePrimaryRepointhandles a primary-mobile switch to an already-accepted key.Reviewer notes (known trade-offs)
InitTimeout(~10s) for other envs during a re-anchor. The data plane is unaffected (c.muis released around the build). This is a deliberate v1 trade-off; a possible follow-up is bounded parallelism or a shorter re-anchor init timeout.Reconcilestrip-from-additions <->reanchorcoupling and the rollback path (RevertAnchorChange+ the env-side undo inreconcileCredentials).Split sequence
PR 4 of 4: store handover (#736) -> relayenv refactors (#737) -> credential deferred-flip (#738) -> synchronous re-anchor core (this PR).
Note
High Risk
Changes core credential reconciliation and upstream client lifecycle for every anchor rotation; rollback and stale-build races are subtle, and synchronous builds can block the RAC handler for up to init timeout.
Overview
When
sdkKey.valuechanges, relay now swaps the upstream SDK client synchronously instead of flipping the anchor early and building async.reconcileCredentialsruns add → re-anchor → remove underreconcileMu(shared with the cleanup ticker).reanchoreither builds and waits forInitialized()(Case A) or reuses an existing client / skips build offline (Case B), thenCommitAnchor,ReplaceCredentialon events/metrics, and clearsinitErron success. Failed Case A builds roll back viaRevertAnchorChange, env-side mapping teardown for brand-new anchors, and skipping expiration of the still-serving previous anchor.Rotator: signals
AnchorChange(withNewAnchorPreviouslyAccepted), strips brand-new anchors fromadditionsto avoid racingstartSDKClient, addsMobilePrimaryRepoint,RevertAnchorChange, andStepTimenever expires the current anchor (even with a stale grace expiry after rollback).Concurrency:
anchorClientGendiscards latestartSDKClientbuilds so they cannot clobber the committed anchor or poisoninitErr. Design doc updated for Case A/B and reconcile coupling.Extensive regression tests cover happy path, rollback, ticker serialization, and inverted PoC H6/H7. Big-segment re-wire remains a documented stub.
Reviewed by Cursor Bugbot for commit d771b85. Bugbot is set up for automated code reviews on this repo. Configure here.