Skip to main content

faucet_conformance/
doubles.rs

1//! Synthetic `Source` / `Sink` doubles the battery drives (and that connector
2//! authors can reuse in their own tests).
3//!
4//! The doubles come in **conformant** and deliberately **non-conformant**
5//! flavours. The non-conformant ones (`FailingSource`, `PanickingSource`,
6//! `LyingIdempotentSink`, `LyingKeyedSink`, `NoOpEvolvingSink`,
7//! `MultiPageZeroSource`, `EmptyNameSource`, `ErringCheckSource`,
8//! `ErringCheckSink`) exist so the battery's own unit tests can prove each
9//! check actually *fails* when the contract is violated — a check that can
10//! never fail is worthless. The conformant ones (`CountingSource`, `TestSink`,
11//! `EvolvingSink`) demonstrate the contract genuinely.
12
13use std::collections::HashMap;
14use std::pin::Pin;
15use std::sync::{Arc, Mutex};
16
17use faucet_core::check::{CheckContext, CheckReport};
18use faucet_core::drift::SchemaEvolution;
19use faucet_core::write_mode::{DeleteMarker, WriteMode};
20use faucet_core::{FaucetError, Sink, Source, StreamPage, Value, async_trait};
21use futures_core::Stream;
22use serde_json::json;
23
24/// Field the conformance battery uses to flag a record as a delete when
25/// exercising an upsert sink's delete path (matches the `cdc_unwrap`
26/// convention). A sink under test must be configured with a
27/// `delete_marker { field: "__op", values: ["d"] }` for the delete branch of
28/// [`assert_write_modes_truthful`](crate::assert_write_modes_truthful) to run.
29pub const DELETE_MARKER_FIELD: &str = "__op";
30/// Value of [`DELETE_MARKER_FIELD`] that means "this record is a delete".
31pub const DELETE_MARKER_VALUE: &str = "d";
32
33/// A source that lazily emits `total` synthetic records (`{"n": i}`) in pages of
34/// its configured `batch` (or the `stream_pages` hint), **without** buffering
35/// the whole set — so it exercises the bounded-memory contract genuinely.
36///
37/// It also honours incremental resume: after `stream_pages` runs to completion
38/// it emits a `{"n": total}` bookmark; feeding that back via
39/// [`apply_start_bookmark`](Source::apply_start_bookmark) makes the next run
40/// start at that offset (so a fully-consumed source resumes to zero records).
41/// Construct with [`CountingSource::non_resumable`] to model a source that
42/// *ignores* the bookmark — used to prove the bookmark-roundtrip check fails.
43pub struct CountingSource {
44    total: usize,
45    batch: usize,
46    resumable: bool,
47    start: Arc<Mutex<usize>>,
48}
49
50impl CountingSource {
51    /// `total` records, chunked into pages of `batch` (0 = one page). Resumable.
52    pub fn new(total: usize, batch: usize) -> Self {
53        Self {
54            total,
55            batch,
56            resumable: true,
57            start: Arc::new(Mutex::new(0)),
58        }
59    }
60
61    /// Like [`new`](Self::new) but ignores any applied bookmark — a source that
62    /// silently restarts from the beginning on resume (contract violation).
63    pub fn non_resumable(total: usize, batch: usize) -> Self {
64        Self {
65            total,
66            batch,
67            resumable: false,
68            start: Arc::new(Mutex::new(0)),
69        }
70    }
71}
72
73#[async_trait]
74impl Source for CountingSource {
75    async fn fetch_with_context(
76        &self,
77        _context: &HashMap<String, Value>,
78    ) -> Result<Vec<Value>, FaucetError> {
79        let start = *self.start.lock().unwrap();
80        Ok((start..self.total).map(|i| json!({ "n": i })).collect())
81    }
82
83    fn stream_pages<'a>(
84        &'a self,
85        _context: &'a HashMap<String, Value>,
86        _batch_size: usize,
87    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
88        // Like real sources, the double treats its own configured `batch` as
89        // authoritative and ignores the pipeline hint. `batch == 0` is the
90        // "no batching" sentinel — emit the whole set as one page (useful for
91        // exercising the bounded-memory check's failure path).
92        let batch = if self.batch == 0 {
93            self.total.max(1)
94        } else {
95            self.batch
96        };
97        let total = self.total;
98        let start = (*self.start.lock().unwrap()).min(total);
99        Box::pin(async_stream::try_stream! {
100            let mut n = start;
101            if n >= total {
102                // Fully consumed on resume: still emit one empty page carrying
103                // the terminal bookmark so the pipeline advances its checkpoint.
104                yield StreamPage { records: Vec::new(), bookmark: Some(json!({ "n": total })) };
105                return;
106            }
107            while n < total {
108                let end = (n + batch).min(total);
109                let records: Vec<Value> = (n..end).map(|i| json!({ "n": i })).collect();
110                n = end;
111                let bookmark = if n >= total { Some(json!({ "n": total })) } else { None };
112                yield StreamPage { records, bookmark };
113            }
114        })
115    }
116
117    fn connector_name(&self) -> &'static str {
118        "counting-source"
119    }
120
121    fn state_key(&self) -> Option<String> {
122        Some("conformance:counting".to_string())
123    }
124
125    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
126        if !self.resumable {
127            return Ok(());
128        }
129        if let Some(n) = bookmark.get("n").and_then(|v| v.as_u64()) {
130            *self.start.lock().unwrap() = n as usize;
131        }
132        Ok(())
133    }
134}
135
136/// A source whose read path always returns a typed [`FaucetError`] — models an
137/// unreachable endpoint / bad credentials. Used to prove the
138/// `errors-not-panics` check passes on a well-behaved failure.
139pub struct FailingSource;
140
141#[async_trait]
142impl Source for FailingSource {
143    async fn fetch_with_context(
144        &self,
145        _context: &HashMap<String, Value>,
146    ) -> Result<Vec<Value>, FaucetError> {
147        Err(FaucetError::Source(
148            "unreachable endpoint (test double)".to_string(),
149        ))
150    }
151
152    fn connector_name(&self) -> &'static str {
153        "failing-source"
154    }
155}
156
157/// A source whose read path **panics** — models a buggy connector that unwraps
158/// on unexpected input. Used to prove the `errors-not-panics` check *fails*
159/// (catches the unwind) rather than letting the panic escape silently.
160pub struct PanickingSource;
161
162#[async_trait]
163impl Source for PanickingSource {
164    async fn fetch_with_context(
165        &self,
166        _context: &HashMap<String, Value>,
167    ) -> Result<Vec<Value>, FaucetError> {
168        panic!("connector bug: unwrap() on a None value");
169    }
170
171    fn connector_name(&self) -> &'static str {
172        "panicking-source"
173    }
174}
175
176/// A sink that records everything written, optionally deduplicating by a key
177/// field (upsert), optionally advertising the atomic-watermark idempotent path.
178///
179/// Modes:
180/// - [`TestSink::new`] — append-only, non-idempotent.
181/// - [`TestSink::keyed`] — dedups by key on `write_batch` (keyed-upsert /
182///   `dedups_by_key`), advertises `Upsert`/`Delete`.
183/// - [`TestSink::keyed_upsert`] — like `keyed`, but also honours a delete
184///   marker (`{"__op": "d"}`) so a delete-marked record genuinely *removes* the
185///   keyed row — used to exercise the delete path of
186///   [`assert_write_modes_truthful`](crate::assert_write_modes_truthful).
187/// - [`TestSink::idempotent`] — additionally advertises
188///   `supports_idempotent_writes` and stores a per-scope commit token, so the
189///   atomic-watermark path can be exercised.
190#[derive(Clone, Default)]
191pub struct TestSink {
192    key_field: Option<String>,
193    idempotent: bool,
194    delete_marker: Option<DeleteMarker>,
195    keyed: Arc<Mutex<HashMap<String, Value>>>,
196    appended: Arc<Mutex<Vec<Value>>>,
197    tokens: Arc<Mutex<HashMap<String, String>>>,
198    write_calls: Arc<Mutex<usize>>,
199}
200
201impl TestSink {
202    /// An append-only recording sink.
203    pub fn new() -> Self {
204        Self::default()
205    }
206
207    /// An upsert sink that dedups by `key_field` in `write_batch`.
208    pub fn keyed(key_field: impl Into<String>) -> Self {
209        Self {
210            key_field: Some(key_field.into()),
211            ..Self::default()
212        }
213    }
214
215    /// A keyed upsert sink that additionally honours the standard delete marker
216    /// (a record carrying [`DELETE_MARKER_FIELD`] = [`DELETE_MARKER_VALUE`]
217    /// removes its keyed row), so both the upsert and delete paths can be
218    /// exercised through `write_batch`.
219    pub fn keyed_upsert(key_field: impl Into<String>) -> Self {
220        Self {
221            key_field: Some(key_field.into()),
222            delete_marker: Some(DeleteMarker {
223                field: DELETE_MARKER_FIELD.to_string(),
224                values: vec![DELETE_MARKER_VALUE.to_string()],
225            }),
226            ..Self::default()
227        }
228    }
229
230    /// An upsert sink that also commits an atomic watermark token per scope,
231    /// so it advertises (and honours) `supports_idempotent_writes`.
232    pub fn idempotent(key_field: impl Into<String>) -> Self {
233        Self {
234            key_field: Some(key_field.into()),
235            idempotent: true,
236            ..Self::default()
237        }
238    }
239
240    /// Number of distinct rows currently stored (keyed) or appended.
241    pub fn len(&self) -> usize {
242        if self.key_field.is_some() {
243            self.keyed.lock().unwrap().len()
244        } else {
245            self.appended.lock().unwrap().len()
246        }
247    }
248
249    /// Whether the sink holds no rows.
250    pub fn is_empty(&self) -> bool {
251        self.len() == 0
252    }
253
254    /// Total number of records passed to `write_batch` across all calls
255    /// (counts re-delivered duplicates).
256    pub fn total_written(&self) -> usize {
257        *self.write_calls.lock().unwrap()
258    }
259
260    /// Whether `record` is flagged as a delete by this sink's configured marker.
261    fn is_delete_marked(&self, record: &Value) -> bool {
262        match &self.delete_marker {
263            Some(dm) => record
264                .get(&dm.field)
265                .and_then(|v| v.as_str())
266                .is_some_and(|s| dm.values.iter().any(|m| m == s)),
267            None => false,
268        }
269    }
270}
271
272#[async_trait]
273impl Sink for TestSink {
274    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
275        *self.write_calls.lock().unwrap() += records.len();
276        match &self.key_field {
277            Some(field) => {
278                let mut map = self.keyed.lock().unwrap();
279                for r in records {
280                    let key = r.get(field).map(|v| v.to_string()).ok_or_else(|| {
281                        FaucetError::Sink(format!("record missing key `{field}`"))
282                    })?;
283                    // A delete-marked record removes its keyed row (upsert
284                    // sinks with a `delete_marker`); otherwise insert/overwrite.
285                    if self.is_delete_marked(r) {
286                        map.remove(&key);
287                    } else {
288                        map.insert(key, r.clone());
289                    }
290                }
291            }
292            None => self
293                .appended
294                .lock()
295                .unwrap()
296                .extend(records.iter().cloned()),
297        }
298        Ok(records.len())
299    }
300
301    fn supports_idempotent_writes(&self) -> bool {
302        self.idempotent
303    }
304
305    fn dedups_by_key(&self) -> bool {
306        self.key_field.is_some()
307    }
308
309    fn supported_write_modes(&self) -> &'static [WriteMode] {
310        if self.key_field.is_some() {
311            &[WriteMode::Append, WriteMode::Upsert, WriteMode::Delete]
312        } else {
313            &[WriteMode::Append]
314        }
315    }
316
317    async fn write_batch_idempotent(
318        &self,
319        records: &[Value],
320        scope: &str,
321        token: &str,
322    ) -> Result<usize, FaucetError> {
323        // Store the token opaquely (last-write-wins). Monotonicity is enforced
324        // by the pipeline via `last_committed_token`, not by the sink — the
325        // double models a real atomic-watermark commit faithfully.
326        self.tokens
327            .lock()
328            .unwrap()
329            .insert(scope.to_string(), token.to_string());
330        self.write_batch(records).await
331    }
332
333    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
334        Ok(self.tokens.lock().unwrap().get(scope).cloned())
335    }
336
337    fn connector_name(&self) -> &'static str {
338        "test-sink"
339    }
340}
341
342/// A sink that **claims** `supports_idempotent_writes` but does not actually
343/// store a commit token (it just appends). Used to prove the idempotent-replay
344/// and capabilities checks *fail* against a lying sink.
345#[derive(Clone, Default)]
346pub struct LyingIdempotentSink {
347    appended: Arc<Mutex<Vec<Value>>>,
348}
349
350impl LyingIdempotentSink {
351    /// A fresh lying sink.
352    pub fn new() -> Self {
353        Self::default()
354    }
355    /// Rows appended so far.
356    pub fn len(&self) -> usize {
357        self.appended.lock().unwrap().len()
358    }
359    /// Whether the sink holds no rows.
360    pub fn is_empty(&self) -> bool {
361        self.len() == 0
362    }
363}
364
365#[async_trait]
366impl Sink for LyingIdempotentSink {
367    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
368        self.appended
369            .lock()
370            .unwrap()
371            .extend(records.iter().cloned());
372        Ok(records.len())
373    }
374
375    fn supports_idempotent_writes(&self) -> bool {
376        true // the lie — it never persists a token (default methods apply).
377    }
378
379    fn connector_name(&self) -> &'static str {
380        "lying-idempotent-sink"
381    }
382}
383
384/// A sink that **claims** to dedup by key (`dedups_by_key` + `Upsert` in
385/// `supported_write_modes`) but actually appends duplicates. Used to prove the
386/// keyed-convergence branch of the idempotent-replay check *fails*.
387#[derive(Clone, Default)]
388pub struct LyingKeyedSink {
389    appended: Arc<Mutex<Vec<Value>>>,
390}
391
392impl LyingKeyedSink {
393    /// A fresh lying keyed sink.
394    pub fn new() -> Self {
395        Self::default()
396    }
397    /// Rows appended so far.
398    pub fn len(&self) -> usize {
399        self.appended.lock().unwrap().len()
400    }
401    /// Whether the sink holds no rows.
402    pub fn is_empty(&self) -> bool {
403        self.len() == 0
404    }
405}
406
407#[async_trait]
408impl Sink for LyingKeyedSink {
409    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
410        self.appended
411            .lock()
412            .unwrap()
413            .extend(records.iter().cloned());
414        Ok(records.len())
415    }
416
417    fn dedups_by_key(&self) -> bool {
418        true // the lie — it never dedups.
419    }
420
421    fn supported_write_modes(&self) -> &'static [WriteMode] {
422        &[WriteMode::Append, WriteMode::Upsert]
423    }
424
425    fn connector_name(&self) -> &'static str {
426        "lying-keyed-sink"
427    }
428}
429
430/// A schemaless-to-typed sink that maintains a live destination schema and
431/// **genuinely evolves** it: `evolve_schema` adds/overwrites the columns of a
432/// [`SchemaEvolution`] into the stored schema, so a fresh `current_schema()`
433/// reflects the change. Used to prove
434/// [`assert_schema_evolution_effective`](crate::assert_schema_evolution_effective)
435/// passes for a sink that actually applies the DDL.
436#[derive(Clone)]
437pub struct EvolvingSink {
438    /// `column name -> JSON-Schema type fragment`.
439    columns: Arc<Mutex<HashMap<String, Value>>>,
440}
441
442impl Default for EvolvingSink {
443    fn default() -> Self {
444        let mut cols = HashMap::new();
445        cols.insert("id".to_string(), json!({ "type": "integer" }));
446        Self {
447            columns: Arc::new(Mutex::new(cols)),
448        }
449    }
450}
451
452impl EvolvingSink {
453    /// A fresh evolving sink seeded with a single `id: integer` column.
454    pub fn new() -> Self {
455        Self::default()
456    }
457
458    /// Number of columns currently in the destination schema.
459    pub fn column_count(&self) -> usize {
460        self.columns.lock().unwrap().len()
461    }
462}
463
464/// Build an `infer_schema`-shaped object schema from a column map.
465fn schema_from_columns(cols: &HashMap<String, Value>) -> Value {
466    let props: serde_json::Map<String, Value> =
467        cols.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
468    json!({ "type": "object", "properties": props })
469}
470
471#[async_trait]
472impl Sink for EvolvingSink {
473    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
474        Ok(records.len())
475    }
476
477    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
478        Ok(Some(schema_from_columns(&self.columns.lock().unwrap())))
479    }
480
481    fn supports_schema_evolution(&self) -> bool {
482        true
483    }
484
485    async fn evolve_schema(&self, evolution: &SchemaEvolution) -> Result<(), FaucetError> {
486        let mut cols = self.columns.lock().unwrap();
487        for change in evolution.additions.iter().chain(&evolution.widenings) {
488            cols.insert(change.name.clone(), change.to.clone());
489        }
490        Ok(())
491    }
492
493    fn connector_name(&self) -> &'static str {
494        "evolving-sink"
495    }
496}
497
498/// A sink that **claims** `supports_schema_evolution` and exposes a fixed
499/// destination schema, but whose `evolve_schema` is a silent no-op — a fresh
500/// `current_schema()` never changes. Used to prove
501/// [`assert_schema_evolution_effective`](crate::assert_schema_evolution_effective)
502/// *fails* against a sink that only pretends to evolve.
503#[derive(Clone, Default)]
504pub struct NoOpEvolvingSink;
505
506#[async_trait]
507impl Sink for NoOpEvolvingSink {
508    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
509        Ok(records.len())
510    }
511
512    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
513        Ok(Some(json!({
514            "type": "object",
515            "properties": { "id": { "type": "integer" } }
516        })))
517    }
518
519    fn supports_schema_evolution(&self) -> bool {
520        true // the lie — evolve_schema does nothing.
521    }
522
523    async fn evolve_schema(&self, _evolution: &SchemaEvolution) -> Result<(), FaucetError> {
524        Ok(()) // accepted, but the schema never actually changes.
525    }
526
527    fn connector_name(&self) -> &'static str {
528        "noop-evolving-sink"
529    }
530}
531
532/// A source that emits `total` records in multiple non-empty pages **even when
533/// asked to page with `batch_size = 0`** — violating the "no batching = single
534/// page" contract. Used to prove
535/// [`assert_batch_size_zero_single_page`](crate::assert_batch_size_zero_single_page)
536/// *fails*.
537pub struct MultiPageZeroSource {
538    total: usize,
539    page: usize,
540}
541
542impl MultiPageZeroSource {
543    /// `total` records emitted in fixed pages of `page` (defaults to 2),
544    /// regardless of the `batch_size` hint.
545    pub fn new(total: usize) -> Self {
546        Self { total, page: 2 }
547    }
548}
549
550#[async_trait]
551impl Source for MultiPageZeroSource {
552    async fn fetch_with_context(
553        &self,
554        _context: &HashMap<String, Value>,
555    ) -> Result<Vec<Value>, FaucetError> {
556        Ok((0..self.total).map(|i| json!({ "n": i })).collect())
557    }
558
559    fn stream_pages<'a>(
560        &'a self,
561        _context: &'a HashMap<String, Value>,
562        _batch_size: usize,
563    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
564        // Deliberately ignores the batch_size=0 "single page" sentinel.
565        let total = self.total;
566        let page = self.page.max(1);
567        Box::pin(async_stream::try_stream! {
568            let mut n = 0;
569            while n < total {
570                let end = (n + page).min(total);
571                let records: Vec<Value> = (n..end).map(|i| json!({ "n": i })).collect();
572                n = end;
573                let bookmark = if n >= total { Some(json!({ "n": total })) } else { None };
574                yield StreamPage { records, bookmark };
575            }
576        })
577    }
578
579    fn connector_name(&self) -> &'static str {
580        "multi-page-zero-source"
581    }
582}
583
584/// A source whose `connector_name()` is the empty string — a cardinality-rule
585/// violation (it would surface as the `"unknown"` metric label). Used to prove
586/// [`assert_connector_name_nonempty`](crate::assert_connector_name_nonempty)
587/// *fails*.
588pub struct EmptyNameSource;
589
590#[async_trait]
591impl Source for EmptyNameSource {
592    async fn fetch_with_context(
593        &self,
594        _context: &HashMap<String, Value>,
595    ) -> Result<Vec<Value>, FaucetError> {
596        Ok(Vec::new())
597    }
598
599    fn connector_name(&self) -> &'static str {
600        "" // the violation.
601    }
602}
603
604/// A source whose `check()` returns `Err` instead of surfacing the probe
605/// failure as a [`ProbeStatus::Fail`](faucet_core::check::ProbeStatus) inside
606/// `Ok(report)`. Used to prove
607/// [`assert_preflight_check_wellformed`](crate::assert_preflight_check_wellformed)
608/// *fails*.
609pub struct ErringCheckSource;
610
611#[async_trait]
612impl Source for ErringCheckSource {
613    async fn fetch_with_context(
614        &self,
615        _context: &HashMap<String, Value>,
616    ) -> Result<Vec<Value>, FaucetError> {
617        Ok(Vec::new())
618    }
619
620    async fn check(&self, _ctx: &CheckContext) -> Result<CheckReport, FaucetError> {
621        Err(FaucetError::Source(
622            "probe failed — but returned as Err instead of a Fail probe".to_string(),
623        ))
624    }
625
626    fn connector_name(&self) -> &'static str {
627        "erring-check-source"
628    }
629}
630
631/// A sink whose `check()` returns `Err` instead of a `Fail` probe. Used to prove
632/// [`assert_sink_preflight_check_wellformed`](crate::assert_sink_preflight_check_wellformed)
633/// *fails*.
634#[derive(Clone, Default)]
635pub struct ErringCheckSink;
636
637#[async_trait]
638impl Sink for ErringCheckSink {
639    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
640        Ok(records.len())
641    }
642
643    async fn check(&self, _ctx: &CheckContext) -> Result<CheckReport, FaucetError> {
644        Err(FaucetError::Sink(
645            "probe failed — but returned as Err instead of a Fail probe".to_string(),
646        ))
647    }
648
649    fn connector_name(&self) -> &'static str {
650        "erring-check-sink"
651    }
652}
653
654/// A source with a catalog it can [`discover`](Source::discover) — used to
655/// exercise [`assert_discover_roundtrips`](crate::assert_discover_roundtrips).
656///
657/// Each discovered dataset becomes a [`DatasetDescriptor`](faucet_core::DatasetDescriptor)
658/// whose `config_patch` is `{"dataset": <name>}` — the partial override a
659/// `rebuild` closure deep-merges to select that dataset. The read path is
660/// irrelevant to the check (the `rebuild` closure returns the source that is
661/// actually read), so [`fetch_with_context`](Source::fetch_with_context)
662/// returns nothing.
663///
664/// Construct with [`DiscoverableSource::new`] for a populated catalog, or
665/// [`DiscoverableSource::empty`] to model a source that advertises discovery
666/// but finds no datasets (used to prove the check *fails* on an empty catalog).
667pub struct DiscoverableSource {
668    datasets: Vec<String>,
669}
670
671impl DiscoverableSource {
672    /// A discoverable source over two synthetic datasets (`orders`, `customers`).
673    pub fn new() -> Self {
674        Self {
675            datasets: vec!["orders".to_string(), "customers".to_string()],
676        }
677    }
678
679    /// A discoverable source whose catalog is empty — `discover()` returns no
680    /// descriptors even though `supports_discover()` is `true`.
681    pub fn empty() -> Self {
682        Self {
683            datasets: Vec::new(),
684        }
685    }
686}
687
688impl Default for DiscoverableSource {
689    fn default() -> Self {
690        Self::new()
691    }
692}
693
694#[async_trait]
695impl Source for DiscoverableSource {
696    async fn fetch_with_context(
697        &self,
698        _context: &HashMap<String, Value>,
699    ) -> Result<Vec<Value>, FaucetError> {
700        Ok(Vec::new())
701    }
702
703    fn supports_discover(&self) -> bool {
704        true
705    }
706
707    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
708        Ok(self
709            .datasets
710            .iter()
711            .map(|name| {
712                faucet_core::DatasetDescriptor::new(
713                    name.clone(),
714                    "table",
715                    json!({ "dataset": name }),
716                )
717            })
718            .collect())
719    }
720
721    fn connector_name(&self) -> &'static str {
722        "discoverable-source"
723    }
724}
725
726/// A sink that buffers writes and only makes them **durable on
727/// [`flush`](Sink::flush)** — modelling a real buffered sink whose output is
728/// committed at flush time (a Parquet footer, an S3 multipart completion).
729/// Used to exercise [`assert_cancellation_flushes`](crate::assert_cancellation_flushes):
730/// the pipeline must flush at the cancellation page boundary or the staged rows
731/// are lost.
732///
733/// Construct with [`BufferedSink::new`] for a faithful sink (flush commits the
734/// staging buffer) or [`BufferedSink::broken`] for one whose `flush` silently
735/// drops the buffer (used to prove the check *fails* when a cancel does not
736/// yield durable output).
737#[derive(Clone)]
738pub struct BufferedSink {
739    staged: Arc<Mutex<Vec<Value>>>,
740    durable: Arc<Mutex<Vec<Value>>>,
741    commit_on_flush: bool,
742}
743
744impl BufferedSink {
745    /// A buffered sink whose `flush` durably commits everything staged so far.
746    pub fn new() -> Self {
747        Self {
748            staged: Arc::new(Mutex::new(Vec::new())),
749            durable: Arc::new(Mutex::new(Vec::new())),
750            commit_on_flush: true,
751        }
752    }
753
754    /// A broken buffered sink whose `flush` is a silent no-op — staged rows
755    /// never become durable, so any output buffered when the run ends is lost.
756    pub fn broken() -> Self {
757        Self {
758            commit_on_flush: false,
759            ..Self::new()
760        }
761    }
762
763    /// Number of rows that are **durable** (committed via a flush).
764    pub fn durable_len(&self) -> usize {
765        self.durable.lock().unwrap().len()
766    }
767
768    /// Number of rows currently staged but not yet flushed.
769    pub fn staged_len(&self) -> usize {
770        self.staged.lock().unwrap().len()
771    }
772}
773
774impl Default for BufferedSink {
775    fn default() -> Self {
776        Self::new()
777    }
778}
779
780#[async_trait]
781impl Sink for BufferedSink {
782    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
783        self.staged.lock().unwrap().extend(records.iter().cloned());
784        Ok(records.len())
785    }
786
787    async fn flush(&self) -> Result<(), FaucetError> {
788        if self.commit_on_flush {
789            let mut staged = self.staged.lock().unwrap();
790            self.durable.lock().unwrap().extend(staged.drain(..));
791        }
792        Ok(())
793    }
794
795    fn connector_name(&self) -> &'static str {
796        "buffered-sink"
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use faucet_core::drift::ColumnChange;
804    use futures::StreamExt;
805    use serde_json::json;
806    use std::collections::HashMap;
807
808    #[tokio::test]
809    async fn counting_source_resumes_and_ignores_when_non_resumable() {
810        let s = CountingSource::new(5, 2);
811        assert_eq!(s.state_key().as_deref(), Some("conformance:counting"));
812        assert_eq!(s.connector_name(), "counting-source");
813        assert_eq!(
814            s.fetch_with_context(&HashMap::new()).await.unwrap().len(),
815            5
816        );
817        // Resume from the terminal bookmark → no records left.
818        s.apply_start_bookmark(json!({ "n": 5 })).await.unwrap();
819        assert!(
820            s.fetch_with_context(&HashMap::new())
821                .await
822                .unwrap()
823                .is_empty()
824        );
825
826        // A non-resumable source ignores the applied bookmark.
827        let nr = CountingSource::non_resumable(5, 2);
828        nr.apply_start_bookmark(json!({ "n": 5 })).await.unwrap();
829        assert_eq!(
830            nr.fetch_with_context(&HashMap::new()).await.unwrap().len(),
831            5
832        );
833    }
834
835    #[tokio::test]
836    async fn test_sink_accessors() {
837        let s = TestSink::new();
838        assert!(s.is_empty());
839        s.write_batch(&[json!({ "id": 1 })]).await.unwrap();
840        assert!(!s.is_empty());
841        assert_eq!(s.len(), 1);
842        assert_eq!(s.total_written(), 1);
843        assert_eq!(s.connector_name(), "test-sink");
844    }
845
846    #[tokio::test]
847    async fn lying_idempotent_sink_never_persists_a_token() {
848        let s = LyingIdempotentSink::new();
849        assert!(s.is_empty());
850        assert!(s.supports_idempotent_writes());
851        assert_eq!(s.connector_name(), "lying-idempotent-sink");
852        s.write_batch_idempotent(&[json!({ "id": 1 })], "scope", "00000000000000000001")
853            .await
854            .unwrap();
855        assert_eq!(s.len(), 1);
856        assert!(s.last_committed_token("scope").await.unwrap().is_none());
857    }
858
859    #[tokio::test]
860    async fn lying_keyed_sink_appends_duplicates() {
861        let s = LyingKeyedSink::new();
862        assert!(s.is_empty());
863        assert!(s.dedups_by_key());
864        assert!(s.supported_write_modes().contains(&WriteMode::Upsert));
865        assert_eq!(s.connector_name(), "lying-keyed-sink");
866        s.write_batch(&[json!({ "id": 1 })]).await.unwrap();
867        s.write_batch(&[json!({ "id": 1 })]).await.unwrap();
868        assert_eq!(s.len(), 2, "lying keyed sink does not dedup");
869    }
870
871    #[tokio::test]
872    async fn failing_and_panicking_source_labels() {
873        assert_eq!(FailingSource.connector_name(), "failing-source");
874        assert_eq!(PanickingSource.connector_name(), "panicking-source");
875        assert!(FailingSource.fetch_all().await.is_err());
876    }
877
878    #[tokio::test]
879    async fn test_sink_delete_marker_removes_row() {
880        let s = TestSink::keyed_upsert("id");
881        assert!(s.supported_write_modes().contains(&WriteMode::Delete));
882        s.write_batch(&[json!({ "id": 1, "v": "a" })])
883            .await
884            .unwrap();
885        assert_eq!(s.len(), 1);
886        // A delete-marked record removes the keyed row.
887        s.write_batch(&[json!({ "id": 1, "__op": "d" })])
888            .await
889            .unwrap();
890        assert_eq!(s.len(), 0, "delete marker must remove the row");
891        // A keyed sink without a marker never treats a record as a delete.
892        let plain = TestSink::keyed("id");
893        plain
894            .write_batch(&[json!({ "id": 2, "__op": "d" })])
895            .await
896            .unwrap();
897        assert_eq!(plain.len(), 1, "no marker configured → the row is upserted");
898    }
899
900    #[tokio::test]
901    async fn evolving_sink_evolves_and_noop_does_not() {
902        let evo = EvolvingSink::new();
903        assert_eq!(evo.connector_name(), "evolving-sink");
904        assert_eq!(evo.write_batch(&[json!({ "id": 1 })]).await.unwrap(), 1);
905        assert_eq!(evo.column_count(), 1);
906        let evolution = SchemaEvolution {
907            additions: vec![ColumnChange {
908                name: "email".to_string(),
909                from: None,
910                to: json!({ "type": "string" }),
911            }],
912            widenings: Vec::new(),
913            relax_nullability: Vec::new(),
914        };
915        evo.evolve_schema(&evolution).await.unwrap();
916        assert_eq!(evo.column_count(), 2);
917        let schema = evo.current_schema().await.unwrap().unwrap();
918        assert!(schema["properties"]["email"].is_object());
919
920        let noop = NoOpEvolvingSink;
921        assert!(noop.supports_schema_evolution());
922        assert_eq!(noop.write_batch(&[json!({ "id": 1 })]).await.unwrap(), 1);
923        let before = noop.current_schema().await.unwrap().unwrap();
924        noop.evolve_schema(&evolution).await.unwrap();
925        let after = noop.current_schema().await.unwrap().unwrap();
926        assert_eq!(before, after, "noop evolve must not change the schema");
927    }
928
929    #[tokio::test]
930    async fn multi_page_zero_source_emits_multiple_pages_and_fetches() {
931        let s = MultiPageZeroSource::new(6);
932        assert_eq!(s.connector_name(), "multi-page-zero-source");
933        let ctx: HashMap<String, Value> = HashMap::new();
934        assert_eq!(s.fetch_with_context(&ctx).await.unwrap().len(), 6);
935        let mut stream = s.stream_pages(&ctx, 0);
936        let mut pages = 0usize;
937        let mut records = 0usize;
938        while let Some(p) = stream.next().await {
939            let p = p.unwrap();
940            pages += 1;
941            records += p.records.len();
942        }
943        assert_eq!(records, 6);
944        assert!(pages > 1, "must emit more than one page under batch_size=0");
945    }
946
947    #[tokio::test]
948    async fn empty_name_and_erring_check_doubles() {
949        assert_eq!(EmptyNameSource.connector_name(), "");
950        assert!(
951            EmptyNameSource
952                .fetch_with_context(&HashMap::new())
953                .await
954                .unwrap()
955                .is_empty()
956        );
957
958        let ctx = CheckContext::default();
959        assert_eq!(ErringCheckSource.connector_name(), "erring-check-source");
960        assert!(
961            ErringCheckSource
962                .fetch_with_context(&HashMap::new())
963                .await
964                .unwrap()
965                .is_empty()
966        );
967        assert!(ErringCheckSource.check(&ctx).await.is_err());
968
969        let sink = ErringCheckSink;
970        assert_eq!(sink.connector_name(), "erring-check-sink");
971        assert_eq!(sink.write_batch(&[json!({ "x": 1 })]).await.unwrap(), 1);
972        assert!(sink.check(&ctx).await.is_err());
973    }
974
975    #[tokio::test]
976    async fn discoverable_source_enumerates_its_catalog() {
977        let s = DiscoverableSource::new();
978        assert_eq!(s.connector_name(), "discoverable-source");
979        assert!(s.supports_discover());
980        let ds = s.discover().await.unwrap();
981        assert_eq!(ds.len(), 2);
982        assert_eq!(ds[0].name, "orders");
983        assert_eq!(ds[0].config_patch, json!({ "dataset": "orders" }));
984        // The read path is deliberately empty — the rebuild closure supplies the
985        // source that is actually read.
986        assert!(
987            s.fetch_with_context(&HashMap::new())
988                .await
989                .unwrap()
990                .is_empty()
991        );
992
993        // The empty-catalog variant still advertises discovery.
994        let empty = DiscoverableSource::empty();
995        assert!(empty.supports_discover());
996        assert!(empty.discover().await.unwrap().is_empty());
997    }
998
999    #[tokio::test]
1000    async fn buffered_sink_only_durable_after_flush_unless_broken() {
1001        let s = BufferedSink::new();
1002        assert_eq!(s.connector_name(), "buffered-sink");
1003        s.write_batch(&[json!({ "id": 1 }), json!({ "id": 2 })])
1004            .await
1005            .unwrap();
1006        // Staged, not yet durable.
1007        assert_eq!(s.staged_len(), 2);
1008        assert_eq!(s.durable_len(), 0);
1009        s.flush().await.unwrap();
1010        assert_eq!(s.staged_len(), 0);
1011        assert_eq!(s.durable_len(), 2, "flush must commit the staged rows");
1012
1013        // The broken variant never commits, even on flush.
1014        let broken = BufferedSink::broken();
1015        broken.write_batch(&[json!({ "id": 1 })]).await.unwrap();
1016        broken.flush().await.unwrap();
1017        assert_eq!(broken.durable_len(), 0, "broken flush drops the buffer");
1018    }
1019}