Skip to content

fix: Make FDv1 fallback TTL-based and recover to FDv2 after the TTL - #379

Draft
beekld wants to merge 1 commit into
mainfrom
devin/1792430256-fdv1-fallback-ttl
Draft

beekld wants to merge 1 commit into
mainfrom
devin/1792430256-fdv1-fallback-ttl

Conversation

@beekld

@beekld beekld commented Oct 19, 2026 •

Copy link
Copy Markdown

Makes the server SDK's FDv1 fallback directive TTL-based and never permanent, per sdk-specs#251 (DATASYSTEM v2 1.6 / CSFDV2 8.3).

Closes SDK-2988, part of SDK-2935. Reference: cpp-sdks#631.

  • X-LD-FD-Fallback-TTL and goodbye.protocolFallbackTTL are now read. A TTL in (0, 3600]s is used exactly as sent. A missing or invalid TTL becomes a 1 hour default jittered into [30min, 1h].
  • A goodbye that carries protocolFallbackTTL is now treated as an FDv1 fallback directive (streaming and polling).
  • When the TTL elapses, FDv2 restarts. This happens whether or not an FDv1 fallback synchronizer is configured.
  • With no FDv1 synchronizer, the status is now Interrupted with the original error, instead of Off.
  • The FDv1 source is disconnected and disposed before FDv2 restarts, so the two never write to the store at the same time.

Requirements

  • I have added test coverage for new or changed functionality
  • I have followed the repository's pull request submission guidelines
  • I have validated my changes against all supported platform versions (ran the server SDK tests on net8.0 only)
Implementation details

Before

FDv1FallbackActionApplier called BlockAll(FDv2), and nothing ever undid it. With no FDv1 synchronizer configured, the outer composite ran out of sources and the data system went Off. Neither TTL source was read, and a goodbye never started a fallback.

Describe the solution you've provided

  • TTL rules (new FDv1FallbackTtl): Default() returns 1h minus uniform [0, 30min]. FromServiceTtl(seconds) returns the TTL as-is when it is in (0, 3600], and the default otherwise. FromHeaders(...) requires whole seconds; anything else gets the default.
  • Goodbye: the Goodbye payload now parses protocolFallbackTTL. If the field is present but not a number, it still counts as a directive and uses the default TTL. FDv2ActionGoodbye.FDv1FallbackTtl is null when there is no directive.
  • Streaming and polling: the TTL travels on two new internal fields, ErrorInfo.FDv1FallbackTtl and ChangeSet.FDv1FallbackTtl, so the public API is unchanged.
  • Recovery (new DirectedFallbackRecovery, one per FDv2 data source):
    • Each directive schedules a recovery attempt for when the TTL elapses. A repeated directive replaces the pending attempt with the new TTL.
    • Disposing the data source cancels the timer.
    • On recovery, the new ICompositeSourceActionable.UnblockAll() restores the original entries, then GoToFirst / StartCurrent re-run the full initializer + synchronizer flow (spec 1.6.4.2 allows this).
    • With no FDv1 synchronizer, the applier disposes the current source and doesn't advance, so nothing runs until recovery.
  • Single writer: GoToFirst disconnects and disposes the FDv1 source before building the FDv2 source.

Describe alternatives you've considered

  • Recovering only the synchronizers instead of re-running initializers. The spec allows either. Restoring the whole list was simpler and reused the existing composite operations.
  • Adding a placeholder "idle" entry for the no-FDv1 case. I rejected it: when synchronizers are exhausted normally, the composite would land on that entry instead of going Off.

Deliberately left unchanged

  • Spec 1.6.4.6 (a directive received while already on FDv1). The .NET FDv1 polling synchronizer doesn't inspect X-LD-FD-Fallback, so this case can't happen today.
  • Start() result with no FDv1 synchronizer. When the directive arrives before any data and no FDv1 synchronizer is configured, Start() still resolves false, as before, and InitializationTracker stays terminal. A later recovery that brings data doesn't flip LdClient.Initialized, although evaluations still use the store.
  • Contract tests. The v3 harness doesn't cover TTLs. Its directive without FDv1 fallback configured halts the data system and directed fallback is terminal tests should still pass, because a directive with no TTL now defaults to 30–60min, which is longer than those tests watch. Their names and comments describe the old semantics, though, and should be updated in the harness.

Tests

  • FDv1FallbackTtlTest: valid TTLs come back unchanged; invalid ones give a default in [30min, 1h].
  • Goodbye parsing and the protocol handler.
  • Polling and streaming: TTL from headers and from a directive goodbye.
  • Applier recovery, with and without an FDv1 synchronizer.
  • End to end: recovery without an FDv1 synchronizer (this replaces the old "halts the data system" test), and FDv1 being disposed before FDv2 restarts.

Link to Devin session: https://app.devin.ai/sessions/e8d6f6e762ae490db6101c46612b1306
Open in Devin Desktop: https://app.devin.ai/desktop/session/e8d6f6e762ae490db6101c46612b1306?variant=devin
Requested by: @beekld

Co-Authored-By: Bee Klimt <bklimt@launchdarkly.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant