Skip to content

Commit d771b85

Browse files
committed
fix: Discard superseded upstream client builds after re-anchor
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.
1 parent 3828dc9 commit d771b85

4 files changed

Lines changed: 300 additions & 76 deletions

File tree

internal/credential/rotator.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,8 @@ func (r *Rotator) StepTime(now time.Time) (additions []SDKCredential, expiration
195195

196196
for key, info := range r.acceptedSDKKeys {
197197
if key == r.anchorKey {
198-
// The anchor is never expired while it is the anchor, even if an entry left a stale expiry on
199-
// it (e.g. a re-anchor that grace-demoted this key then rolled back before CommitAnchor). This
200-
// enforces the "anchor is always permanent" invariant at the source; without it the cleanup
201-
// ticker would reap the still-authoritative anchor and close the env's only client. Mirrors the
202-
// anchor guard in DeprecatedCredentials.
198+
// Never expire the current anchor, even if a stale expiry was left on its entry (a
199+
// rolled-back re-anchor can). Same guard as DeprecatedCredentials.
203200
continue
204201
}
205202
if info.Expiry != nil && now.After(*info.Expiry) {

internal/relayenv/env_context_impl.go

Lines changed: 66 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@ type envContextImpl struct {
129129
// GetEvaluator / addCredential continue to run during the (potentially seconds-long) SDK client
130130
// construction when re-anchoring to a new key.
131131
reconcileMu sync.Mutex
132+
133+
// anchorClientGen counts how many times the upstream anchor client has been (re)established. A
134+
// re-anchor commit bumps it. startSDKClient builds its client without c.mu, so a slow build can
135+
// finish after a later re-anchor already installed a fresh anchor client; it captures this value at
136+
// launch and, on completion, discards its (now stale) build if the generation has advanced rather
137+
// than clobbering the current anchor client. Guarded by c.mu.
138+
anchorClientGen uint64
132139
}
133140

134141
// Implementation of the DataStoreQueries interface that the streams package uses as an abstraction of
@@ -406,7 +413,9 @@ func NewEnvContext(
406413
}
407414

408415
// Connecting may take time, so do this in parallel
409-
go envContext.startSDKClient(envConfig.SDKKey, readyCh, allConfig.Main.IgnoreConnectionErrors)
416+
// launchGen is 0 here: no re-anchor can have committed yet (the env isn't wired into reconcile until
417+
// after construction returns), so this initial build is never superseded and its result is recorded.
418+
go envContext.startSDKClient(envConfig.SDKKey, readyCh, allConfig.Main.IgnoreConnectionErrors, 0)
410419

411420
cleanupInterval := params.ExpiredCredentialCleanupInterval
412421
if cleanupInterval == 0 { // 0 means it wasn't specified; the config system disallows 0 as a valid value.
@@ -455,7 +464,7 @@ func (c *envContextImpl) addCredential(newCredential credential.SDKCredential) {
455464
case config.SDKKey:
456465
if key == c.keyRotator.AnchorKey() {
457466
if !c.offline {
458-
go c.startSDKClient(key, nil, false)
467+
go c.startSDKClient(key, nil, false, c.anchorClientGen)
459468
}
460469
if c.metricsEventPub != nil { // metrics event publisher always uses SDK key
461470
c.metricsEventPub.ReplaceCredential(key)
@@ -497,43 +506,52 @@ func (c *envContextImpl) removeCredential(oldCredential credential.SDKCredential
497506
}
498507
}
499508

500-
func (c *envContextImpl) startSDKClient(sdkKey config.SDKKey, readyCh chan<- EnvContext, suppressErrors bool) {
509+
func (c *envContextImpl) startSDKClient(sdkKey config.SDKKey, readyCh chan<- EnvContext, suppressErrors bool, launchGen uint64) {
501510
client, err := c.sdkClientFactory(sdkKey, c.sdkConfig, c.sdkInitTimeout)
502511
c.mu.Lock()
503512
name := c.identifiers.GetDisplayName()
513+
// The build happens before we take c.mu. By now the env may be closed, the key may have been
514+
// revoked, or a re-anchor may have committed a fresh anchor client since this build was launched
515+
// (anchorClientGen advanced). In any of those cases this build is stale: close it rather than install
516+
// it, so it cannot clobber the current anchor client. This must not rely on client==nil: a failed SDK
517+
// build returns a non-nil, uninitialized client together with the error, so a stale failed build
518+
// would otherwise replace a healthy anchor client with a dead one. Only defined keys are
519+
// revocation-checked: an undefined SDK key is never tracked, and dropping its client would break envs
520+
// that legitimately run without an SDK key (offline / not-yet-configured / tests).
521+
superseded := c.anchorClientGen != launchGen
504522
droppedInactive := false
505-
if client != nil && (c.closed || (sdkKey.Defined() && !c.sdkKeyIsActive(sdkKey))) {
506-
// startSDKClient builds the client before taking c.mu, so by the time we hold the lock the key
507-
// may already have been revoked (rotated away) or the environment may have been closed. In
508-
// either case the freshly-built client must be closed here rather than installed, otherwise its
509-
// upstream connection and goroutines leak until env.Close() (and, once closed, nothing ever
510-
// closes it). removeCredential cannot close it because the client was never in c.clients.
511-
//
512-
// The revocation check applies only to a defined key: an undefined (empty) SDK key is never a
513-
// tracked credential -- the rotator filters undefined credentials out of its accepted set -- so
514-
// it can never be "revoked", and dropping its client would break environments that legitimately
515-
// run without an SDK key (e.g. offline or not-yet-configured envs, and test fixtures).
523+
if client != nil && (c.closed || superseded || (sdkKey.Defined() && !c.sdkKeyIsActive(sdkKey))) {
516524
_ = client.Close()
517525
client = nil
518526
droppedInactive = true
519527
}
520528
if client != nil {
521-
// If a client already exists for this SDK key (e.g. the key was re-anchored back into the
522-
// primary slot while a previous client for it was still alive in its grace period), close
523-
// the stale one before replacing it so its upstream connection and goroutines are not leaked.
529+
// If a client already exists for this key (e.g. it was re-anchored back into the anchor slot
530+
// while a prior client for it was still in its grace period), close the stale one before
531+
// replacing it so its upstream connection and goroutines are not leaked.
524532
if existing := c.clients[sdkKey]; existing != nil && existing != client {
525533
_ = existing.Close()
526534
}
527535
c.clients[sdkKey] = client
528-
529-
// The data store instance is created by the SDK when it creates the client. Now that we have a
530-
// data store, we can finish setting up the Evaluator for this environment.
531-
c.rebuildEvaluator()
536+
c.rebuildEvaluator() // the SDK created the data store during Build; wire the evaluator to it now
537+
}
538+
// Record this build's result as the env's init status only when it is the current anchor's build: not
539+
// superseded by a newer anchor client, and its key is still the anchor. A genuine failure of the
540+
// current anchor is thus recorded (the middleware 401s a broken env); a stale build's late failure is
541+
// not, so it cannot 401 a healthy re-anchored env.
542+
if !superseded && sdkKey == c.keyRotator.AnchorKey() {
543+
c.initErr = err
532544
}
533-
c.initErr = err
534545
c.mu.Unlock()
535546

536547
switch {
548+
case droppedInactive:
549+
// The build finished but was superseded by a re-anchor, or its key was revoked, or the env
550+
// closed, so it was discarded above rather than installed (even if it also errored -- a
551+
// discarded build's error is moot). The environment is still consistent: no stale client left
552+
// behind.
553+
c.globalLoggers.Infof("SDK key %s build was superseded, revoked, or the environment was closed "+
554+
"before it finished initializing; the client was discarded", sdkKey.Masked())
537555
case err != nil:
538556
if suppressErrors {
539557
c.globalLoggers.Warnf("Ignoring error initializing LaunchDarkly client for %q: %+v",
@@ -546,12 +564,6 @@ func (c *envContextImpl) startSDKClient(sdkKey config.SDKKey, readyCh chan<- Env
546564
}
547565
return
548566
}
549-
case droppedInactive:
550-
// The client initialized successfully but the key was revoked (or the environment was closed)
551-
// before it could be installed, so it was discarded above. The environment is still considered
552-
// ready: it is in a consistent state with no client for this no-longer-tracked key.
553-
c.globalLoggers.Infof("SDK key %s was revoked or the environment was closed before its client "+
554-
"finished initializing; the client was discarded", sdkKey.Masked())
555567
default:
556568
c.globalLoggers.Infof("Initialized LaunchDarkly client for %q (SDK key %s)", name, sdkKey.Masked())
557569
}
@@ -560,10 +572,9 @@ func (c *envContextImpl) startSDKClient(sdkKey config.SDKKey, readyCh chan<- Env
560572
}
561573
}
562574

563-
// sdkKeyIsActive reports whether the given SDK key is still a tracked credential -- either the primary
564-
// key or one within its deprecation grace period -- according to the rotator. startSDKClient uses this
565-
// to avoid installing (and thereby leaking) a client for a key that was revoked while the client was
566-
// being constructed.
575+
// sdkKeyIsActive reports whether the given SDK key is still a tracked credential -- the anchor or a key
576+
// within its deprecation grace period -- according to the rotator. startSDKClient uses this to avoid
577+
// installing (and thereby leaking) a client for a key that was revoked while it was being built.
567578
func (c *envContextImpl) sdkKeyIsActive(sdkKey config.SDKKey) bool {
568579
return slices.Contains(c.keyRotator.AllCredentials(), credential.SDKCredential(sdkKey))
569580
}
@@ -590,27 +601,18 @@ func (c *envContextImpl) ReconcileCredentials(newSet credential.AcceptedSet) {
590601
c.reconcileCredentials(newSet, time.Now())
591602
}
592603

593-
// reconcileCredentials is the time-injectable implementation of ReconcileCredentials. now is the
594-
// reference time for expiry math; production callers pass time.Now() via ReconcileCredentials.
595-
//
596-
// Order of operations: add → re-anchor → remove. Additions drain first so credential mappings
597-
// are registered before any synchronous re-anchor runs, the re-anchor swaps the upstream client
598-
// while the old anchor is still serving, and expirations drain last so the old anchor's client
599-
// (and any other revoked keys) are torn down only after the new anchor is fully operational. addCredential
600-
// opens an upstream client only for the anchor — non-anchor server keys are accepted and routed
601-
// without a second connection.
604+
// reconcileCredentials is the time-injectable implementation of ReconcileCredentials (now is the
605+
// reference time for expiry math).
602606
//
603-
// Re-anchor handling: Reconcile defers the SDK anchor flip and strips the new anchor from additions
604-
// so this method owns the new anchor's setup. The synchronous re-anchor sequence — build a new client
605-
// (new anchor key) or reuse an existing one (previously-accepted anchor key), CommitAnchor,
606-
// ReplaceCredential on event dispatcher + metrics publisher, re-wire big-segment sync — happens between the addition
607-
// and expiration phases. The MobilePrimaryRepoint case (primary mobile key changed to a key that
608-
// was already in the accepted set) is handled in the same window: addCredential's gate won't fire
609-
// for it because the key isn't in additions, so ReplaceCredential is called synchronously.
607+
// Order: add -> re-anchor -> remove. Adding first registers the new keys' mappings; the re-anchor then
608+
// swaps the upstream client while the old anchor is still serving; removing last tears down the old
609+
// anchor (and any revoked keys) only once the new one is up. addCredential opens an upstream client
610+
// only for the anchor -- non-anchor server keys are routed without a second connection.
610611
//
611-
// reconcileMu serializes concurrent reconciles. If two reconciles arrive while a synchronous build
612-
// is in flight, the second blocks here until the first completes — matching the all-or-nothing
613-
// atomicity requirement.
612+
// reconcileMu serializes this whole method against concurrent reconciles and the cleanup ticker (see
613+
// triggerCredentialChanges). See reanchor for the SDK-anchor swap; MobilePrimaryRepoint is handled
614+
// inline below (a primary-mobile change to an already-accepted key isn't in additions, so addCredential
615+
// won't repoint event forwarding for it).
614616
func (c *envContextImpl) reconcileCredentials(newSet credential.AcceptedSet, now time.Time) {
615617
c.reconcileMu.Lock()
616618
defer c.reconcileMu.Unlock()
@@ -624,14 +626,10 @@ func (c *envContextImpl) reconcileCredentials(newSet credential.AcceptedSet, now
624626

625627
if result.AnchorChange != nil {
626628
if committed := c.reanchor(result.AnchorChange); !committed {
627-
// The re-anchor rolled back — the new anchor's client never came up. Back out just this
628-
// anchor change (other changes in the payload stand). The env-side undo mirrors
629-
// RevertAnchorChange's accepted-set logic exactly:
630-
// - A brand-new anchor: reanchor registered its mappings this cycle, so undo them here;
631-
// RevertAnchorChange drops it from the accepted set.
632-
// - A previously-accepted anchor (e.g. a non-anchor key promoted to anchor): reanchor did
633-
// NOT register mappings (they predate this reconcile), so we must NOT tear them down;
634-
// RevertAnchorChange keeps it accepted. It reverts to the non-anchor key it already was.
629+
// Rolled back: the new anchor's client never came up. Undo just this anchor change (other
630+
// changes in the payload stand), mirroring RevertAnchorChange. A brand-new anchor had its
631+
// mappings registered this cycle, so tear them down here; a previously-accepted anchor keeps
632+
// its mappings and reverts to the non-anchor key it already was.
635633
if !result.AnchorChange.NewAnchorPreviouslyAccepted {
636634
c.removeCredential(result.AnchorChange.NewAnchor)
637635
}
@@ -687,18 +685,12 @@ func (c *envContextImpl) reanchor(change *credential.AnchorChange) bool {
687685
c.mu.Lock()
688686
defer c.mu.Unlock()
689687

690-
// The two signals below answer two different questions, so they are used independently:
691-
// - NewAnchorPreviouslyAccepted: were this key's credential mappings already registered? If it
692-
// was already accepted (e.g. a non-anchor server key that addCredential registered mappings for
693-
// but never built a client), the mappings exist, so skip re-registering. If it is brand new
694-
// (Reconcile stripped it from additions, so addCredential never ran for it), register them here.
695-
// - the client check below: is there already a client for this key? If so, reuse it; otherwise
696-
// build one.
697-
// These genuinely differ: promoting a previously-accepted non-anchor key to anchor has mappings but
698-
// no client (register: skip, client: build). The one-way invariant "a client exists =>
699-
// NewAnchorPreviouslyAccepted" holds (removeCredential deletes a key's client in lockstep with the
700-
// rotator dropping it), which is why gating mapping registration on NewAnchorPreviouslyAccepted is
701-
// safe: a key with a live client is always already accepted, so it never re-registers.
688+
// Two independent questions:
689+
// - NewAnchorPreviouslyAccepted: are this key's credential mappings already registered? A brand-new
690+
// anchor was stripped from additions, so register them now; an already-accepted key already has them.
691+
// - the client check below: does a client already exist? If so reuse it, else build one.
692+
// They differ for a previously-accepted non-anchor key promoted to anchor: mappings exist, client
693+
// does not. (A live client always implies the key was already accepted, so registration never double-fires.)
702694
if !change.NewAnchorPreviouslyAccepted {
703695
c.registerCredentialMappings(newAnchor)
704696
}
@@ -789,6 +781,9 @@ func (c *envContextImpl) commitReanchor(newAnchor, previousAnchor config.SDKKey,
789781
}
790782

791783
c.keyRotator.CommitAnchor(newAnchor)
784+
// A new anchor client is now authoritative, so any startSDKClient build still in flight from before
785+
// this commit is stale: bump the generation so it discards itself instead of clobbering this client.
786+
c.anchorClientGen++
792787
// The anchor now points at a healthy client (freshly built and Initialized, or a reused live
793788
// client), so clear any init error a prior client left behind — otherwise GetInitError() and the
794789
// request middleware would keep reporting a still-serving env as failed.

0 commit comments

Comments
 (0)