Skip to content

feat: Add the FDv2 data system orchestrator - #190

Open
beekld wants to merge 13 commits into
mainfrom
bklimt/SDK-2753/fdv2-orchestrator
Open

feat: Add the FDv2 data system orchestrator#190
beekld wants to merge 13 commits into
mainfrom
bklimt/SDK-2753/fdv2-orchestrator

Conversation

@beekld

@beekld beekld commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the FDv2 orchestrator, the DataSystem implementation that owns the
in-memory store and keeps it populated. It runs an initializer phase to obtain
a basis, then a synchronizer phase for ongoing changes.

The initializer phase tries each initializer in order until one produces a
basis. The synchronizer phase then runs one synchronizer at a time and rotates
between them on three triggers:

  • A fallback timer advances to the next synchronizer after a sustained
    interruption.
  • A recovery timer returns to the primary synchronizer after the recovery
    interval.
  • A terminal error blocks a synchronizer and advances past it.

Initialization is signaled as complete on the first basis and as failed once
every source is exhausted without one, because the client's initialization path
already resolves to success or failure. Synchronizer status transitions are
logged, with interruptions at info (not repeated while ongoing) and terminal
errors at warn.


Note

Overview
Introduces FDv2DataSystem, the FDv2 DataSystem implementation that owns an in-memory store and keeps it updated via a two-phase async run loop spawned from start.

Initializer phase walks factory-built initializers until one returns a basis (full changeset with a selector), applying each non-None changeset and signaling init_complete(true) once any full payload lands (after the initializer chain when needed). Synchronizer phase drives one synchronizer at a time through SourceManager: cyclic rotation, blocking on terminal errors, fallback after sustained Interrupted, and recovery back to the prime when a backup source was active. Selectors are passed into next and updated per changeset rules (None kind preserves selector; selectorless partial clears it).

Wires the module in fdv2/mod.rs and drops dead_code allowances on ChangeSet::selector and TransactionalDataStore::apply now that the orchestrator consumes them. Adds a large #[cfg(test)] suite covering init/failure, rotation, timers, and selector edge cases.

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

@beekld
beekld force-pushed the bklimt/SDK-2753/fdv2-orchestrator branch from 4ea2eb8 to 775e611 Compare August 10, 2026 18:41
@beekld
beekld force-pushed the bklimt/SDK-2753/fdv2-orchestrator branch from 775e611 to 2fa17de Compare August 10, 2026 18:46
@beekld
beekld force-pushed the bklimt/SDK-2753/fdv2-orchestrator branch from 2fa17de to a523fa8 Compare August 12, 2026 21:45
@beekld
beekld force-pushed the bklimt/SDK-2753/fdv2-orchestrator branch from a523fa8 to 3e77e84 Compare August 12, 2026 22:10
Base automatically changed from bklimt/SDK-2742/fdv2-streaming to main August 13, 2026 21:29
@beekld
beekld force-pushed the bklimt/SDK-2753/fdv2-orchestrator branch from 3e77e84 to f3e5a99 Compare August 13, 2026 21:29
@beekld
beekld marked this pull request as ready for review August 13, 2026 21:35
@beekld
beekld requested a review from a team as a code owner August 13, 2026 21:35

@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 f3e5a99. Configure here.

Comment thread launchdarkly-server-sdk/src/fdv2/data_system.rs
@beekld
beekld force-pushed the bklimt/SDK-2753/fdv2-orchestrator branch from f3e5a99 to 58c2a46 Compare August 14, 2026 20:53

@keelerm84 keelerm84 left a comment

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.

A few questions and suggestions on the orchestrator, mostly around the initializer chain and test coverage.

Comment thread launchdarkly-server-sdk/src/fdv2/data_system.rs Outdated
Comment thread launchdarkly-server-sdk/src/fdv2/data_system.rs Outdated
_ = shutdown => return,
event = initializer.run().fuse() => {
if let FDv2SourceResult::ChangeSet(change_set) = event.result {
if !matches!(change_set.kind, ChangeSetKind::None) {

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.

Initializers can arrive with a full change set but no basis (e.g. file based initialization). So applying a change set and determining if the init chain is done are two different things.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah, yeah, I guess I was assuming any initializer that finished would set a basis, but that's not a good assumption. Fixed. And test added.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Okay, so I've made it so that init_complete is called as soon as there's a full payload, which is consistent with Java and C++ (and FDv1). I don't think calling init_complete should require a selector, nor does it necessarily have to happen from an initializer. We may not get a full payload until we get to a synchronizer, if the initializers fail. And if we're on FDv1 fallback, we may get usable data without a selector.

And I've fixed it so that we don't exit the initializer loop until we get a selector. I think we're all on the same page about that.

If that doesn't sound right to you, then we should probably have a quick chat about it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

(or at least, that will be the case after i finish pushing)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Okay, pushed.

}
}

struct MockInitializer {

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.

Might be worth implementing the test data source for FDv2 so future tests can rely on that if needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm not convinced that would actually make these tests any simpler, because we're mocking out the initializers and synchronizers to make them behave in non-standard ways to exercise the orchestrator paths.

But I do have the test data source in my road map, and I intend to do it in a later PR.

}

#[tokio::test]
async fn initializer_basis_signals_once_and_propagates_selector() {

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.

Based on my earlier comment, you will also want a test that gets a payload from the first source, doesn't have a basis, and then continues onto the second one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Test added.

@beekld
beekld requested a review from keelerm84 August 20, 2026 01:34
Comment on lines +193 to +196
if !initialized {
init_complete(true);
initialized = true;
}

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

When you say "basis" here, do you mean (a) a full payload, or (b) a full payload with a selector?

As mentioned above, based on my interpretation of the spec, and the C++ and Java implementations, I think we want to mark as initialized as soon as we get a full payload, regardless of selector, because that's when the data store becomes usable. But we don't advance to the synchronizers until we get the selector.

FDv2SourceResult::ChangeSet(change_set) => {
if !matches!(change_set.kind, ChangeSetKind::None) {
selector = change_set.selector.clone();
if change_set.selector.is_some() {

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed that this should be consistent with the other spot.

.await;

// The request after the selectorless change still carries "s1".
let seen = selectors_seen.lock().unwrap();

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.

I don't think this is right.

If we maintain a basis even after a subsequent change, then we can't safely pick back up where we left off because we have a different state than the basis suggests to FD.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, and this is an existing bug in C++ as well. Fixed, and added a test.

@beekld
beekld requested a review from keelerm84 August 24, 2026 18:07
@beekld
beekld force-pushed the bklimt/SDK-2753/fdv2-orchestrator branch from 6165759 to f8ccaf3 Compare August 25, 2026 22:54

/// Produces a fresh initializer each time the orchestrator starts a run.
pub(crate) trait InitializerFactory: Send + Sync {
fn create(&self) -> Box<dyn Initializer>;

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.

Didn't notice this the first round through, but I think the convention within this repository tends to be more "Builder / build" than "Factory / create". Do you think we should align so it's more consistent for the customer? (Not going to block over this of course)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Do you think we should align so it's more consistent for the customer?

This is not a public API. It's an internal factory for creating initializer instances. The public config API comes in a later PR and does use the "Builder / build" terminology to build the config.

event = initializer.run().fuse() => {
match event.result {
FDv2SourceResult::ChangeSet(change_set) => {
let is_full = matches!(change_set.kind, ChangeSetKind::Full);

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.

I think there is still a difference between this and the Java implementation. The java implementation does not require a full change set. If ANY change set is received, it considers that initialized. So a partial change set with a basis would behave differently between the two. I think the Java one will also trigger if it receives an "up-to-date" without any payload data as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm happy to make this work like Java, but I'm a little confused.

  1. Is it even possible to get a partial result from a request that didn't already have a basis?
  2. Is it possible to get a partial result before any full result?

I was assuming that partial results are always diffs from an existing basis, but these cases where Rust and Java diverge are only cases where there's a partial result before any basis.

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