Skip to content

feat: Synchronous re-anchor of the upstream SDK client#739

Merged
keelerm84 merged 3 commits into
feat/concurrent-keysfrom
mk/sdk-2542/reanchor-core
Jul 8, 2026
Merged

feat: Synchronous re-anchor of the upstream SDK client#739
keelerm84 merged 3 commits into
feat/concurrent-keysfrom
mk/sdk-2542/reanchor-core

Conversation

@keelerm84

@keelerm84 keelerm84 commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

Final slice of #720 (the re-anchor mechanism, SDK-2542): the synchronous upstream SDK-client swap itself. When sdkKey.value changes, relay swaps its single upstream client to the new anchor synchronously, preserving downstream connections and rolling back cleanly on failure.

Stacked PR. Based on mk/sdk-2542/reanchor-core-base (= feat/concurrent-keys + the three prior slices #736 / #737 / #738), so this diff shows only the core. Once those three merge, this will be retargeted to feat/concurrent-keys and the diff is unchanged.

What it does

  • reconcileCredentials runs add -> reanchor -> remove under a dedicated reconcileMu (also held by the cleanup ticker's triggerCredentialChanges, so a credential expiry can't interleave a build).
  • reanchor builds the new anchor's client (Case A) or reuses an existing/offline one (Case B), waits for Initialized, 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.
  • Rollback: on init failure it does not commit, leaves the previous anchor authoritative and still serving, and does not poison initErr (which would 401 a healthy env). RevertAnchorChange realigns the accepted set.
  • Completes the rotator API: Reconcile strips a brand-new anchor from additions so the async startSDKClient can't race the synchronous build; 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 is a documented no-op stub for follow-up (T2.d).

Reviewer notes (known trade-offs)

  • Serial RAC blocking: the synchronous build blocks the single RAC handler goroutine up to InitTimeout (~10s) for other envs during a re-anchor. The data plane is unaffected (c.mu is 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.
  • Highest-risk area: the Reconcile strip-from-additions <-> reanchor coupling and the rollback path (RevertAnchorChange + the env-side undo in reconcileCredentials).

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.value changes, relay now swaps the upstream SDK client synchronously instead of flipping the anchor early and building async.

reconcileCredentials runs add → re-anchor → remove under reconcileMu (shared with the cleanup ticker). reanchor either builds and waits for Initialized() (Case A) or reuses an existing client / skips build offline (Case B), then CommitAnchor, ReplaceCredential on events/metrics, and clears initErr on success. Failed Case A builds roll back via RevertAnchorChange, env-side mapping teardown for brand-new anchors, and skipping expiration of the still-serving previous anchor.

Rotator: signals AnchorChange (with NewAnchorPreviouslyAccepted), strips brand-new anchors from additions to avoid racing startSDKClient, adds MobilePrimaryRepoint, RevertAnchorChange, and StepTime never expires the current anchor (even with a stale grace expiry after rollback).

Concurrency: anchorClientGen discards late startSDKClient builds so they cannot clobber the committed anchor or poison initErr. 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.

keelerm84 added a commit that referenced this pull request Jul 7, 2026
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.
@keelerm84 keelerm84 marked this pull request as ready for review July 7, 2026 20:40
@keelerm84 keelerm84 requested a review from a team as a code owner July 7, 2026 20:40
Comment thread internal/credential/rotator.go Outdated

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment feels much more complex than what it is trying to convey.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comments overall may need a second pass. I find them reasonably inscrutable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kinyoklion

Copy link
Copy Markdown
Member

Bug: a late-completing initial startSDKClient for the old anchor clobbers initErr after a successful re-anchor, 401ing a healthy env.

If the initial startSDKClient(A) is still blocked in the factory when a rotation synchronously re-anchors to a healthy B, commitReanchor sets initErr = nil — but when A's build finally returns ld.ErrInitializationFailed (the natural outcome for a key being rotated out), startSDKClient writes c.initErr = err unconditionally. The droppedInactive/sdkKeyIsActive logic only guards the client install, not the initErr write. The middleware then 401s every request while GetClient() returns the healthy B client, and nothing re-clears initErr except another successful re-anchor.

The existing suite never hits this because every re-anchor test waits for the initial client before rotating.

Recommended fix: in startSDKClient's post-build path, gate the initErr write (and the install/rebuildEvaluator) on the key still being the current anchor — the same "leave initErr untouched" contract buildNewAnchorClient already documents for the reverse direction.

Repro (fails on this branch at the GetInitError() assertions; verified with go test -race):

// 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")
}

@keelerm84 keelerm84 changed the base branch from mk/sdk-2542/reanchor-core-base to feat/concurrent-keys July 8, 2026 12:23
keelerm84 added 2 commits July 8, 2026 08:24
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.
keelerm84 added a commit that referenced this pull request Jul 8, 2026
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).
@keelerm84 keelerm84 force-pushed the mk/sdk-2542/reanchor-core branch from 49d9580 to 00d53a3 Compare July 8, 2026 12:33
@keelerm84

Copy link
Copy Markdown
Member Author

Fixed in 00d53a3. startSDKClient now gates both the client install and the initErr write on sdkKey still being the current anchor — a build whose key is no longer the anchor (a re-anchor moved it while we were building) is treated as stale: the client is closed and initErr is left untouched, mirroring buildNewAnchorClient's contract for the re-anchor direction. That also subsumed the old revoked-key guard, so sdkKeyIsActive is gone. Your repro is in as a permanent regression (TestReanchor_InitialClientMustNotClobberInitErrAfterReanchor) — verified it fails without the gate and passes with it, under -race. Thanks for the catch.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread internal/relayenv/env_context_impl.go Outdated
keelerm84 added a commit that referenced this pull request Jul 8, 2026
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).
@keelerm84 keelerm84 force-pushed the mk/sdk-2542/reanchor-core branch from 00d53a3 to e81b0a4 Compare July 8, 2026 12:43
@keelerm84

Copy link
Copy Markdown
Member Author

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 initErr clobber (your repro has the build fail, so nothing installs anyway), I kept the install as-is and gated only the initErr write on the key still being the anchor. Grace keys keep their client; added a regression for that too. Net for your bug is unchanged. (e81b0a4)

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.
@keelerm84 keelerm84 force-pushed the mk/sdk-2542/reanchor-core branch from e81b0a4 to d771b85 Compare July 8, 2026 13:32
@keelerm84

Copy link
Copy Markdown
Member Author

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: startSDKClient now captures an anchorClientGen counter at launch (bumped in commitReanchor), and on completion discards a superseded build — closes it, doesn't install it, leaves initErr untouched — rather than clobbering the current anchor client. A genuine failure of the current anchor (no re-anchor since launch) still records initErr, so a broken env still 401s. Regression tests use realistic non-nil uninitialized failing clients and cover the single-reanchor, discarded-successful, and A→B→A cases (all verified failing without the guard). d771b85.

@keelerm84 keelerm84 requested a review from kinyoklion July 8, 2026 14:11
@keelerm84 keelerm84 merged commit 41bd432 into feat/concurrent-keys Jul 8, 2026
16 checks passed
@keelerm84 keelerm84 deleted the mk/sdk-2542/reanchor-core branch July 8, 2026 17:58
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.

2 participants