Skip to content

Commit 7bc352f

Browse files
authored
feat: Add the FDv1 adapter synchronizer (#192)
## Summary Gives the FDv1 fallback an implementation: an adapter that runs an existing FDv1 `DataSource` and turns its store writes into FDv2 change sets. A full store initialization becomes a full change set and an individual upsert becomes a partial one. A permanent FDv1 initialization failure surfaces to the orchestrator as a terminal error. The fallback source is built once and re-subscribed on each activation, rather than rebuilt, because the FDv1 data source factory is not `Send + Sync` and cannot be captured in the synchronizer factory. Re-subscribing is safe, since each subscription starts a fresh stream or poll loop. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Adds an **FDv1 → FDv2 fallback synchronizer** so legacy `DataSource` implementations can feed the FDv2 orchestrator without rewriting streaming/polling. > > A new `FDv1AdapterFactory` builds an FDv1 source on each activation and wires it to a **`CapturingStore`** that turns `init` into a **full** change set and `upsert` into **partial** change sets, delivered over an async channel as `FDv2SourceResult`. Failed FDv1 initialization or an unexpected source stop surfaces as **`TerminalError`**; dropping the synchronizer broadcasts shutdown to the wrapped source. > > Unit tests cover change-set translation, init failure, source death, and shutdown propagation. `fdv2/mod.rs` registers the new `fdv1_adapter` module. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit df50475. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 14bffc3 commit 7bc352f

2 files changed

Lines changed: 303 additions & 0 deletions

File tree

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
use std::collections::HashMap;
2+
use std::sync::Arc;
3+
4+
use futures::future::BoxFuture;
5+
use launchdarkly_server_sdk_evaluation::{Flag, Segment, Store};
6+
use parking_lot::RwLock;
7+
use tokio::sync::{broadcast, mpsc};
8+
9+
use crate::data_source::DataSource;
10+
use crate::stores::change_set::{ChangeSet, ItemChange};
11+
use crate::stores::store::{DataStore, UpdateError};
12+
use crate::stores::store_types::{AllData, PatchTarget, StorageItem};
13+
14+
use super::data_system::SynchronizerFactory;
15+
use super::model::{ChangeSetKind, Selector};
16+
use super::source::{ErrorInfo, ErrorKind, FDv2SourceEvent, FDv2SourceResult, Synchronizer};
17+
18+
/// Adapts the FDv1 source's store writes into FDv2 results on a channel.
19+
struct CapturingStore {
20+
sender: mpsc::UnboundedSender<FDv2SourceResult>,
21+
}
22+
23+
// The FDv1 source only writes to the store; these reads are never called.
24+
impl Store for CapturingStore {
25+
fn flag(&self, _flag_key: &str) -> Option<Flag> {
26+
None
27+
}
28+
29+
fn segment(&self, _segment_key: &str) -> Option<Segment> {
30+
None
31+
}
32+
}
33+
34+
impl DataStore for CapturingStore {
35+
fn init(&mut self, new_data: AllData<Flag, Segment>) {
36+
let mut changes = Vec::new();
37+
for (key, flag) in new_data.flags {
38+
changes.push(ItemChange::Flag {
39+
key,
40+
item: StorageItem::Item(flag),
41+
});
42+
}
43+
for (key, segment) in new_data.segments {
44+
changes.push(ItemChange::Segment {
45+
key,
46+
item: StorageItem::Item(segment),
47+
});
48+
}
49+
// A send error means the adapter's receiver was dropped; ignore it.
50+
let _ = self.sender.send(FDv2SourceResult::ChangeSet(ChangeSet {
51+
kind: ChangeSetKind::Full,
52+
changes,
53+
selector: None,
54+
}));
55+
}
56+
57+
// Unused; the FDv1 source never reads back from the store.
58+
fn all_flags(&self) -> HashMap<String, Flag> {
59+
HashMap::new()
60+
}
61+
62+
fn upsert(&mut self, key: &str, data: PatchTarget) -> Result<(), UpdateError> {
63+
let change = match data {
64+
PatchTarget::Flag(item) => ItemChange::Flag {
65+
key: key.to_string(),
66+
item,
67+
},
68+
PatchTarget::Segment(item) => ItemChange::Segment {
69+
key: key.to_string(),
70+
item,
71+
},
72+
PatchTarget::Other(value) => {
73+
return Err(UpdateError::InvalidTarget(
74+
"flag or segment".to_string(),
75+
format!("{value:?}"),
76+
))
77+
}
78+
};
79+
let _ = self.sender.send(FDv2SourceResult::ChangeSet(ChangeSet {
80+
kind: ChangeSetKind::Partial,
81+
changes: vec![change],
82+
selector: None,
83+
}));
84+
Ok(())
85+
}
86+
87+
fn to_store(&self) -> &dyn Store {
88+
self
89+
}
90+
}
91+
92+
/// Wraps an FDv1 `DataSource` as an FDv2 `Synchronizer`.
93+
struct FDv1AdapterSynchronizer {
94+
results: mpsc::UnboundedReceiver<FDv2SourceResult>,
95+
// Dropping this stops the wrapped FDv1 source's task.
96+
_shutdown: broadcast::Sender<()>,
97+
}
98+
99+
impl Synchronizer for FDv1AdapterSynchronizer {
100+
fn next(&mut self, _selector: Selector) -> BoxFuture<'_, FDv2SourceEvent> {
101+
Box::pin(async move {
102+
let result = self.results.recv().await.unwrap_or_else(|| {
103+
FDv2SourceResult::TerminalError(ErrorInfo {
104+
kind: ErrorKind::Unknown,
105+
message: "FDv1 fallback source stopped".to_string(),
106+
})
107+
});
108+
FDv2SourceEvent {
109+
result,
110+
fdv1_fallback: None,
111+
}
112+
})
113+
}
114+
115+
fn name(&self) -> &str {
116+
"fdv1-adapter"
117+
}
118+
}
119+
120+
/// Wraps a freshly built FDv1 source as the FDv2 fallback synchronizer.
121+
pub(crate) struct FDv1AdapterFactory {
122+
source_builder: Box<dyn Fn() -> Arc<dyn DataSource> + Send + Sync>,
123+
}
124+
125+
impl FDv1AdapterFactory {
126+
pub(crate) fn new(source_builder: Box<dyn Fn() -> Arc<dyn DataSource> + Send + Sync>) -> Self {
127+
Self { source_builder }
128+
}
129+
}
130+
131+
impl SynchronizerFactory for FDv1AdapterFactory {
132+
fn create(&self) -> Box<dyn Synchronizer> {
133+
let (sender, results) = mpsc::unbounded_channel();
134+
let store: Arc<RwLock<dyn DataStore>> = Arc::new(RwLock::new(CapturingStore {
135+
sender: sender.clone(),
136+
}));
137+
let (shutdown_tx, shutdown_rx) = broadcast::channel(1);
138+
139+
// A permanent FDv1 failure becomes a terminal error for the orchestrator.
140+
let init_complete: Arc<dyn Fn(bool) + Send + Sync> = Arc::new(move |success| {
141+
if !success {
142+
let _ = sender.send(FDv2SourceResult::TerminalError(ErrorInfo {
143+
kind: ErrorKind::Unknown,
144+
message: "FDv1 fallback source failed to initialize".to_string(),
145+
}));
146+
}
147+
});
148+
149+
(self.source_builder)().subscribe(store, init_complete, shutdown_rx);
150+
151+
Box::new(FDv1AdapterSynchronizer {
152+
results,
153+
_shutdown: shutdown_tx,
154+
})
155+
}
156+
157+
fn is_fdv1_fallback(&self) -> bool {
158+
true
159+
}
160+
}
161+
162+
#[cfg(test)]
163+
mod tests {
164+
use super::*;
165+
use crate::test_common::basic_flag;
166+
use tokio::sync::Notify;
167+
168+
/// An FDv1 source that writes an init then an upsert, then reports success.
169+
struct WritingSource;
170+
171+
impl DataSource for WritingSource {
172+
fn subscribe(
173+
&self,
174+
data_store: Arc<RwLock<dyn DataStore>>,
175+
init_complete: Arc<dyn Fn(bool) + Send + Sync>,
176+
_shutdown: broadcast::Receiver<()>,
177+
) {
178+
let mut store = data_store.write();
179+
let mut flags = HashMap::new();
180+
flags.insert("init-flag".to_string(), basic_flag("init-flag"));
181+
store.init(AllData {
182+
flags,
183+
segments: HashMap::new(),
184+
});
185+
store
186+
.upsert(
187+
"upsert-flag",
188+
PatchTarget::Flag(StorageItem::Item(basic_flag("upsert-flag"))),
189+
)
190+
.unwrap();
191+
drop(store);
192+
init_complete(true);
193+
}
194+
}
195+
196+
#[tokio::test]
197+
async fn translates_init_and_upsert_to_change_sets() {
198+
let factory =
199+
FDv1AdapterFactory::new(Box::new(|| Arc::new(WritingSource) as Arc<dyn DataSource>));
200+
let mut synchronizer = factory.create();
201+
202+
// The init becomes a full change set.
203+
match synchronizer.next(None).await.result {
204+
FDv2SourceResult::ChangeSet(cs) => assert_eq!(cs.kind, ChangeSetKind::Full),
205+
other => panic!("expected a full change set, got {other:?}"),
206+
}
207+
// The upsert becomes a partial change set.
208+
match synchronizer.next(None).await.result {
209+
FDv2SourceResult::ChangeSet(cs) => assert_eq!(cs.kind, ChangeSetKind::Partial),
210+
other => panic!("expected a partial change set, got {other:?}"),
211+
}
212+
}
213+
214+
/// An FDv1 source that reports a permanent initialization failure.
215+
struct FailingSource;
216+
217+
impl DataSource for FailingSource {
218+
fn subscribe(
219+
&self,
220+
_data_store: Arc<RwLock<dyn DataStore>>,
221+
init_complete: Arc<dyn Fn(bool) + Send + Sync>,
222+
_shutdown: broadcast::Receiver<()>,
223+
) {
224+
init_complete(false);
225+
}
226+
}
227+
228+
#[tokio::test]
229+
async fn init_failure_becomes_a_terminal_error() {
230+
let factory =
231+
FDv1AdapterFactory::new(Box::new(|| Arc::new(FailingSource) as Arc<dyn DataSource>));
232+
let mut synchronizer = factory.create();
233+
assert!(matches!(
234+
synchronizer.next(None).await.result,
235+
FDv2SourceResult::TerminalError(_)
236+
));
237+
}
238+
239+
/// An FDv1 source whose task ends at once, dropping the store and init_complete.
240+
struct DyingSource;
241+
242+
impl DataSource for DyingSource {
243+
fn subscribe(
244+
&self,
245+
_data_store: Arc<RwLock<dyn DataStore>>,
246+
_init_complete: Arc<dyn Fn(bool) + Send + Sync>,
247+
_shutdown: broadcast::Receiver<()>,
248+
) {
249+
// Returning drops the store and init_complete, which closes the channel.
250+
}
251+
}
252+
253+
#[tokio::test]
254+
async fn source_death_becomes_a_terminal_error() {
255+
let factory =
256+
FDv1AdapterFactory::new(Box::new(|| Arc::new(DyingSource) as Arc<dyn DataSource>));
257+
let mut synchronizer = factory.create();
258+
259+
// A dead source closes the channel, which is a terminal error, not a shutdown.
260+
assert!(matches!(
261+
synchronizer.next(None).await.result,
262+
FDv2SourceResult::TerminalError(_)
263+
));
264+
}
265+
266+
/// An FDv1 source whose task notifies when it observes shutdown.
267+
struct ShutdownObservingSource {
268+
observed: Arc<Notify>,
269+
}
270+
271+
impl DataSource for ShutdownObservingSource {
272+
fn subscribe(
273+
&self,
274+
_data_store: Arc<RwLock<dyn DataStore>>,
275+
_init_complete: Arc<dyn Fn(bool) + Send + Sync>,
276+
mut shutdown: broadcast::Receiver<()>,
277+
) {
278+
let observed = self.observed.clone();
279+
tokio::spawn(async move {
280+
let _ = shutdown.recv().await;
281+
observed.notify_one();
282+
});
283+
}
284+
}
285+
286+
#[tokio::test]
287+
async fn dropping_the_adapter_shuts_down_the_source() {
288+
let observed = Arc::new(Notify::new());
289+
let builder_observed = observed.clone();
290+
let factory = FDv1AdapterFactory::new(Box::new(move || {
291+
Arc::new(ShutdownObservingSource {
292+
observed: builder_observed.clone(),
293+
}) as Arc<dyn DataSource>
294+
}));
295+
296+
let synchronizer = factory.create();
297+
drop(synchronizer);
298+
299+
// The source's task saw the shutdown signal.
300+
observed.notified().await;
301+
}
302+
}

launchdarkly-server-sdk/src/fdv2/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod data_system;
2+
mod fdv1_adapter;
23
pub mod model;
34
mod polling;
45
mod protocol;

0 commit comments

Comments
 (0)