faucet_core/traits.rs
1//! Shared traits for faucet sources and sinks.
2
3use crate::error::FaucetError;
4use crate::pipeline::StreamPage;
5use async_trait::async_trait;
6use futures_core::Stream;
7use serde_json::Value;
8use std::pin::Pin;
9
10/// A source fetches records from an external system.
11#[async_trait]
12pub trait Source: Send + Sync {
13 /// Primary fetch method. Receives context from a parent source's records.
14 ///
15 /// An empty context map means this is a root source (no parent).
16 /// Connectors that support being a child should use
17 /// [`substitute_context()`](crate::util::substitute_context) to resolve
18 /// `{placeholder}` tokens in their URL path, query parameters, headers,
19 /// or body. Connectors that don't need parent context ignore the map.
20 async fn fetch_with_context(
21 &self,
22 context: &std::collections::HashMap<String, Value>,
23 ) -> Result<Vec<Value>, FaucetError>;
24
25 /// Convenience: fetch with no parent context.
26 async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
27 self.fetch_with_context(&std::collections::HashMap::new())
28 .await
29 }
30
31 /// Incremental fetch with parent context support.
32 ///
33 /// Returns the records and an optional bookmark value for incremental
34 /// replication. The default delegates to `fetch_with_context` and
35 /// returns `None` for the bookmark.
36 async fn fetch_with_context_incremental(
37 &self,
38 context: &std::collections::HashMap<String, Value>,
39 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
40 let records = self.fetch_with_context(context).await?;
41 Ok((records, None))
42 }
43
44 /// Convenience: incremental fetch with no parent context.
45 async fn fetch_all_incremental(&self) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
46 self.fetch_with_context_incremental(&std::collections::HashMap::new())
47 .await
48 }
49
50 /// Stream records page-by-page so the pipeline can write to the sink as
51 /// pages arrive instead of buffering the full result set.
52 ///
53 /// `batch_size` is the *hint* the pipeline passes down; sources are free
54 /// to use a larger or smaller native chunk (e.g. one page per HTTP
55 /// response, one row-group per Parquet file) but should approximate it
56 /// where feasible. The special value `batch_size = 0` means "do not
57 /// batch — emit the entire result set in a single page." Sources that
58 /// stream natively should treat `0` as "skip the chunking layer and
59 /// yield one page after the underlying read completes" (useful for
60 /// small lookup tables or for sinks like SQL `COPY` / BigQuery load
61 /// jobs that prefer one large request).
62 ///
63 /// The default implementation fetches the full result set via
64 /// [`fetch_with_context_incremental`](Self::fetch_with_context_incremental)
65 /// and chunks it in memory by `batch_size`. The bookmark (when present)
66 /// is attached to the *final* page so the pipeline only persists after
67 /// the entire fetch has been written. Sources that can stream natively
68 /// override this method and may emit per-page bookmarks (e.g. CDC).
69 ///
70 /// An empty result with a `Some(bookmark)` still yields one empty page
71 /// carrying the bookmark, so incremental runs that produce no records
72 /// still advance their checkpoint.
73 fn stream_pages<'a>(
74 &'a self,
75 context: &'a std::collections::HashMap<String, Value>,
76 batch_size: usize,
77 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
78 Box::pin(async_stream::try_stream! {
79 let (records, bookmark) = self
80 .fetch_with_context_incremental(context)
81 .await?;
82 let total = records.len();
83 // batch_size == 0 means "no batching" — emit all records as one
84 // page. Otherwise chunk into pages of size `batch_size`.
85 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
86
87 if total == 0 {
88 if bookmark.is_some() {
89 yield StreamPage {
90 records: Vec::new(),
91 bookmark,
92 };
93 }
94 return;
95 }
96
97 let mut iter = records.into_iter();
98 let mut consumed = 0usize;
99 loop {
100 let batch: Vec<Value> = iter.by_ref().take(chunk).collect();
101 if batch.is_empty() {
102 break;
103 }
104 consumed += batch.len();
105 let page_bookmark = if consumed >= total {
106 bookmark.clone()
107 } else {
108 None
109 };
110 yield StreamPage {
111 records: batch,
112 bookmark: page_bookmark,
113 };
114 }
115 })
116 }
117
118 /// Whether this source can emit **columnar** ([`ColumnarPage`](crate::columnar::ColumnarPage))
119 /// pages via [`stream_batches`](Self::stream_batches). Default: `false`.
120 ///
121 /// The pipeline uses the columnar fast path only when *both* the source and
122 /// sink return `true` here (and no `Value`-shaped stage needs to observe the
123 /// records), so an Arrow-native `parquet → parquet` chain never materializes
124 /// `Value`. Opt-in and additive — see [`crate::columnar`] (RFC 0002 / #375).
125 #[cfg(feature = "arrow")]
126 fn supports_columnar(&self) -> bool {
127 false
128 }
129
130 /// Stream the source natively as Arrow
131 /// [`ColumnarPage`](crate::columnar::ColumnarPage)s.
132 ///
133 /// Only invoked when [`supports_columnar`](Self::supports_columnar) returns
134 /// `true`; the default yields a single typed "unsupported" error so a source
135 /// that advertises support but forgets to override this fails loudly rather
136 /// than silently. Each page's `bookmark` carries the same checkpoint
137 /// semantics as [`StreamPage`].
138 #[cfg(feature = "arrow")]
139 fn stream_batches<'a>(
140 &'a self,
141 context: &'a std::collections::HashMap<String, Value>,
142 batch_size: usize,
143 ) -> Pin<Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'a>>
144 {
145 let _ = (context, batch_size);
146 let name = self.connector_name();
147 let err: Result<crate::columnar::ColumnarPage, FaucetError> = Err(FaucetError::Source(
148 format!("source '{name}' does not support columnar streaming (stream_batches)"),
149 ));
150 Box::pin(futures::stream::once(async move { err }))
151 }
152
153 /// Return a JSON Schema describing the configuration this source accepts.
154 fn config_schema(&self) -> Value {
155 serde_json::json!({"type": "object", "properties": {}})
156 }
157
158 /// Stable key under which this source's incremental-replication bookmark
159 /// should be persisted in a [`StateStore`](crate::state::StateStore).
160 ///
161 /// Returning `Some(key)` opts this source into resumable runs: when the
162 /// pipeline is configured with a state store via
163 /// [`Pipeline::with_state_store`](crate::Pipeline::with_state_store), it
164 /// reads the bookmark at `key` before fetching and writes the new
165 /// bookmark back only after the sink confirms the batch was written.
166 ///
167 /// The default returns `None`, meaning the source is not persisted.
168 /// Keys must satisfy [`validate_state_key`](crate::state::validate_state_key).
169 fn state_key(&self) -> Option<String> {
170 None
171 }
172
173 /// Apply a bookmark loaded from a [`StateStore`](crate::state::StateStore)
174 /// as this run's starting point.
175 ///
176 /// The default implementation ignores the value, which keeps existing
177 /// sources backwards-compatible. Sources that support incremental
178 /// replication override this — typically by storing the value behind
179 /// interior mutability and consulting it inside
180 /// `fetch_with_context_incremental`.
181 async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
182 Ok(())
183 }
184
185 /// Capture the source's current replication position **without consuming
186 /// any changes**, ensuring any server-side resource (e.g. a logical
187 /// replication slot) needed to later resume from that position exists.
188 ///
189 /// Returns the position as a bookmark [`Value`] — the same shape
190 /// [`apply_start_bookmark`](Self::apply_start_bookmark) accepts — or `None`
191 /// if this source does not support position capture.
192 ///
193 /// Used by the snapshot→CDC replication orchestrator (`faucet replicate`)
194 /// to anchor the CDC stream at-or-before the bulk snapshot's read point so
195 /// the handoff has no gap. The default returns `None`.
196 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
197 Ok(None)
198 }
199
200 /// Whether this source **deterministically replays** the same page sequence
201 /// from a given bookmark — the requirement for the atomic-watermark
202 /// effectively-once path (a non-deterministic replay could cause the pipeline
203 /// to skip a page whose contents differ from the one already committed).
204 /// Default: `false`.
205 ///
206 /// Sources with a durable monotonic position and per-page bookmarks (CDC)
207 /// override this to return `true`. The pipeline rejects
208 /// `DeliveryMode::ExactlyOnce` against a source that returns `false`.
209 fn supports_exactly_once(&self) -> bool {
210 false
211 }
212
213 /// The typed replay capability this source advertises — see
214 /// [`ReplayGuarantee`](crate::ReplayGuarantee).
215 ///
216 /// The default derives from [`supports_exactly_once`](Self::supports_exactly_once)
217 /// (the boolean stays the back-compat primitive: existing connectors that
218 /// override only the boolean automatically advertise `Deterministic`
219 /// here). Override this directly only to *diverge* from the boolean —
220 /// there is currently no reason to.
221 fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
222 if self.supports_exactly_once() {
223 crate::idempotency::ReplayGuarantee::Deterministic
224 } else {
225 crate::idempotency::ReplayGuarantee::NonDeterministic
226 }
227 }
228
229 /// Whether this source can split its work into independent shards for
230 /// clustered (Mode B) execution. Default: `false` (single whole-dataset
231 /// shard). Sources with a natural partition (object-store prefixes, table
232 /// primary-key ranges) override this to `true` and implement
233 /// [`enumerate_shards`](Self::enumerate_shards) +
234 /// [`apply_shard`](Self::apply_shard).
235 fn is_shardable(&self) -> bool {
236 false
237 }
238
239 /// Enumerate the shards this source splits into, aiming for roughly `target`
240 /// of them (a hint — the source may return fewer, e.g. when the dataset is
241 /// small, or one per natural partition regardless of `target`).
242 ///
243 /// Called **once per run** by the cluster coordinator; enumeration must be
244 /// deterministic enough that re-enumeration yields a compatible set (stable
245 /// shard ids), since it may run on more than one instance and is reconciled
246 /// by idempotent insert. May perform read-only I/O (a `LIST`, a `MIN/MAX`
247 /// query). The default returns a single whole-dataset shard
248 /// ([`ShardSpec::whole`](crate::ShardSpec::whole)), preserving today's
249 /// single-worker behavior.
250 async fn enumerate_shards(
251 &self,
252 _target: usize,
253 ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
254 Ok(vec![crate::shard::ShardSpec::whole()])
255 }
256
257 /// Narrow this source instance to a single shard before streaming.
258 ///
259 /// Called on the worker that claims `shard`, after construction and before
260 /// any `stream_pages` call. Like [`apply_start_bookmark`](Self::apply_start_bookmark)
261 /// this takes `&self` and is expected to record the shard behind interior
262 /// mutability (the source consults it when building its query / listing).
263 /// The default ignores the shard — a non-shardable source only ever receives
264 /// [`ShardSpec::whole`](crate::ShardSpec::whole), so ignoring it streams the
265 /// whole dataset. Implementations should accept the whole shard as a no-op.
266 async fn apply_shard(&self, _shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
267 Ok(())
268 }
269
270 /// Whether this source can enumerate the datasets behind its connection
271 /// via [`discover`](Self::discover). Default: `false`. Sources backed by
272 /// an introspectable catalog (database `information_schema`, MongoDB
273 /// collections, Elasticsearch indices, object-store prefixes) override
274 /// this to `true`.
275 fn supports_discover(&self) -> bool {
276 false
277 }
278
279 /// Enumerate the datasets living behind this source's connection — one
280 /// [`DatasetDescriptor`](crate::discover::DatasetDescriptor) per table /
281 /// collection / index / prefix, each carrying a partial config override
282 /// that selects it (used by `faucet discover` to scaffold one matrix row
283 /// per dataset).
284 ///
285 /// Must be **read-only and cheap**: catalog metadata queries and listings
286 /// only, never a data scan. Descriptors must never embed credentials.
287 /// The default returns a typed "unsupported" error; override it (and
288 /// return `true` from [`supports_discover`](Self::supports_discover))
289 /// only for sources with a real catalog to introspect.
290 async fn discover(&self) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
291 Err(FaucetError::Source(format!(
292 "source '{}' does not support dataset discovery",
293 self.connector_name()
294 )))
295 }
296
297 /// Stable identifier used as the `connector` label on metrics and the
298 /// `connector` attribute on spans. Defaults to the final segment of
299 /// `std::any::type_name::<Self>()`, e.g. `"RestSource"`. Built-in
300 /// connectors override with a short, friendly snake_case name (e.g.
301 /// `"rest"`). Must return a non-empty string; observability decorators
302 /// fall back to `"unknown"` in release builds if it is empty (and
303 /// `debug_assert!` in debug builds).
304 fn connector_name(&self) -> &'static str {
305 crate::observability::strip_type_name(std::any::type_name::<Self>())
306 }
307
308 /// Logical dataset identity for lineage emission, following OpenLineage
309 /// naming conventions (<https://openlineage.io/docs/spec/naming>).
310 ///
311 /// The default returns `"<connector_name>://unknown"`. Built-in connectors
312 /// override with a credential-free URI derived from their config. Strip any
313 /// credentials with [`redact_uri_credentials`](crate::redact_uri_credentials).
314 /// Informational metadata only — never used for I/O.
315 fn dataset_uri(&self) -> String {
316 format!("{}://unknown", self.connector_name())
317 }
318
319 /// Run a fast, non-mutating preflight probe (used by `faucet doctor`).
320 ///
321 /// The default pulls a **single page** via
322 /// [`stream_pages`](Self::stream_pages) and reports success/failure — it
323 /// exercises the real read path (DNS, TLS, auth, the first request, the
324 /// first-record decode) but never paginates the full dataset and never
325 /// repeats. The page stream is dropped immediately after the first page.
326 ///
327 /// Sources whose first page *blocks* waiting for inbound data (webhook,
328 /// websocket) or has *side effects* (CDC consuming WAL) override this with a
329 /// cheaper, side-effect-free probe. Probe-level failures are returned as a
330 /// [`ProbeStatus::Fail`](crate::check::ProbeStatus) inside `Ok(report)`.
331 async fn check(
332 &self,
333 ctx: &crate::check::CheckContext,
334 ) -> Result<crate::check::CheckReport, FaucetError> {
335 use crate::check::{CheckReport, Probe};
336 use futures::StreamExt;
337
338 let empty = std::collections::HashMap::new();
339 let start = std::time::Instant::now();
340 let mut pages = self.stream_pages(&empty, 1);
341 let probe = match tokio::time::timeout(ctx.timeout, pages.next()).await {
342 Err(_) => Probe::fail("read", start.elapsed(), "timed out fetching first page"),
343 Ok(None) | Ok(Some(Ok(_))) => Probe::pass("read", start.elapsed()),
344 Ok(Some(Err(e))) => Probe::fail("read", start.elapsed(), e.to_string()),
345 };
346 Ok(CheckReport::single(probe))
347 }
348}
349
350/// Per-row outcome from [`Sink::write_batch_partial`].
351///
352/// `Ok(())` — the row was durably written to the sink.
353/// `Err(_)` — the row failed; the pipeline will route it to the DLQ when
354/// one is configured.
355pub type RowOutcome = Result<(), FaucetError>;
356
357/// A sink writes records to an external system.
358#[async_trait]
359pub trait Sink: Send + Sync {
360 /// Write a batch of records to the destination.
361 ///
362 /// Returns the number of records successfully written.
363 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError>;
364
365 /// Flush any buffered data to the destination.
366 ///
367 /// The default implementation is a no-op (suitable for sinks that
368 /// write immediately in `write_batch`).
369 async fn flush(&self) -> Result<(), FaucetError> {
370 Ok(())
371 }
372
373 /// Write a batch and report per-row outcomes.
374 ///
375 /// Sinks whose underlying API exposes per-row results (BigQuery
376 /// `insertAll`, Elasticsearch `_bulk`) override this. The default
377 /// implementation delegates to [`Self::write_batch`] and maps a single success
378 /// onto a uniform all-`Ok(())` vector. An outer failure is bubbled up
379 /// unchanged so the pipeline's DLQ router can apply its `on_batch_error`
380 /// policy at a single decision point.
381 async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
382 self.write_batch(records).await?;
383 Ok(records.iter().map(|_| Ok(())).collect())
384 }
385
386 /// Whether this sink can consume **columnar** (`arrow::RecordBatch`) writes
387 /// via [`write_batch_columnar`](Self::write_batch_columnar) without first
388 /// converting to `Value`. Default: `false`.
389 ///
390 /// The pipeline takes the columnar fast path only when *both* the source and
391 /// sink return `true` (RFC 0002 / #375).
392 #[cfg(feature = "arrow")]
393 fn supports_columnar(&self) -> bool {
394 false
395 }
396
397 /// Write a columnar `RecordBatch` to the destination, returning the number of
398 /// rows written.
399 ///
400 /// The default converts the batch to `Value` rows via the
401 /// [`columnar`](crate::columnar) shim and delegates to
402 /// [`write_batch`](Self::write_batch), so every sink participates correctly
403 /// even without a native columnar path. Sinks that write Arrow/Parquet
404 /// directly override this — and return `true` from
405 /// [`supports_columnar`](Self::supports_columnar) — to skip the conversion.
406 #[cfg(feature = "arrow")]
407 async fn write_batch_columnar(
408 &self,
409 batch: &arrow::array::RecordBatch,
410 ) -> Result<usize, FaucetError> {
411 let rows = crate::columnar::record_batch_to_values(batch)?;
412 self.write_batch(&rows).await
413 }
414
415 /// Whether this sink can durably commit a page's rows **and** a commit token
416 /// in a single atomic transaction. Default: `false` (at-least-once only).
417 ///
418 /// Only return `true` from a sink that genuinely commits both atomically —
419 /// see [`write_batch_idempotent`](Self::write_batch_idempotent). The pipeline
420 /// rejects `DeliveryMode::ExactlyOnce` against a sink that returns `false`.
421 fn supports_idempotent_writes(&self) -> bool {
422 false
423 }
424
425 /// The strongest delivery guarantee this sink can uphold — see
426 /// [`SinkGuarantee`](crate::SinkGuarantee).
427 ///
428 /// The default derives from the two back-compat primitives:
429 /// [`supports_idempotent_writes`](Self::supports_idempotent_writes) →
430 /// `AtomicWatermark`, else an upsert-capable
431 /// [`supported_write_modes`](Self::supported_write_modes) → `KeyedUpsert`,
432 /// else `AtLeastOnce`. Existing connectors that override only the
433 /// primitives automatically advertise the right capability here.
434 fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
435 if self.supports_idempotent_writes() {
436 crate::idempotency::SinkGuarantee::AtomicWatermark
437 } else if self
438 .supported_write_modes()
439 .contains(&crate::write_mode::WriteMode::Upsert)
440 {
441 crate::idempotency::SinkGuarantee::KeyedUpsert
442 } else {
443 crate::idempotency::SinkGuarantee::AtLeastOnce
444 }
445 }
446
447 /// Whether this sink instance is **configured** to dedup by key — i.e.
448 /// `write_mode: upsert` (or `delete`) with a non-empty `key`, so
449 /// re-applying a record with the same key converges instead of
450 /// duplicating. Default: `false`.
451 ///
452 /// Distinct from [`sink_guarantee`](Self::sink_guarantee) (capability):
453 /// this reflects the *live config*. Sinks that flatten a
454 /// [`WriteSpec`](crate::write_mode::WriteSpec) into their config override
455 /// it as `self.config.write.dedups_by_key()`. The pipeline consults it to
456 /// derive the keyed-upsert effectively-once mechanism at run time.
457 fn dedups_by_key(&self) -> bool {
458 false
459 }
460
461 /// Write modes this sink can apply. Default: append-only. Sinks that
462 /// implement key-based merge override this to include
463 /// [`WriteMode::Upsert`](crate::write_mode::WriteMode) /
464 /// [`WriteMode::Delete`](crate::write_mode::WriteMode). The CLI rejects a
465 /// configured mode that is not in this set at config-load time.
466 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
467 &[crate::write_mode::WriteMode::Append]
468 }
469
470 /// The sink's live destination schema as an `infer_schema`-shaped object
471 /// (`{"type":"object","properties":{ <col>: <type-fragment>, … }}`), or
472 /// `None` for a schemaless sink or a target that does not exist yet.
473 ///
474 /// Used by the schema-drift policy to diff each page's shape against the
475 /// real destination. Default: `Ok(None)` (drift handling is inert).
476 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
477 Ok(None)
478 }
479
480 /// Whether this sink can apply additive/widening DDL via
481 /// [`evolve_schema`](Self::evolve_schema). Default: `false`. The CLI rejects
482 /// `on_drift: evolve` against a sink that returns `false` at config-load.
483 fn supports_schema_evolution(&self) -> bool {
484 false
485 }
486
487 /// Apply an additive schema evolution (new columns, lossless widenings,
488 /// nullability relaxations) to the destination. MUST be idempotent
489 /// (`ADD COLUMN IF NOT EXISTS` semantics) so concurrent runs converge.
490 ///
491 /// Default: a typed "unsupported" error. Override only when the backend
492 /// supports in-place additive DDL (and return `true` from
493 /// `supports_schema_evolution`).
494 async fn evolve_schema(
495 &self,
496 evolution: &crate::drift::SchemaEvolution,
497 ) -> Result<(), FaucetError> {
498 let _ = evolution;
499 Err(FaucetError::Sink(format!(
500 "sink '{}' does not support schema evolution",
501 self.connector_name()
502 )))
503 }
504
505 /// Whether this sink can delete a scoped set of rows for scoped cleanup
506 /// (#478). Default `false`; the upsert-capable sinks override it.
507 fn supports_cleanup(&self) -> bool {
508 false
509 }
510
511 /// Delete rows matching `scope` whose key is **not** in `seen`.
512 ///
513 /// Called at most once per invocation, only after the run completed
514 /// successfully and uncancelled — see [`crate::cleanup`] for why the timing
515 /// is load-bearing. `scope` is a set of equality predicates in destination
516 /// column terms, AND-ed together; `seen` holds the key tuples this run wrote.
517 ///
518 /// Returns the number of rows deleted. Implementations **must** be
519 /// all-or-nothing where the backend allows it: a partial delete would remove
520 /// rows the run actually wrote.
521 ///
522 /// The default is a typed "unsupported" error, so no existing or third-party
523 /// connector breaks.
524 async fn cleanup_scope(
525 &self,
526 scope: &std::collections::BTreeMap<String, Value>,
527 seen: &crate::cleanup::SeenKeys,
528 ) -> Result<u64, FaucetError> {
529 let _ = (scope, seen);
530 Err(FaucetError::Sink(format!(
531 "sink '{}' does not support scoped cleanup",
532 self.connector_name()
533 )))
534 }
535
536 /// Write `records` AND durably record `token` for `scope`, atomically.
537 ///
538 /// `scope` namespaces the watermark (the pipeline passes the per-row state
539 /// key, e.g. `"{name}::{row_id}"`). `token` is a monotonic, fixed-width
540 /// string (see [`format_token`](crate::format_token)).
541 ///
542 /// The default is **not** idempotent: it ignores the token and delegates to
543 /// [`write_batch`](Self::write_batch). Override only when the commit is
544 /// genuinely atomic (and return `true` from `supports_idempotent_writes`).
545 async fn write_batch_idempotent(
546 &self,
547 records: &[Value],
548 scope: &str,
549 token: &str,
550 ) -> Result<usize, FaucetError> {
551 let _ = (scope, token);
552 self.write_batch(records).await
553 }
554
555 /// The last token durably committed for `scope`, or `None` if this sink has
556 /// never committed under that scope. Default: `None`.
557 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
558 let _ = scope;
559 Ok(None)
560 }
561
562 /// Return a JSON Schema describing the configuration this sink accepts.
563 ///
564 /// The schema is auto-generated from the config struct using `schemars`.
565 /// Callers can inspect it to discover required fields, types, defaults,
566 /// and descriptions before constructing the sink.
567 ///
568 /// The default returns an empty object schema.
569 fn config_schema(&self) -> Value {
570 serde_json::json!({"type": "object", "properties": {}})
571 }
572
573 /// Stable identifier used as the `connector` label on metrics and the
574 /// `connector` attribute on spans. See `Source::connector_name`.
575 fn connector_name(&self) -> &'static str {
576 crate::observability::strip_type_name(std::any::type_name::<Self>())
577 }
578
579 /// Logical dataset identity for lineage emission, following OpenLineage
580 /// naming conventions (<https://openlineage.io/docs/spec/naming>).
581 ///
582 /// The default returns `"<connector_name>://unknown"`. Built-in connectors
583 /// override with a credential-free URI derived from their config. Strip any
584 /// credentials with [`redact_uri_credentials`](crate::redact_uri_credentials).
585 /// Informational metadata only — never used for I/O.
586 fn dataset_uri(&self) -> String {
587 format!("{}://unknown", self.connector_name())
588 }
589
590 /// Run a fast, non-mutating preflight probe (used by `faucet doctor`).
591 ///
592 /// Unlike sources, a sink has no non-mutating "first page" equivalent
593 /// (`write_batch` mutates the destination), so the default returns
594 /// [`CheckReport::not_implemented`](crate::check::CheckReport::not_implemented).
595 /// Built-in sinks override this with a connect / auth / metadata probe.
596 ///
597 /// The probe **MUST be idempotent and side-effect-free** — no inserts, no
598 /// residual rows or objects — and must never put credentials or connection
599 /// strings in a probe `reason`/`hint`.
600 async fn check(
601 &self,
602 _ctx: &crate::check::CheckContext,
603 ) -> Result<crate::check::CheckReport, FaucetError> {
604 Ok(crate::check::CheckReport::not_implemented())
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use serde_json::json;
612
613 // ── Mock Source ──────────────────────────────────────────────────────────
614
615 struct MockSource {
616 records: Vec<Value>,
617 }
618
619 #[async_trait]
620 impl Source for MockSource {
621 async fn fetch_with_context(
622 &self,
623 _context: &std::collections::HashMap<String, Value>,
624 ) -> Result<Vec<Value>, FaucetError> {
625 Ok(self.records.clone())
626 }
627 }
628
629 struct IncrementalSource {
630 records: Vec<Value>,
631 bookmark: Value,
632 }
633
634 #[async_trait]
635 impl Source for IncrementalSource {
636 async fn fetch_with_context(
637 &self,
638 _context: &std::collections::HashMap<String, Value>,
639 ) -> Result<Vec<Value>, FaucetError> {
640 Ok(self.records.clone())
641 }
642
643 async fn fetch_with_context_incremental(
644 &self,
645 _context: &std::collections::HashMap<String, Value>,
646 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
647 Ok((self.records.clone(), Some(self.bookmark.clone())))
648 }
649 }
650
651 struct FailingSource;
652
653 #[async_trait]
654 impl Source for FailingSource {
655 async fn fetch_with_context(
656 &self,
657 _context: &std::collections::HashMap<String, Value>,
658 ) -> Result<Vec<Value>, FaucetError> {
659 Err(FaucetError::Auth("no credentials".into()))
660 }
661 }
662
663 // ── Mock Sink ───────────────────────────────────────────────────────────
664
665 struct MockSink {
666 written: std::sync::Mutex<Vec<Value>>,
667 }
668
669 impl MockSink {
670 fn new() -> Self {
671 Self {
672 written: std::sync::Mutex::new(Vec::new()),
673 }
674 }
675 }
676
677 #[async_trait]
678 impl Sink for MockSink {
679 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
680 let mut w = self.written.lock().unwrap();
681 w.extend(records.iter().cloned());
682 Ok(records.len())
683 }
684 }
685
686 struct FailingSink;
687
688 #[async_trait]
689 impl Sink for FailingSink {
690 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
691 Err(FaucetError::Sink("write failed".into()))
692 }
693 }
694
695 // ── Source tests ────────────────────────────────────────────────────────
696
697 #[tokio::test]
698 async fn source_fetch_all_returns_records() {
699 let source = MockSource {
700 records: vec![json!({"id": 1}), json!({"id": 2})],
701 };
702 let records = source.fetch_all().await.unwrap();
703 assert_eq!(records.len(), 2);
704 assert_eq!(records[0]["id"], 1);
705 }
706
707 #[tokio::test]
708 async fn source_fetch_all_empty() {
709 let source = MockSource { records: vec![] };
710 let records = source.fetch_all().await.unwrap();
711 assert!(records.is_empty());
712 }
713
714 #[tokio::test]
715 async fn source_default_incremental_returns_none_bookmark() {
716 let source = MockSource {
717 records: vec![json!({"id": 1})],
718 };
719 let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
720 assert_eq!(records.len(), 1);
721 assert!(bookmark.is_none());
722 }
723
724 #[tokio::test]
725 async fn source_custom_incremental_returns_bookmark() {
726 let source = IncrementalSource {
727 records: vec![json!({"id": 1})],
728 bookmark: json!("2024-12-01"),
729 };
730 let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
731 assert_eq!(records.len(), 1);
732 assert_eq!(bookmark, Some(json!("2024-12-01")));
733 }
734
735 #[tokio::test]
736 async fn source_error_propagates() {
737 let source = FailingSource;
738 let result = source.fetch_all().await;
739 assert!(result.is_err());
740 assert!(matches!(result, Err(FaucetError::Auth(_))));
741 }
742
743 #[tokio::test]
744 async fn source_as_trait_object() {
745 let source: Box<dyn Source> = Box::new(MockSource {
746 records: vec![json!({"id": 42})],
747 });
748 let records = source.fetch_all().await.unwrap();
749 assert_eq!(records[0]["id"], 42);
750 }
751
752 // ── Sink tests ──────────────────────────────────────────────────────────
753
754 #[tokio::test]
755 async fn sink_write_batch_returns_count() {
756 let sink = MockSink::new();
757 let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
758 let count = sink.write_batch(&records).await.unwrap();
759 assert_eq!(count, 3);
760 }
761
762 #[tokio::test]
763 async fn sink_write_batch_empty() {
764 let sink = MockSink::new();
765 let count = sink.write_batch(&[]).await.unwrap();
766 assert_eq!(count, 0);
767 }
768
769 #[tokio::test]
770 async fn sink_accumulates_records() {
771 let sink = MockSink::new();
772 sink.write_batch(&[json!({"a": 1})]).await.unwrap();
773 sink.write_batch(&[json!({"b": 2})]).await.unwrap();
774 let written = sink.written.lock().unwrap();
775 assert_eq!(written.len(), 2);
776 }
777
778 #[tokio::test]
779 async fn sink_default_flush_is_noop() {
780 let sink = MockSink::new();
781 assert!(sink.flush().await.is_ok());
782 }
783
784 #[tokio::test]
785 async fn sink_error_propagates() {
786 let sink = FailingSink;
787 let result = sink.write_batch(&[json!({"id": 1})]).await;
788 assert!(result.is_err());
789 assert!(matches!(result, Err(FaucetError::Sink(_))));
790 }
791
792 #[tokio::test]
793 async fn sink_as_trait_object() {
794 let sink: Box<dyn Sink> = Box::new(MockSink::new());
795 let count = sink.write_batch(&[json!({"id": 1})]).await.unwrap();
796 assert_eq!(count, 1);
797 }
798
799 // ── stream_pages tests ──────────────────────────────────────────────────
800
801 use crate::pipeline::DEFAULT_BATCH_SIZE;
802 use futures::StreamExt;
803
804 #[tokio::test]
805 async fn default_stream_pages_chunks_records() {
806 let source = MockSource {
807 records: (0..5).map(|i| json!({"i": i})).collect(),
808 };
809 let ctx = std::collections::HashMap::new();
810 let mut pages = source.stream_pages(&ctx, 2);
811 let mut all = Vec::new();
812 while let Some(page) = pages.next().await {
813 all.push(page.unwrap());
814 }
815 // 5 records, batch_size=2 → pages of [2, 2, 1]
816 assert_eq!(all.len(), 3);
817 assert_eq!(all[0].records.len(), 2);
818 assert_eq!(all[1].records.len(), 2);
819 assert_eq!(all[2].records.len(), 1);
820 }
821
822 #[tokio::test]
823 async fn default_stream_pages_attaches_bookmark_to_final_page_only() {
824 let source = IncrementalSource {
825 records: (0..5).map(|i| json!({"i": i})).collect(),
826 bookmark: json!("v1"),
827 };
828 let ctx = std::collections::HashMap::new();
829 let mut pages = source.stream_pages(&ctx, 2);
830 let mut collected = Vec::new();
831 while let Some(page) = pages.next().await {
832 collected.push(page.unwrap());
833 }
834 assert_eq!(collected.len(), 3);
835 assert!(collected[0].bookmark.is_none());
836 assert!(collected[1].bookmark.is_none());
837 assert_eq!(collected[2].bookmark, Some(json!("v1")));
838 }
839
840 #[tokio::test]
841 async fn default_stream_pages_single_page_when_batch_size_exceeds_total() {
842 let source = MockSource {
843 records: vec![json!({"id": 1}), json!({"id": 2})],
844 };
845 let ctx = std::collections::HashMap::new();
846 let mut pages = source.stream_pages(&ctx, 100);
847 let mut collected = Vec::new();
848 while let Some(page) = pages.next().await {
849 collected.push(page.unwrap());
850 }
851 assert_eq!(collected.len(), 1);
852 assert_eq!(collected[0].records.len(), 2);
853 }
854
855 #[tokio::test]
856 async fn default_stream_pages_batch_size_zero_emits_single_page() {
857 // batch_size = 0 is the "no batching" sentinel — yields every record
858 // in one page regardless of total count.
859 let source = MockSource {
860 records: (0..50_000).map(|i| json!({"i": i})).collect(),
861 };
862 let ctx = std::collections::HashMap::new();
863 let mut pages = source.stream_pages(&ctx, 0);
864 let mut collected = Vec::new();
865 while let Some(page) = pages.next().await {
866 collected.push(page.unwrap());
867 }
868 assert_eq!(
869 collected.len(),
870 1,
871 "batch_size=0 must emit exactly one page"
872 );
873 assert_eq!(collected[0].records.len(), 50_000);
874 }
875
876 #[tokio::test]
877 async fn default_stream_pages_batch_size_zero_attaches_bookmark_to_sole_page() {
878 let source = IncrementalSource {
879 records: (0..3).map(|i| json!({"i": i})).collect(),
880 bookmark: json!("v1"),
881 };
882 let ctx = std::collections::HashMap::new();
883 let mut pages = source.stream_pages(&ctx, 0);
884 let page = pages.next().await.unwrap().unwrap();
885 assert_eq!(page.records.len(), 3);
886 assert_eq!(page.bookmark, Some(json!("v1")));
887 assert!(pages.next().await.is_none());
888 }
889
890 #[tokio::test]
891 async fn default_stream_pages_empty_source_yields_no_pages() {
892 let source = MockSource { records: vec![] };
893 let ctx = std::collections::HashMap::new();
894 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
895 assert!(pages.next().await.is_none());
896 }
897
898 #[tokio::test]
899 async fn default_stream_pages_empty_source_with_bookmark_yields_single_empty_page() {
900 let source = IncrementalSource {
901 records: vec![],
902 bookmark: json!("v0"),
903 };
904 let ctx = std::collections::HashMap::new();
905 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
906 let mut collected = Vec::new();
907 while let Some(page) = pages.next().await {
908 collected.push(page.unwrap());
909 }
910 // One empty-records page that carries the bookmark, so the pipeline
911 // still persists progress on otherwise-empty incremental runs.
912 assert_eq!(collected.len(), 1);
913 assert!(collected[0].records.is_empty());
914 assert_eq!(collected[0].bookmark, Some(json!("v0")));
915 }
916
917 #[tokio::test]
918 async fn default_stream_pages_propagates_fetch_errors() {
919 let source = FailingSource;
920 let ctx = std::collections::HashMap::new();
921 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
922 let first = pages.next().await.unwrap();
923 assert!(matches!(first, Err(FaucetError::Auth(_))));
924 }
925
926 #[test]
927 fn source_default_connector_name_is_stripped_type_name() {
928 // MockSource lives at `faucet_core::traits::tests::MockSource`; the
929 // stripped type_name yields the trailing segment.
930 let source = MockSource { records: vec![] };
931 assert_eq!(source.connector_name(), "MockSource");
932 }
933
934 #[test]
935 fn sink_default_connector_name_is_stripped_type_name() {
936 let sink = MockSink::new();
937 assert_eq!(sink.connector_name(), "MockSink");
938 }
939
940 #[test]
941 fn source_default_dataset_uri_uses_connector_name() {
942 let source = MockSource { records: vec![] };
943 assert_eq!(source.dataset_uri(), "MockSource://unknown");
944 }
945
946 #[test]
947 fn sink_default_dataset_uri_uses_connector_name() {
948 let sink = MockSink::new();
949 assert_eq!(sink.dataset_uri(), "MockSink://unknown");
950 }
951
952 // ── write_batch_partial tests ───────────────────────────────────────────
953
954 #[tokio::test]
955 async fn default_write_batch_partial_success_returns_all_ok() {
956 let sink = MockSink::new();
957 let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
958 let outcomes = sink.write_batch_partial(&records).await.unwrap();
959 assert_eq!(outcomes.len(), 3);
960 assert!(outcomes.iter().all(|o| o.is_ok()));
961 assert_eq!(sink.written.lock().unwrap().len(), 3);
962 }
963
964 #[tokio::test]
965 async fn default_write_batch_partial_bubbles_outer_err() {
966 let sink = FailingSink;
967 let records = vec![json!({"id": 1}), json!({"id": 2})];
968 let result = sink.write_batch_partial(&records).await;
969 assert!(matches!(result, Err(FaucetError::Sink(_))));
970 }
971
972 #[tokio::test]
973 async fn default_write_batch_partial_empty_returns_empty_vec() {
974 let sink = MockSink::new();
975 let outcomes = sink.write_batch_partial(&[]).await.unwrap();
976 assert!(outcomes.is_empty());
977 }
978
979 #[tokio::test]
980 async fn default_write_batch_partial_callable_through_trait_object() {
981 let sink: Box<dyn Sink> = Box::new(MockSink::new());
982 let records = vec![json!({"id": 1}), json!({"id": 2})];
983 let outcomes = sink.write_batch_partial(&records).await.unwrap();
984 assert_eq!(outcomes.len(), 2);
985 assert!(outcomes.iter().all(|o| o.is_ok()));
986 }
987
988 // ── check() tests ─────────────────────────────────────────────────────────
989
990 #[tokio::test]
991 async fn source_default_check_pulls_first_page_and_passes() {
992 let source = MockSource {
993 records: vec![json!({"id": 1}), json!({"id": 2})],
994 };
995 let report = source
996 .check(&crate::check::CheckContext::default())
997 .await
998 .unwrap();
999 assert_eq!(report.failed_count(), 0);
1000 assert!(
1001 report
1002 .probes
1003 .iter()
1004 .any(|p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Pass))
1005 );
1006 }
1007
1008 #[tokio::test]
1009 async fn source_default_check_passes_on_empty_source() {
1010 let source = MockSource { records: vec![] };
1011 let report = source
1012 .check(&crate::check::CheckContext::default())
1013 .await
1014 .unwrap();
1015 // Reachable but empty is still a healthy source.
1016 assert_eq!(report.failed_count(), 0);
1017 }
1018
1019 #[tokio::test]
1020 async fn source_default_check_fails_when_fetch_errors() {
1021 let source = FailingSource;
1022 let report = source
1023 .check(&crate::check::CheckContext::default())
1024 .await
1025 .unwrap();
1026 assert_eq!(report.failed_count(), 1);
1027 assert!(report.probes.iter().any(
1028 |p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Fail { .. })
1029 ));
1030 }
1031
1032 #[tokio::test]
1033 async fn sink_default_check_is_not_implemented_skip() {
1034 let sink = MockSink::new();
1035 let report = sink
1036 .check(&crate::check::CheckContext::default())
1037 .await
1038 .unwrap();
1039 assert_eq!(report.probes.len(), 1);
1040 assert!(matches!(
1041 report.probes[0].status,
1042 crate::check::ProbeStatus::Skip { .. }
1043 ));
1044 }
1045
1046 #[tokio::test]
1047 async fn source_check_callable_through_trait_object() {
1048 let source: Box<dyn Source> = Box::new(MockSource {
1049 records: vec![json!({"id": 1})],
1050 });
1051 let report = source
1052 .check(&crate::check::CheckContext::default())
1053 .await
1054 .unwrap();
1055 assert_eq!(report.failed_count(), 0);
1056 }
1057
1058 // ── idempotent-write / exactly-once capability tests ──────────────────────
1059
1060 #[tokio::test]
1061 async fn sink_default_is_not_idempotent() {
1062 let sink = MockSink::new();
1063 assert!(!sink.supports_idempotent_writes());
1064 // Default write_batch_idempotent ignores the token and delegates.
1065 let n = sink
1066 .write_batch_idempotent(&[json!({"id": 1})], "scope::a", "00000000000000000001")
1067 .await
1068 .unwrap();
1069 assert_eq!(n, 1);
1070 assert_eq!(sink.last_committed_token("scope::a").await.unwrap(), None);
1071 assert_eq!(sink.written.lock().unwrap().len(), 1);
1072 }
1073
1074 #[test]
1075 fn source_default_does_not_support_exactly_once() {
1076 let source = MockSource { records: vec![] };
1077 assert!(!source.supports_exactly_once());
1078 }
1079
1080 #[test]
1081 fn sink_default_supported_write_modes_is_append_only() {
1082 use crate::write_mode::WriteMode;
1083 let sink = MockSink::new();
1084 assert_eq!(sink.supported_write_modes(), &[WriteMode::Append]);
1085 }
1086
1087 #[test]
1088 fn supported_write_modes_callable_through_trait_object() {
1089 use crate::write_mode::WriteMode;
1090 let sink: Box<dyn Sink> = Box::new(MockSink::new());
1091 assert!(sink.supported_write_modes().contains(&WriteMode::Append));
1092 }
1093
1094 #[tokio::test]
1095 async fn sink_default_current_schema_is_none() {
1096 let sink = MockSink::new();
1097 assert_eq!(sink.current_schema().await.unwrap(), None);
1098 }
1099
1100 #[test]
1101 fn sink_default_does_not_support_schema_evolution() {
1102 let sink = MockSink::new();
1103 assert!(!sink.supports_schema_evolution());
1104 }
1105
1106 #[tokio::test]
1107 async fn sink_default_evolve_schema_is_unsupported_error() {
1108 let sink = MockSink::new();
1109 let evo = crate::drift::SchemaEvolution::default();
1110 let err = sink.evolve_schema(&evo).await.unwrap_err();
1111 assert!(matches!(err, FaucetError::Sink(_)));
1112 assert!(err.to_string().contains("schema evolution"));
1113 }
1114
1115 #[tokio::test]
1116 async fn source_default_capture_resume_position_is_none() {
1117 let source = MockSource { records: vec![] };
1118 assert_eq!(source.capture_resume_position().await.unwrap(), None);
1119 }
1120
1121 #[tokio::test]
1122 async fn capture_resume_position_callable_through_trait_object() {
1123 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1124 assert!(source.capture_resume_position().await.unwrap().is_none());
1125 }
1126
1127 #[tokio::test]
1128 async fn source_default_does_not_support_discover() {
1129 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1130 assert!(!source.supports_discover());
1131 let err = source.discover().await.unwrap_err();
1132 assert!(matches!(err, FaucetError::Source(_)));
1133 assert!(
1134 err.to_string().contains("dataset discovery"),
1135 "typed unsupported error: {err}"
1136 );
1137 }
1138
1139 #[tokio::test]
1140 async fn source_default_is_not_shardable() {
1141 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1142 assert!(!source.is_shardable());
1143 }
1144
1145 #[tokio::test]
1146 async fn source_default_enumerates_single_whole_shard() {
1147 // A non-shardable source enumerates to exactly one whole-dataset shard,
1148 // regardless of the requested target — preserving single-worker behavior.
1149 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1150 let shards = source.enumerate_shards(8).await.unwrap();
1151 assert_eq!(shards.len(), 1);
1152 assert!(shards[0].is_whole());
1153 }
1154
1155 #[tokio::test]
1156 async fn source_default_apply_shard_is_noop() {
1157 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
1158 // Applying the whole shard is a no-op and must not error.
1159 source
1160 .apply_shard(&crate::shard::ShardSpec::whole())
1161 .await
1162 .unwrap();
1163 }
1164}