Skip to content

feat: Move an environment between SDK keys without rebuilding its client - #896

Merged
keelerm84 merged 14 commits into
feat/concurrent-keys-v9from
mk/SDK-3199/anchor-reanchor
Oct 5, 2026
Merged

keelerm84 merged 14 commits into
feat/concurrent-keys-v9from
mk/SDK-3199/anchor-reanchor

Conversation

@keelerm84

@keelerm84 keelerm84 commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Phase 3 of forward-porting concurrent multi-key support from v8 (#817) to v9. Stacked on #894, so this diff shows only phase 3; GitHub retargets it to feat/concurrent-keys-v9 as the stack merges.

An environment now serves its whole accepted credential set, and moves its upstream connection between SDK keys by re-keying the one client it holds. ReconcileCredentials replaces UpdateCredential: add the incoming keys' mappings, re-anchor while the outgoing key still authenticates downstream traffic, then take revoked mappings down.

⚠️ go.mod temporarily pins go-server-sdk#457 and go-sdk-events#63 at their PR commits. Both must be released and repinned before this merges.

What a re-anchor is now

LDClient.SetSDKKey, plus the two components relay owns that the SDK cannot reach: the big segment synchronizer, which takes its key at construction and so is rebuilt, and the event publishers, which are repointed through the existing ReplaceCredential.

There is no client to build, so there is nothing to roll back on a transient failure. SetSDKKey fails only on a key that is not valid in an HTTP header, which no retry fixes, so the environment parks on its current anchor and logs loudly; the next auto-configuration payload supplies a new key. A reconcile can also move the anchor while the first client build is still in flight, so startSDKClient re-keys the client on install if the anchor moved underneath it.

The line worth reviewing closely

removeCredential no longer closes the SDK client. The client is not tied to the key it was built with any more, so closing it there would tear down an environment's only upstream connection whenever any SDK key was revoked, including the environment's original key once it has been rotated away from. TestRotatingTheSDKKeyRekeysTheSameClient asserts the client survives both the rotation and the outgoing key's expiry.

Stream handlers

Built per request, behind a re-check of the accepted set. The map they used to live in was what made revocation racy: a handler stayed in it until the removal was processed, so a request that authenticated before a revocation still found a working handler, and the REPORT stream endpoints let a client pace the body read that precedes the lookup.

The build is cheap enough to do per connect, and this is measured rather than assumed — env_context_stream_handler_bench_test.go puts the client-side path at 13ns with no allocations, which is faster than the two-level map lookup it replaced, and the heaviest provider (server-side V2, wrapping an init deadline and a basis-header closure) at 105ns and 96 bytes. Both are invisible next to the SSE handshake that follows.

Behavior changes visible in tests

GetCredentials() reports the whole accepted set, so a key inside its grace period appears there as well as in GetDeprecatedCredentials(). The shared assertions in relay/testutils_test.go now compare against the designated credentials via a designatedCredentials helper, which is what they were really checking.

The accepted set is also declarative where the old model was incremental: an old-format archive payload listing one SDK key revokes a key that a previous payload had granted a grace period. That is called out in the offline-mode test.

Deletions this phase owed

The previous phases retained superseded API because its callers live here: the single-key rotation shims, UpdateCredential and CredentialUpdate, and EnvironmentParams.ExpiringSDKKey with ExpiringKeyRep.ToParams. Also fixes the missing c.closed guard in the reconcile path, and the missing return in AddEnvironment that called UpdateCredential on a nil EnvContext after an initialization error.

Follow-ups filed

  • SDK-3200 — re-key the big segment synchronizer instead of rebuilding it. Its requests already set Authorization per request, so the plumbing needs no change; what needs designing is resetting its retry backoff from another goroutine.
  • The /status key arrays (sdkKeys[], mobileKeys[]) remain phase 5, which is where the singular expiringSdkKey field and the metrics derived from it get re-based onto them.

Part of SDK-3199, under epic SDK-3188.


Note

Overview
Replaces incremental credential rotation with a declarative accepted-set model. EnvContext.ReconcileCredentials (add → re-anchor → remove) supersedes UpdateCredential, and the legacy single-key rotator shims plus EnvironmentParams.ExpiringSDKKey are removed.

SDK key rotation no longer spins up a second client. Each environment keeps one upstream LDClient, moved via SetSDKKey (temporary go.mod pins unreleased go-server-sdk support). Re-anchor also repoints metrics/events dispatchers and rebuilds the big-segment synchronizer on the new anchor; revoking a key no longer closes the client. Stream handlers are built per connect with an accepted-set re-check instead of a cached handler map.

Auto-config and offline archives validate with envfactory.BuildAcceptedSet before applying changes; malformed payloads are refused without partial updates. ArchiveManager only records environment versions after add/update succeed so hand-corrected archives retry without a version bump.

Status reporting uses GetAcceptedKeys for anchor/mobile keys and picks a deterministic expiringSdkKey from the accepted set.

Reviewed by Cursor Bugbot for commit a268af8. Bugbot is set up for automated code reviews on this repo. Configure here.

@keelerm84
keelerm84 requested a review from a team as a code owner September 25, 2026 13:44

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

Stale Bugbot comment from a previous run.

Comment thread internal/relayenv/env_context_impl.go

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

Stale Bugbot comment from a previous run.

Comment thread internal/events/event-relay.go Outdated

@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 2 potential issues.

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 8b21c9f. Configure here.

Comment thread internal/relayenv/env_context_impl.go
Comment thread internal/events/event-relay.go Outdated
@keelerm84
keelerm84 force-pushed the mk/SDK-3199/anchor-reanchor branch from 8244281 to 564e023 Compare September 29, 2026 13:51
@keelerm84
keelerm84 added this pull request to stack #900 September 29, 2026 14:13
Base automatically changed from mk/SDK-3191/wire-and-params to feat/concurrent-keys-v9 September 29, 2026 14:17
Phase 3 of forward-porting concurrent multi-key support from v8 (#817).

An environment now serves its whole accepted credential set, and moves its
upstream connection between SDK keys by re-keying the one client it holds.
ReconcileCredentials replaces UpdateCredential: it adds the incoming keys'
mappings, re-anchors while the outgoing key still authenticates downstream
traffic, then takes revoked mappings down.

The re-anchor is LDClient.SetSDKKey plus the two components relay owns and the
SDK cannot reach: the big segment synchronizer, which takes its key at
construction and so is rebuilt, and the event publishers, which are repointed.
There is no client to build, so there is nothing to roll back on a transient
failure. SetSDKKey fails only on a key that is not valid in an HTTP header,
which no retry fixes, so the environment parks on its current anchor and says so.

Stream handlers are built per request behind a re-check of the accepted set. The
map they used to live in was what made revocation racy: a handler stayed in it
until the removal was processed, so a request that authenticated before a
revocation still found a working handler. The REPORT stream endpoints let a
client pace the body read that precedes the lookup, so that window is as long as
the client wants.

Removing a credential no longer closes the SDK client. The client is not tied to
the key it was built with any more, so closing it there would tear down an
environment's only upstream connection whenever any SDK key was revoked.

The status resource's credential fields now come from one accepted-set snapshot,
so they cannot disagree with each other, and neither they nor the metrics
derived from them depend on map iteration order.

Also pays off the deletions the previous phases deferred, because their callers
live here: the single-key rotation shims, UpdateCredential and CredentialUpdate,
and EnvironmentParams.ExpiringSDKKey with ExpiringKeyRep.ToParams.

go.mod temporarily pins go-server-sdk#457 and go-sdk-events#63, which must
become released versions before this merges.
It was written to check an assumption about the cost of building a handler per
request rather than caching it, which it confirmed. The numbers are recorded in
the comment on acceptsForStream; the benchmark itself is not worth carrying.
Offline mode validated a credential payload after creating the environment.
NewEnvContext seeds the rotator from the same params, so a payload declared
malformed still had its anchor and primary mobile key admitted, while every key
its arrays listed was refused. A relay serving that environment authenticated
the one credential the payload's authoritative arrays omitted, and 401'd the
SDKs holding the keys those arrays named.

Validate first and abandon the environment on a malformed payload, which is
where the auto-config stream already validates relative to applying a put. An
update validates before writing any of the identifiers, TTL or secure mode, so
a refused environment is left exactly as it was rather than half updated.

UpdateHandler now reports a refusal, because ArchiveManager recorded the
archive's version as applied either way. A corrected archive can carry the same
version and data ID as the one just refused, which the unchanged-archive check
would then skip, so the correction never landed until the process restarted. The
version advances only once the handler accepts the environment.
…e key

A payload with no mobKey and an empty mobileKeys[] is valid: it makes the
environment server-side only. Reconcile reported a mobile repoint only when a
replacement key existed, so this case produced no signal at all and the event
dispatcher kept forwarding on the key the payload had just revoked.

Report the revocation separately from a repoint, and stop the mobile analytics
endpoint when it fires. This does not rescue the events already queued: closing
runs a final flush, which goes out on the revoked key and is refused. Nothing
can deliver them, because LaunchDarkly revoked the key before relay was told.
What it does is stop relay holding a dead credential and its goroutines for the
life of the environment, and say plainly in the log that those events are lost.
…ch-up

Two paths in the re-anchor had no test.

The first is the invariant that makes a failed re-anchor safe. A brand-new
anchor's connection mappings go up before the client is re-keyed, because a key
cannot serve before its mappings exist, so a failure has to take them back down
again. The test drives a refusal through the fake client and asserts the whole
sequence, not just the end state: the key is registered, then unregistered, and
the previous anchor is left accepted and still serving.

The second is the catch-up for an anchor that moves while the initial client
build is in flight. Holding the fake factory inside its unbuffered send keeps
the build running for as long as the test needs, so the reconcile lands with no
client present and the newly built client has to be caught up to the anchor the
rotator already holds.
Stopping an endpoint closed its relays but left them attached. A later payload
that gave the environment a mobile key again only replaced the credential, which
updates relays whose goroutines have already exited, so mobile posts were
accepted with a 202 and nothing reached LaunchDarkly. That is worse than what it
replaced: before, those events at least failed loudly against a revoked key.

Release the relays as well as closing them. The endpoint keeps its configuration
and its credential, and the getters that built the relays in the first place are
lazy, so the next event after a credential is restored builds live ones.

The rest of the behaviour is unchanged: the events already queued when the key
was revoked are still lost, because nothing can deliver them.
Releasing the relays left the endpoint able to build new ones. A post that got
past the middleware before the revoked credential's connection mapping came
down, or one whose body read was still in progress, reached the lazy getter and
built live relays on the credential that had just been revoked. Those goroutines
then lived for the rest of the environment's life, which is the leak that
stopping the endpoint exists to close.

Mark the endpoint stopped and discard events while it is, so nothing rebuilds on
a credential the environment no longer accepts. Replacing the credential clears
the mark, so a restored key still resumes forwarding.
…eans

Three review comments asked what the code meant rather than what it did.

Both comments on the refusal paths in updatedArchive leaned on "the check
above" without saying which check or why it matters. lastKnownEnvs is the only
thing that decides whether a reload counts as a change, so say that, and say why
each branch records nothing: a hand-corrected environment need not change its
version or its data, so recording the refused metadata would make the correction
compare equal and be skipped.

The ReconcileCredentials contract used "re-anchor" without defining it, and the
word predates the current design, so a reader who knew the old one reasonably
read it as building a replacement client. State what the anchor is, that
re-anchoring moves that designation, that the client is re-keyed in place and
keeps its store, and which components still have to be moved by hand.
… addCredential

Two fixes that came from review of this branch rather than from me, kept and
verified here.

The stopped check belonged in the relay getters, not in dispatch. Reading the
flag and then looking up the relay were two separate acquisitions of the
endpoint's lock, so a revocation landing between them still built a relay on the
credential that had just been revoked -- the same defect the flag was added to
prevent, with a narrower window. The getters now decline while stopped, which
makes the decision and the lookup one operation, and the callers note the drop.

addCredential had no closed check. Close takes none of the reconcile locks, so a
reconcile applying its additions, or the cleanup ticker draining them, can finish
after Close has. A mapping registered then would let a request select an
environment that has no validator or stream left to serve it.

Also correct the per-connect measurements comment to say that the benchmark it
quotes was removed, rather than implying a reader can still run it.
@keelerm84
keelerm84 force-pushed the mk/SDK-3199/anchor-reanchor branch from ea8a1eb to 0b32737 Compare September 29, 2026 14:17
…ccur

Every LaunchDarkly environment has at least one global key of each kind, so the
auto-configuration stream never sends a payload that leaves an environment
without a mobile key. The signal for that transition, and everything built to
act on it, was guarding a state that does not arise.

This removes the Reconcile result field, the branch that consumed it, and the
endpoint machinery it drove: stopping an endpoint, releasing its relays, the
stopped flag that kept the lazy getters from rebuilding on a revoked credential,
and the drop path for a post that raced the revocation. Their tests go with them.

Worth recording what is being given up, because it was not free. The original
finding was real for the shape it described, and two follow-ups fixed genuine
defects in the fix: releasing the relays rather than only closing them, so a
restored credential could forward again, and moving the decision into the getters
so the check and the relay lookup could not interleave. All of that only mattered
for a payload the service does not produce.
Two comments described the same invariant and neither drew the line a reader
needs. The auto-configuration stream always names a server key, a mobile key and
an environment ID, because every LaunchDarkly environment has at least one global
key of each kind. Manual configuration is the case that does not, and it reaches
the rotator by a different route.

BuildAcceptedSet attributed an environment with no mobile key to a hand-assembled
offline archive. An archive is generated from the same LaunchDarkly data and
carries the same guarantee, so that pointed at the wrong source and implied this
function has to tolerate something that arrives somewhere else. It says instead
why the shape is accepted rather than refused -- an accepted set needs only an
SDK key and an anchor -- and that manual configuration, which never comes through
here, is where it would come from.

Initialize stated the permissive half without saying why, so a reader could not
tell whether it was a manual-configuration allowance or a gap in what the stream
sends. It now says which, and that those two checks are load-bearing for that
reason. It also records that manually configured environments are never
reconciled, so for them this is the whole credential set rather than a starting
point.
@tanderson-ld
tanderson-ld self-requested a review September 30, 2026 17:27
@tanderson-ld

Copy link
Copy Markdown

In the description of the PR at the time of writing this there is:

⚠️ go.mod temporarily pins launchdarkly/go-server-sdk#457 and launchdarkly/go-sdk-events#63 at their PR commits. Both must be released and repinned before this merges.

Is this still accurate?

@keelerm84

Copy link
Copy Markdown
Member Author

In the description of the PR at the time of writing this there is:

⚠️ go.mod temporarily pins launchdarkly/go-server-sdk#457 and launchdarkly/go-sdk-events#63 at their PR commits. Both must be released and repinned before this merges.

Is this still accurate?

They are still using those PRs. I'm not planning on merging those until I have this work mostly finished relay side. That's why this is all being merged into a feature branch instead of directly into v9.

Comment thread internal/relayenv/env_context.go Outdated
// ReconcileCredentials updates the environment's accepted credentials to match newSet. Calls are
// serialized. The method owns the order of operations: add, re-anchor, remove. Adding first
// registers the new keys' mappings, the re-anchor then moves the upstream connection while the
// outgoing key still authenticates downstream traffic, and revoked mappings come down last.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Usually in systems migrating credentials, revocation happens first to ensure least access. This comment says "revoked mappings come down last". Is there any risk that a revoked key gets used successfully by downstream traffic after the upstream connection moved?

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.

Yes, deliberately — and your instinct is right that it's worth pinning down, because the real exposure isn't this ordering.

Relay's keys do two jobs. The anchor is what relay authenticates with upstream; every accepted key is what SDKs authenticate with downstream. Tearing a downstream mapping down early doesn't improve least access, it just 401s SDKs that are still holding a key LaunchDarkly said was valid until some expiry. So within one reconcile the order is add, re-anchor, remove, which guarantees there's no instant where neither the outgoing nor the incoming key can serve. That window is microseconds to milliseconds, inside a single reconcile.

How long a revoked key actually keeps working is decided by its expiry, not by this ordering, and the two cases differ:

  • Revoked outright (the payload stops listing it, no expiry): reconcileAcceptedKeys deletes it and queues its expiration in the same reconcile, so it comes down at the end of that same pass.
  • Deprecated with a grace period: it keeps authenticating until its expiry, which is the intended behaviour — that's what the grace period is for.

Where least access genuinely leaks is the thing you found further down: IsAccepted is a bare map lookup and never consults Expiry, so a deprecated key stays valid up to one ExpiredCredentialCleanupInterval past its stated expiry, and that interval has a validated minimum and no maximum. That's the real answer to "can a revoked key still be used successfully" — not the within-reconcile ordering, but the slop after expiry. Enforcing expiry in IsAccepted closes it.

Your removal-ordering point compounds the same thing from the other side: the comment justifies deferring the outgoing anchor's removal, but the code defers every revocation, so the window scales with the number of additions in the payload. Revoking non-anchor keys first and keeping only the outgoing anchor for last is the right shape, and I'll reword this comment to say what it actually covers rather than implying all removals need to be last.

Comment thread internal/relayenv/env_context_impl.go Outdated
// change it. Its HTTP requests already set Authorization per request rather than relying on baked
// in headers, so re-keying it in place is within reach; what stands in the way is coordinating a
// backoff reset and a reconnect with the goroutine that owns its retry strategy. Refer to
// SDK-3200. It is nil when big segments are not configured.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do you actually want this ticket number, SDK-3200, here?

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.

No, good catch — it should come out. This repository is public and SDK-3200 is an internal ticket, so to anyone reading ld-relay outside LaunchDarkly that reference is a dead end. It's also the same class of thing the repo already avoids in comments, since the reference rots independently of the code.

The explanation around it stands on its own: the synchronizer takes its key at construction and exposes no way to change it, its requests already set Authorization per request rather than relying on baked-in headers, so re-keying it in place is within reach, and what stands in the way is coordinating a backoff reset and a reconnect with the goroutine that owns its retry strategy. That tells the next reader what the obstacle is and roughly what it would take, without needing a ticket they can't open. I'll drop the sentence pointing at SDK-3200 and keep the rest.

@tanderson-ld

Copy link
Copy Markdown

I ran a multi-agent review against this PR (three parallel reviewers — general, security-focused, and adversarial — at head 3f37593, with the pinned go-server-sdk / go-sdk-events commits checked out for cross-verification). Findings were deduplicated and adversarially verified; the top three are reproduced by runnable failing tests, included in collapsible blocks below so they can be pulled into a fix as regression tests. Line references are to head.

Blocking

1. Critical — a key rotation can no longer heal a dead environment

Two halves, same root: the environment now gets exactly one client-build attempt for its whole lifetime (startSDKClient has one call site, internal/relayenv/env_context_impl.go:397; the old recovery path — addCredential → go c.startSDKClient(key, nil, false) — was deleted with nothing replacing it).

  • initErr is never cleared. It is set once (env_context_impl.go:500) and reanchor never touches it. The middleware rejects every request while GetInitError() is ErrInitializationFailed (internal/middleware/middleware.go:146, :252), and MakeCustomClient returns a live client together with that error on a 401. So an environment that first came up on a bad/revoked key answers 401 forever — even after a successful rotation re-keys the client and moves the anchor.
  • SetSDKKey cannot restart a dead data system. It swaps the header for future requests only; the pinned SDK treats 401/403 as unrecoverable, permanently removes each synchronizer, and exits the run loop (internal/datasourcev2/helpers.go:37-50, internal/datasystem/fdv2_datasystem.go:358-487). Nothing in relay watches for DataSourceStateOff except status reporting.

No race is required: relay restarts after a rotation happened while it was down → the auto-config cache replays the stale key → 401 kills the data system → the live payload's re-anchor calls SetSDKKey, logs "moved the environment to a new SDK key", and the environment stays dead (serving stale data, or 401ing everything per the first bullet) until a process restart.

Suggested direction: after a successful SetSDKKey, check GetDataSourceStatus().State == Off and close/rebuild the client on the new anchor (or better, give the SDK PR a data-system restart); clear initErr on a successful re-anchor.

Failing proof test: internal/relayenv/proof_init_error_survives_rotation_test.go
package relayenv

import (
	"log/slog"
	"testing"
	"time"

	"github.com/launchdarkly/ld-relay/v9/config"
	"github.com/launchdarkly/ld-relay/v9/internal/sdks"
	st "github.com/launchdarkly/ld-relay/v9/internal/sharedtest"
	"github.com/launchdarkly/ld-relay/v9/internal/sharedtest/testclient"

	ld "github.com/launchdarkly/go-server-sdk/v7"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestProofInitErrorSurvivesAKeyRotation(t *testing.T) {
	envConfig := st.EnvMain.Config
	readyCh := make(chan EnvContext, 1)
	clientCh := make(chan *testclient.FakeLDClient, 1)
	inner := testclient.FakeLDClientFactoryWithChannel(true, clientCh, nil)

	// LaunchDarkly answers 401 for the configured key, so MakeCustomClient returns a live client
	// together with ErrInitializationFailed. Any other key works.
	factory := func(sdkKey config.SDKKey, cfg ld.Config, timeout time.Duration) (sdks.LDClientContext, error) {
		c, err := inner(sdkKey, cfg, timeout)
		if sdkKey == envConfig.SDKKey {
			return c, ld.ErrInitializationFailed
		}
		return c, err
	}

	env := makeBasicEnv(t, envConfig, factory, slog.Default(), readyCh)
	defer env.Close()
	requireEnvReady(t, readyCh)
	client := requireClientReady(t, clientCh)
	require.ErrorIs(t, env.GetInitError(), ld.ErrInitializationFailed)

	rotated := config.SDKKey("sdk-rotated-and-valid")
	env.(*envContextImpl).reconcileCredentials(mustAcceptedSet(t, rotated, "", ""), time.Unix(1000, 0))
	require.Equal(t, rotated, client.CurrentSDKKey(), "the client must have been re-keyed")
	require.Equal(t, rotated, env.GetAnchorKey())

	// middleware.SelectEnvironmentByAuthorizationKey answers 401 to every request for an
	// environment whose GetInitError is ErrInitializationFailed. Nothing clears it.
	assert.NoError(t, env.GetInitError(),
		"rotating onto a working key must clear the environment's initialization failure")
}

Fails on head with the client verifiably re-keyed and the anchor moved, yet GetInitError() still returning ErrInitializationFailed.

2. High — an auto-config payload relay refuses is acknowledged and never retried

internal/autoconfig/stream_manager.go:571-574 records the payload's version via envReceiver.Upsert before dispatching to the handler, and the handler methods return nothing — a BuildAcceptedSet refusal in relay/autoconfig_actions.go cannot propagate. A corrected payload at the same version is then ActionNoop, and a stream reconnect does not help because envReceiver outlives the connection, so the reconnect put is version-filtered too.

Two documentation/code mismatches compound it:

  • relay/autoconfig_actions.go:33-38 claims "the stream manager validates before it records the payload's version" — there is no credential validation anywhere in internal/autoconfig.
  • internal/credential/accepted_set.go:29-38 documents the required remedy (reconnect the stream with jitter, since there's no NAK channel) — unimplemented.

The PR implemented exactly this recovery for the offline/filedata path (error returns; lastKnownEnvs not advanced on refusal); the asymmetry is the bug. Net effect of a refused rotation payload: relay keeps accepting the outgoing credential indefinitely and never picks up the replacement — the opposite of what the revocation intended.

Related, same file: AddEnvironment creates the environment (and UpdateEnvironment applies SetIdentifiers/RefreshEnvironmentIndexes/SetTTL/SetSecureMode) before validation, so a refused payload still installs its singular sdkKey/mobKey (seeded via Rotator.Initialize) / half-applies the update. The filedata side validates first, and its comment (relay/filedata_actions.go:80-82) asserts the auto-config path does the same — it doesn't.

Failing proof test: relay/proof_malformed_autoconfig_retry_test.go
package relay

import (
	"encoding/json"
	"testing"
	"time"

	c "github.com/launchdarkly/ld-relay/v9/config"
	"github.com/launchdarkly/ld-relay/v9/internal/autoconfig"
	"github.com/launchdarkly/ld-relay/v9/internal/envfactory"

	"github.com/launchdarkly/go-test-helpers/v3/httphelpers"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func proofPatchEventFromRep(rep envfactory.EnvironmentRep) httphelpers.SSEEvent {
	body, _ := json.Marshal(rep)
	jsonData, _ := json.Marshal(autoconfig.PatchMessageData{
		Path: "/environments/" + string(rep.EnvID),
		Data: body,
	})
	return httphelpers.SSEEvent{Event: autoconfig.PatchEvent, Data: string(jsonData)}
}

func TestProofMalformedAutoConfigCredentialPayloadIsNeverRetried(t *testing.T) {
	initialEvent := makeAutoConfPutEvent(testAutoConfEnv1)
	autoConfTest(t, testAutoConfDefaultConfig, &initialEvent, func(p autoConfTestParams) {
		_ = p.awaitClient()
		env := p.awaitEnvironment(testAutoConfEnv1.id)
		require.Equal(t, testAutoConfEnv1.SDKKey(), env.GetAnchorKey())

		nextVersion := testAutoConfEnv1.version + 1
		rotated := c.SDKKey("sdkkey1-rotated")

		// Malformed: the designated anchor (sdkKey.value) is absent from sdkKeys[].
		malformed := testAutoConfEnv1.toEnvironmentRep()
		malformed.Version = nextVersion
		malformed.SDKKey = envfactory.SDKKeyRep{Value: rotated}
		malformed.SDKKeys = []envfactory.ConcurrentKeyRep{{Key: "some-other", Value: "sdkkey-unrelated"}}
		malformed.MobileKeys = []envfactory.ConcurrentKeyRep{{Key: "m", Value: string(testAutoConfEnv1.mobKey)}}
		p.stream.Enqueue(proofPatchEventFromRep(malformed))

		// The refusal keeps the previous credentials, which is the intended safe response.
		time.Sleep(200 * time.Millisecond)
		assert.Equal(t, testAutoConfEnv1.SDKKey(), env.GetAnchorKey())

		// The service corrects the payload. Same version: nothing about the environment changed
		// except the shape relay refused.
		corrected := testAutoConfEnv1.toEnvironmentRep()
		corrected.Version = nextVersion
		corrected.SDKKey = envfactory.SDKKeyRep{Value: rotated}
		corrected.SDKKeys = []envfactory.ConcurrentKeyRep{{Key: "primary", Value: string(rotated)}}
		corrected.MobileKeys = []envfactory.ConcurrentKeyRep{{Key: "m", Value: string(testAutoConfEnv1.mobKey)}}
		p.stream.Enqueue(proofPatchEventFromRep(corrected))

		// And the "put" a stream reconnect delivers, at the same version.
		p.stream.Enqueue(makeAutoConfPutEvent(testAutoConfEnv1))

		time.Sleep(300 * time.Millisecond)
		assert.Equal(t, rotated, env.GetAnchorKey(),
			"a corrected payload at the same version must still be applied")
		foundByRotated, _ := p.relay.getEnvironment(rotated)
		assert.NotNil(t, foundByRotated, "the corrected key must be able to authenticate")
	})
}

Fails on head: neither the corrected same-version patch nor the reconnect-style put is applied, and the rotated key never authenticates.

3. High — startSDKClient installs a client into an already-closed environment

env_context_impl.go:473-474 assigns c.client = client under c.mu with no c.closed check; Close() only closes an already-installed client. An environment deleted (or relay shut down) inside the init window leaks the client, its data-system goroutines, upstream connection, and the status-listener goroutine from internal/sdks/client_factory.go:91-98. The gap predates this PR, but this PR's c.closed audit guarded every other writer (addCredential, applyCredentialSet, reanchor) and missed this one — and the stakes rose now that this is the environment's only client and removeCredential no longer closes anything. Fix is small: under the lock, if c.closed, unlock and client.Close() instead of installing.

Failing proof test: internal/relayenv/proof_client_built_after_close_test.go
package relayenv

import (
	"log/slog"
	"testing"
	"time"

	"github.com/launchdarkly/ld-relay/v9/config"
	"github.com/launchdarkly/ld-relay/v9/internal/sdks"
	st "github.com/launchdarkly/ld-relay/v9/internal/sharedtest"
	"github.com/launchdarkly/ld-relay/v9/internal/sharedtest/testclient"

	ld "github.com/launchdarkly/go-server-sdk/v7"
	helpers "github.com/launchdarkly/go-test-helpers/v3"

	"github.com/stretchr/testify/require"
)

func TestProofClientBuiltAfterCloseIsLeaked(t *testing.T) {
	envConfig := st.EnvMain.Config
	release := make(chan struct{})
	clientCh := make(chan *testclient.FakeLDClient, 1)
	inner := testclient.FakeLDClientFactoryWithChannel(true, clientCh, nil)
	factory := func(sdkKey config.SDKKey, cfg ld.Config, timeout time.Duration) (sdks.LDClientContext, error) {
		<-release
		return inner(sdkKey, cfg, timeout)
	}

	env := makeBasicEnv(t, envConfig, factory, slog.Default(), nil)
	require.NoError(t, env.Close())
	close(release)

	client := helpers.RequireValue(t, clientCh, time.Second, "timed out waiting for the client")
	if !helpers.AssertChannelClosed(t, client.CloseCh, time.Second,
		"a client whose build finished after Close must still be closed") {
		t.FailNow()
	}
}

Should fix

  • Docs overstate the re-anchor (internal/relayenv/env_context.go:40-53, env_context_impl.go:602-612): the open upstream stream is not moved — the pinned SDK is explicit that an open connection continues until the service/network ends it — and SetSDKKey returning nil never validates the new key with a real request. internal/sdks/client_factory.go:24-33 states it correctly; the two relayenv doc blocks (and the ordering rationale in applyCredentialSet) don't. During a grace rotation, relay keeps consuming upstream on the deprecated key for the whole grace period.
  • Remove-last is broader than its rationale (env_context_impl.go:557-600): "a revoked key keeps working until the connection has moved" justifies deferring only the outgoing anchor's removal, but every revoked key is removed after all additions and the re-anchor — and non-stream endpoints (poll, eval, events, PHP) authenticate via the connection mapper alone with no IsAccepted re-check. With unbounded sdkKeys[]/mobileKeys[] (no size cap in ToParams/BuildAcceptedSet, per-key lock acquisitions and unbuffered eventsource registrations), the revocation window is O(additions). Suggest removing non-anchor revocations first, keeping only the outgoing anchor's removal for last, and capping the accepted-set size.
  • No ownership check on credential mappings (relay/environment_lookup.go:213-219): mapParams silently overwrites and unmapParams deletes regardless of owner, so one environment's payload listing another's key re-points (and, on later revocation, unmaps) the other environment's routing. Pre-existing, but the accepted-set arrays widen it from ~4 keys to unbounded. Defense-in-depth: refuse (and log) a mapping for a credential already owned by a different environment, and pass the owning env to removal.
  • TestRefusedEnvironmentIsRetriedWhenACorrectedFileKeepsTheSameVersion is flaky — 8/10 failures under whole-package -race load on this machine. ArchiveManager stats the file before reading it (archive_manager.go:103,112-128), so a stat landing mid-write records a partial size and the next tick sees a spurious change; the new refusal-continue then re-offers the environment on every reload (also a production noise source while an env is refused). Passes in isolation.

Worth a look (low)

  • The install-time catch-up re-key (env_context_impl.go:477-482) logs and discards its error — no designation revert, no initErr — while reanchor does a full rollback for the identical failure. More generally: a failed init is visible in /status, a parked re-anchor is only an error log. The three paths that can fail to apply an anchor (init, re-anchor, catch-up) each behave differently; worth converging.
  • IsAccepted never consults Expiry (internal/credential/rotator.go:170-186) — a deprecated key stays valid up to one ExpiredCredentialCleanupInterval (which has a validated minimum but no maximum) past its stated expiry. The new per-request stream check would be the natural cheap place to enforce it.
  • A rolled-back re-anchor re-admits the previous anchor without its expiry (rotator.go:341-359) and strips it from expirations — a revoked/expiring key becomes permanently accepted, and the parked state has no deadline or retry.
  • Rotator lifecycle logs ("credential is now accepted" / "…revoked" / "…expired") use the relay-global logger with no environment attribution (env_context_impl.go:200) — hard to audit a rotation in a multi-env fleet.
  • FakeLDClient.Key is now written under c.lock but read bare at ~12 call sites (and SetSDKKeyErr written bare); CloseCh close isn't once-guarded. Latent race for the next test that rotates and reads .Key; suggest unexporting Key behind CurrentSDKKey().
  • TestAddCredentialLeavesNoMappingOnAClosedEnvironment short-circuits at applyCredentialSet's closed-check and never reaches the ticker-drain interleaving its comment describes — the assertion holds with every guard deleted.
  • The 13ns/105ns handler-build figures in env_context_impl.go:753-757 cite a benchmark and commit no longer reachable from this branch — either keep the benchmark or drop the numbers.

Verified clean (adversarial checks that failed to break it)

For confidence: overlapping ReconcileCredentials calls converge (300-reconcile -race stress probe — final mappings exactly matched the accepted set); no endpoint family accepts a revoked key after a reconcile returns (streaming V1/V2/mobile/JS, polling, eval, events, PHP, REPORT variants — a strict improvement over the old handler map, whose pacing race is genuinely closed); event integrity holds across a re-anchor (senders re-resolve Authorization at send time; buffered events flush on the new key; verified against both pinned dependency PRs); SetSDKKey's implementation matches the contract documented in client_factory.go exactly; no big-segment synchronizer goroutine leak across repeated rotations; the new c.closed guards and lock ordering are sound, including Close()'s unlocked bigSegmentSync read (safe via the reanchor closed-check).

Full test suite on head: go build/go vet clean, -race clean on all touched packages; the only ./... failures besides the flaky filedata test are two pre-existing macOS-specific socket tests in internal/streams, untouched by this diff.

@keelerm84

Copy link
Copy Markdown
Member Author

Thanks — this is a good catch list, and the three blocking items are all real. The important context is that all three are already fixed in PRs stacked above this one, which a review scoped to #896's head can't see. This is the third of five: #897, #898 and #899 sit on top of it and are pushed.

Blocking

1. Critical, rotation can't heal a dead environment — fixed in #899. Your suggested direction is exactly what's there: after a successful SetSDKKey, check GetDataSourceStatus().State == Off and rebuild the client on the new anchor, and clear initErr. Two things beyond what you describe. initErr is cleared on a plain re-key only when the data source is actually Valid — clearing it unconditionally would be worse than the bug, because an interrupted client can hold an empty store and relay would answer 200 with no flags instead of refusing. And a nil client from a failed build triggers the rebuild too, which is the same defect by a different route and which Bugbot caught separately; that needed a guard so two reconciles can't start concurrent builds.

2. High, refused payload acknowledged and never retried — fixed in #897. validateCredentialPayload now runs before envReceiver.Upsert on both the put and patch paths, which is precisely the ordering you identify as missing, and for the same reason: leaving the version where it was is what lets the replay be applied.

The doc/code mismatches are the more useful half, because one is live in this PR. autoconfig_actions.go claims "the stream manager validates before it records the payload's version" and at this head that is simply false — it's a forward reference to a fix one PR up, so a reader of #896 alone is told something untrue. I'll fix that here. The accepted_set.go reconnect doc I'd already reworded in #897, when partial refusals stopped reconnecting: a put that applied at least one environment no longer restarts the stream, because the refused environment comes back identical and the reconnect only costs the healthy environments their updates.

3. High, startSDKClient installs into a closed environment — fixed in #899, with the one-line shape you prescribe. Worth noting my own review ruled this pre-existing and out of scope; you're right that the stakes changed once this became the environment's only client, and it rode in with the rebuild work regardless.

Didn't reproduce

The flaky filedata test. I can't get it to fail here: 0/10 whole-package under -race, 0/50 for the test alone under -race, 0/4 under four parallel package runs. What machine and OS did you see 8/10 on?

Your mechanism is sound either way and worth acting on independently of the test: ArchiveManager stats before reading, a stat landing mid-write records a partial size, and the refusal-continue then re-offers the environment on every spurious reload. That second part is a production noise source while an environment is refused, which is the bit I care about more than the test.

TestAddCredentialLeavesNoMappingOnAClosedEnvironment. You say the assertion holds with every guard deleted. I removed all three c.closed guards — addCredential, reanchor, and the applyCredentialSet one you name as the short-circuit — and it fails. Did your experiment differ, or were you on an earlier head?

Agreed, acting on here

The handler-build figures cite commit e8c6166, which is no longer reachable from HEAD after the rebasing — the object exists but isn't an ancestor. Confirmed; I'll drop the SHA.

Want to talk about these rather than just patch them

  • IsAccepted never consults Expiry. Confirmed — it's a bare map lookup, so a deprecated key stays valid up to one cleanup interval past its stated expiry, and that interval has a validated minimum and no maximum. My review missed this one entirely. Your suggestion to enforce it in the new per-request stream check looks right.
  • Removal ordering broader than its rationale. Agreed on the substance: the comment justifies deferring the outgoing anchor's removal and the code defers every revocation. Revoking non-anchor keys first and keeping only the outgoing anchor for last is a real tightening. I'd push back on the size-cap half — the input is the trusted stream, and a cap is a speculative bound on a trusted source.
  • Re-anchor docs overstate what moves. Agreed, and client_factory.go already says it correctly. The consequence you draw out isn't documented anywhere: during a grace rotation relay keeps consuming upstream on the deprecated key for the whole grace period.
  • Mapping ownership in environment_lookup.go. Pre-existing, genuinely widened by unbounded arrays. Defense-in-depth rather than a fix, but worth a ticket.

Replying to your two inline comments separately — the revocation-ordering question is the interesting one and connects directly to the removal-ordering finding above.

Review found each of these; none changes behaviour.

reconcileCredentials claimed the stream manager validates a credential payload
before recording its version. That is true in a later PR and false here, so this
PR on its own told a reader something untrue. It now describes only what this
function does, and why keeping the previous set is the safe response, which holds
either way.

The handler-build measurements named the commit that removed the benchmark they
came from. Rebasing has since orphaned that commit, so the reference sent a
reader looking for something no longer reachable. The numbers stay, with the
benchmark described as throwaway rather than identified by hash.

The add, re-anchor, remove rationale justified deferring the outgoing anchor's
removal and then implied every removal has to be last. It now says which key the
deferral is for, and that the others are removed later than they need to be, with
the expiry rather than this ordering being what decides how long a revoked key
authenticates.

The big-segment synchronizer comment pointed at an internal ticket. This
repository is public, so that is a dead end for most readers; the explanation of
what blocks re-keying it in place stands without it.
Three places described the same operation and only sdks.LDClientContext.SetSDKKey
was accurate. The two in relayenv said the re-anchor "moves the upstream
connection", which reads as though the connection relocates when reanchor
returns. It does not: SetSDKKey changes the credential future requests carry, and
a stream that is already open keeps the previous key until LaunchDarkly or the
network ends it.

Both now say what happens instead, and ReconcileCredentials records the
consequence that was written down nowhere. A graceful rotation keeps the outgoing
key alive for its grace period on purpose, and a healthy stream can outlast that
window, so Relay may go on consuming flag data on the deprecated key well after
it reports the rotation applied.

reanchor also notes that a nil return from SetSDKKey means only that the value is
a legal HTTP header. No request is made there, so it is not evidence that
LaunchDarkly accepts the key -- which is how a successful-looking re-anchor onto
a dead data system went unnoticed for as long as it did.

The success log said the environment had moved to a new key. It now says the key
was changed and that an open connection keeps the previous one until it
reconnects, which is what actually happened. Nothing asserts on that text.
@keelerm84

Copy link
Copy Markdown
Member Author

Worked through the four remaining items. Two landed, two we're deliberately leaving — reasoning for each below so the decisions are on the record rather than just the outcomes.

Landed

Re-anchor docs overstating what moves (a268af88) — you were right, and it was the one of the four that was a straight defect rather than a judgment call. Both relayenv blocks now say the re-anchor re-keys the client rather than moving the connection, and both note that an already-open stream keeps the previous key until it reconnects. ReconcileCredentials carries the consequence you drew out, which was written down nowhere: a graceful rotation keeps the outgoing key alive for its grace period on purpose, a healthy stream can outlast that window, so Relay may go on consuming flag data on the deprecated key well after it reports the rotation applied.

reanchor also now records that a nil return from SetSDKKey means only that the value is a legal HTTP header — no request is made, so it is not evidence LaunchDarkly accepts the key. That is worth having in writing, since it is exactly how a successful-looking re-anchor onto a dead data system escaped notice. The success log no longer claims the environment moved either; it says the key was changed and that an open connection keeps the previous one until it reconnects.

The four smaller corrections (438bdb96) — the autoconfig_actions.go claim about upstream validation, the orphaned e8c6166 reference, the SDK-3200 pointer, and the removal-ordering rationale.

Leaving, with reasons

IsAccepted never consults Expiry. Confirmed exactly as you describe, and we're keeping it. Ticker-driven cleanup is how expiry has always been enforced here: at bd063f29 there is no IsAccepted at all, and StepTime has the same shape it does now — walk the deprecated keys, expire the ones whose time has passed, hand the expirations to the ticker. The slop between a stated expiry and actual removal is long-standing and not something this work changed; the new code just puts an expiry next to a membership test and invites the question.

One refinement on the framing, because it matters if anyone picks this up later: IsAccepted isn't where the leak lives. The connection mapping is what gates every endpoint, and it comes down on the tick, so tightening IsAccepted would cover the stream paths only and leave polling, evaluation, events and PHP on the ticker regardless. Enforcing expiry properly means a check wherever the middleware resolves a credential — a bigger change, and a different conversation from this PR.

Removal ordering broader than its rationale. Agreed on the substance, declining on magnitude. The window is bounded by one reconcile: a couple of map writes and an eventsource registration per addition, plus a re-anchor measured in single-digit microseconds. Set that beside the expiry decision directly above — a key past its stated expiry keeps working for up to a minute, by design, with no configured maximum on the interval. Reordering inside the reconcile would shave microseconds off one path while that minute stands, so it would be odd to take one and not the other.

Also worth flagging for whoever revisits it: "remove non-anchor revocations first" taken literally creates a gap, since removals have to follow additions or a payload replacing key A with key B leaves a window where neither authenticates. The defensible version is narrower — non-anchor removals between the additions and the re-anchor. I have reworded the comment so it no longer implies every removal needs to be last, which was the part that was actually wrong.

No ownership check on credential mappings. Leaving this one too, though I'll note it's the one where I think the convention cuts your way: the payload is a genuine system boundary, so defending there is consistent rather than speculative. Both functions are byte-identical at bd063f29, and triggering it needs the backend to emit one environment's credential inside another's payload, which is a backend defect rather than anything reachable from outside. If it ever does become a concern, the cheap version is detection rather than refusal — log when mapParams finds a credential already mapped to a different environment, naming both, and proceed. Refusing would mean inventing a policy for which environment wins, and Relay has no basis to choose.

Still curious about the two that wouldn't reproduce here, whenever you get a chance — the filedata flake and the closed-environment test — since a difference in machine or head would be useful to know about.

@keelerm84
keelerm84 requested a review from tanderson-ld October 1, 2026 16:03
@keelerm84
keelerm84 merged commit 6c54869 into feat/concurrent-keys-v9 Oct 5, 2026
16 checks passed
@keelerm84
keelerm84 deleted the mk/SDK-3199/anchor-reanchor branch October 5, 2026 15:09
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