feat: Add the FDv2 data system orchestrator - #190
Conversation
4ea2eb8 to
775e611
Compare
775e611 to
2fa17de
Compare
2fa17de to
a523fa8
Compare
a523fa8 to
3e77e84
Compare
3e77e84 to
f3e5a99
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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.
f3e5a99 to
58c2a46
Compare
keelerm84
left a comment
There was a problem hiding this comment.
A few questions and suggestions on the orchestrator, mostly around the initializer chain and test coverage.
| _ = shutdown => return, | ||
| event = initializer.run().fuse() => { | ||
| if let FDv2SourceResult::ChangeSet(change_set) = event.result { | ||
| if !matches!(change_set.kind, ChangeSetKind::None) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
(or at least, that will be the case after i finish pushing)
| } | ||
| } | ||
|
|
||
| struct MockInitializer { |
There was a problem hiding this comment.
Might be worth implementing the test data source for FDv2 so future tests can rely on that if needed.
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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.
| if !initialized { | ||
| init_complete(true); | ||
| initialized = true; | ||
| } |
There was a problem hiding this comment.
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
You're right, and this is an existing bug in C++ as well. Fixed, and added a test.
…they are consumed
6165759 to
f8ccaf3
Compare
|
|
||
| /// Produces a fresh initializer each time the orchestrator starts a run. | ||
| pub(crate) trait InitializerFactory: Send + Sync { | ||
| fn create(&self) -> Box<dyn Initializer>; |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I'm happy to make this work like Java, but I'm a little confused.
- Is it even possible to get a partial result from a request that didn't already have a basis?
- 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.

Summary
Adds the FDv2 orchestrator, the
DataSystemimplementation that owns thein-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:
interruption.
interval.
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 FDv2DataSystemimplementation that owns an in-memory store and keeps it updated via a two-phase asyncrunloop spawned fromstart.Initializer phase walks factory-built initializers until one returns a basis (full changeset with a selector), applying each non-
Nonechangeset and signalinginit_complete(true)once any full payload lands (after the initializer chain when needed). Synchronizer phase drives one synchronizer at a time throughSourceManager: cyclic rotation, blocking on terminal errors, fallback after sustainedInterrupted, and recovery back to the prime when a backup source was active. Selectors are passed intonextand updated per changeset rules (Nonekind preserves selector; selectorless partial clears it).Wires the module in
fdv2/mod.rsand dropsdead_codeallowances onChangeSet::selectorandTransactionalDataStore::applynow 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.