1use crate::error::FaucetError;
7use crate::observability::{Labels, instrumented_apply_stages};
8use crate::pipeline::StreamPage;
9use crate::stage::{CompiledStage, TransformStage, compile_stage};
10use crate::traits::Source;
11use async_trait::async_trait;
12use futures::StreamExt;
13use futures_core::Stream;
14use serde_json::Value;
15use std::collections::HashMap;
16use std::pin::Pin;
17
18pub struct TransformingSource {
39 inner: Box<dyn Source>,
40 stages: Vec<CompiledStage>,
41 labels: Labels,
42 #[cfg(feature = "arrow")]
48 batch_fns: Vec<Option<crate::stage::PageFnBatchBox>>,
49 row_accounts: Option<std::sync::Arc<crate::accounting::RowAccounts>>,
51}
52
53impl TransformingSource {
54 pub fn new(
59 inner: Box<dyn Source>,
60 stages: Vec<TransformStage>,
61 labels: Labels,
62 ) -> Result<Self, FaucetError> {
63 let compiled = stages
64 .iter()
65 .map(compile_stage)
66 .collect::<Result<Vec<_>, _>>()?;
67 #[cfg(feature = "arrow")]
75 let batch_fns: Vec<Option<crate::stage::PageFnBatchBox>> = stages
76 .iter()
77 .map(|s| match s {
78 TransformStage::Map(t) => crate::columnar_transform::batch_form(t),
79 _ => None,
80 })
81 .collect();
82 Ok(Self {
83 inner,
84 stages: compiled,
85 labels,
86 #[cfg(feature = "arrow")]
87 batch_fns,
88 row_accounts: None,
89 })
90 }
91
92 #[cfg(feature = "arrow")]
97 pub fn new_with_batches(
98 inner: Box<dyn Source>,
99 stages: Vec<TransformStage>,
100 batch_fns: Vec<Option<crate::stage::PageFnBatchBox>>,
101 labels: Labels,
102 ) -> Result<Self, FaucetError> {
103 if batch_fns.len() != stages.len() {
104 return Err(FaucetError::Transform(format!(
105 "TransformingSource::new_with_batches: {} batch fns for {} stages",
106 batch_fns.len(),
107 stages.len()
108 )));
109 }
110 let compiled = stages
111 .iter()
112 .map(compile_stage)
113 .collect::<Result<Vec<_>, _>>()?;
114 Ok(Self {
115 inner,
116 stages: compiled,
117 labels,
118 batch_fns,
119 row_accounts: None,
120 })
121 }
122
123 pub fn with_row_accounts(
126 mut self,
127 accounts: std::sync::Arc<crate::accounting::RowAccounts>,
128 ) -> Self {
129 self.row_accounts = Some(accounts);
130 self
131 }
132}
133
134#[async_trait]
135impl Source for TransformingSource {
136 async fn fetch_with_context(
137 &self,
138 ctx: &HashMap<String, Value>,
139 ) -> Result<Vec<Value>, FaucetError> {
140 let records = self.inner.fetch_with_context(ctx).await?;
141 instrumented_apply_stages(records, &self.stages, &self.labels)
142 }
143
144 async fn fetch_with_context_incremental(
145 &self,
146 ctx: &HashMap<String, Value>,
147 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
148 let (records, bookmark) = self.inner.fetch_with_context_incremental(ctx).await?;
149 let transformed = instrumented_apply_stages(records, &self.stages, &self.labels)?;
150 Ok((transformed, bookmark))
151 }
152
153 fn stream_pages<'a>(
154 &'a self,
155 ctx: &'a HashMap<String, Value>,
156 batch_size: usize,
157 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
158 Box::pin(async_stream::try_stream! {
159 let mut pages = self.inner.stream_pages(ctx, batch_size);
160 while let Some(page) = pages.next().await {
161 let page = page?;
162 let page_len = page.records.len();
171 let records = page.records;
172 let out = instrumented_apply_stages(records, &self.stages, &self.labels)?;
173 if let Some(a) = &self.row_accounts {
174 a.add_transform(page_len, out.len());
175 }
176 if out.is_empty() {
177 yield StreamPage { records: vec![], bookmark: page.bookmark };
178 continue;
179 }
180 if batch_size == 0 || page.bookmark.is_some() {
184 yield StreamPage { records: out, bookmark: page.bookmark };
185 continue;
186 }
187 let effective = std::cmp::max(batch_size, page_len);
188 if out.len() <= effective {
189 yield StreamPage { records: out, bookmark: page.bookmark };
190 continue;
191 }
192 let mut rest = out.into_iter();
194 while rest.len() > 0 {
195 let chunk: Vec<Value> = rest.by_ref().take(effective).collect();
196 let is_last = rest.len() == 0;
197 yield StreamPage {
198 records: chunk,
199 bookmark: if is_last { page.bookmark.clone() } else { None },
200 };
201 }
202 }
203 })
204 }
205
206 #[cfg(feature = "arrow")]
211 fn supports_columnar(&self) -> bool {
212 self.inner.supports_columnar()
213 && !self.batch_fns.is_empty()
214 && self.batch_fns.iter().all(Option::is_some)
215 }
216
217 #[cfg(feature = "arrow")]
222 fn stream_batches<'a>(
223 &'a self,
224 ctx: &'a HashMap<String, Value>,
225 batch_size: usize,
226 ) -> Pin<Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'a>>
227 {
228 Box::pin(async_stream::try_stream! {
229 use metrics::{Label, SharedString, counter};
230 let metric_labels = vec![
231 Label::new("pipeline", SharedString::from(self.labels.pipeline.to_string())),
232 Label::new("row", SharedString::from(self.labels.row.to_string())),
233 ];
234 let mut pages = self.inner.stream_batches(ctx, batch_size);
235 while let Some(page) = pages.next().await {
236 let crate::columnar::ColumnarPage { mut batch, bookmark } = page?;
237 let n_in = batch.num_rows();
242 for bf in self.batch_fns.iter().flatten() {
243 batch = crate::stage::run_blocking(true, || bf(batch)).inspect_err(|_| {
244 counter!("faucet_transform_errors_total", metric_labels.clone())
245 .increment(1);
246 })?;
247 }
248 counter!("faucet_transform_records_in_total", metric_labels.clone())
249 .increment(n_in as u64);
250 counter!("faucet_transform_records_out_total", metric_labels.clone())
251 .increment(batch.num_rows() as u64);
252 yield crate::columnar::ColumnarPage { batch, bookmark };
253 }
254 })
255 }
256
257 crate::forward_source!(inner; except fetch_with_context, fetch_with_context_incremental,
262 stream_pages, supports_columnar, stream_batches, fetch_all, fetch_all_incremental,
263 native_output_formats, stream_native, range_digest);
264}
265
266#[cfg(test)]
267#[allow(clippy::disallowed_methods)] mod tests {
269 use super::*;
270
271 struct OnePage;
272
273 #[async_trait::async_trait]
274 impl crate::Source for OnePage {
275 async fn fetch_with_context(
276 &self,
277 _ctx: &HashMap<String, Value>,
278 ) -> Result<Vec<Value>, FaucetError> {
279 Ok(vec![serde_json::json!({"a": 1})])
280 }
281 }
282
283 fn slow_page_source() -> TransformingSource {
284 let slow: crate::stage::PageFnBox = std::sync::Arc::new(|records: Vec<Value>| {
285 std::thread::sleep(std::time::Duration::from_millis(400));
286 Ok(records)
287 });
288 TransformingSource::new(
289 Box::new(OnePage),
290 vec![TransformStage::PageFn(slow)],
291 Labels::for_named("slow"),
292 )
293 .unwrap()
294 }
295
296 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
297 async fn a_slow_page_transform_does_not_pin_the_worker() {
298 use std::sync::atomic::{AtomicUsize, Ordering};
299 let ticks = std::sync::Arc::new(AtomicUsize::new(0));
300 let t = ticks.clone();
301 let ticker = tokio::spawn(async move {
302 loop {
303 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
304 t.fetch_add(1, Ordering::SeqCst);
305 }
306 });
307 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
308 let src = slow_page_source();
309 let during = tokio::spawn(async move {
310 let ctx = HashMap::new();
311 let mut pages = src.stream_pages(&ctx, 10);
312 let before = ticks.load(Ordering::SeqCst);
313 let page = pages.next().await.unwrap().unwrap();
314 assert_eq!(page.records.len(), 1);
315 let (records, _) = src.fetch_with_context_incremental(&ctx).await.unwrap();
316 assert_eq!(records.len(), 1);
317 ticks.load(Ordering::SeqCst) - before
318 })
319 .await
320 .unwrap();
321 ticker.abort();
322 assert!(during >= 20, "the ticker starved: {during} ticks");
323 }
324
325 #[tokio::test]
326 async fn a_slow_page_transform_runs_inline_on_a_current_thread_runtime() {
327 let src = slow_page_source();
328 assert_eq!(
329 src.fetch_with_context(&HashMap::new()).await.unwrap().len(),
330 1
331 );
332 assert_eq!(crate::stage::run_blocking(true, || 7), 7);
333 }
334
335 #[tokio::test]
337 async fn routed_double_fetches_nothing() {
338 use crate::Source as _;
339 assert!(
340 RoutedSource
341 .fetch_with_context(&Default::default())
342 .await
343 .unwrap()
344 .is_empty()
345 );
346 }
347
348 struct RoutedSource;
349
350 #[async_trait::async_trait]
351 impl crate::Source for RoutedSource {
352 async fn fetch_with_context(
353 &self,
354 _ctx: &std::collections::HashMap<String, serde_json::Value>,
355 ) -> Result<Vec<serde_json::Value>, crate::FaucetError> {
356 Ok(Vec::new())
357 }
358
359 fn record_table(&self, record: &serde_json::Value) -> Option<String> {
360 record.get("t")?.as_str().map(str::to_string)
361 }
362
363 fn position_le(&self, a: &serde_json::Value, b: &serde_json::Value) -> Option<bool> {
364 Some(a.as_u64()? <= b.as_u64()?)
365 }
366
367 fn position_min(&self, _positions: &[serde_json::Value]) -> Option<serde_json::Value> {
368 Some(serde_json::json!("inner-min"))
369 }
370
371 fn record_covered(
372 &self,
373 record: &serde_json::Value,
374 position: &serde_json::Value,
375 ) -> Option<bool> {
376 Some(record.get("p")?.as_u64()? < position.as_u64()?)
377 }
378 }
379
380 fn assert_forwards_multi_table_hooks(s: &dyn crate::Source) {
381 assert_eq!(
382 s.record_table(&serde_json::json!({"t": "public.a"}))
383 .as_deref(),
384 Some("public.a")
385 );
386 assert_eq!(
387 s.position_le(&serde_json::json!(1), &serde_json::json!(2)),
388 Some(true)
389 );
390 assert_eq!(
391 s.position_le(&serde_json::json!(3), &serde_json::json!(2)),
392 Some(false)
393 );
394 assert_eq!(
395 s.record_covered(&serde_json::json!({"p": 1}), &serde_json::json!(2)),
396 Some(true)
397 );
398 assert_eq!(
399 s.record_covered(&serde_json::json!({"p": 2}), &serde_json::json!(2)),
400 Some(false)
401 );
402 assert_eq!(
403 s.position_min(&[serde_json::json!(1), serde_json::json!(2)]),
404 Some(serde_json::json!("inner-min"))
405 );
406 }
407
408 #[test]
409 fn multi_table_hooks_are_forwarded_to_the_inner_source() {
410 let wrapped =
411 TransformingSource::new(Box::new(RoutedSource), vec![], Labels::for_named("test"))
412 .unwrap();
413 assert_forwards_multi_table_hooks(&wrapped);
414 }
415
416 #[tokio::test]
417 async fn forwards_every_hook_the_transforms_do_not_change() {
418 let probe = crate::forwarding::HookSource::default();
419 let wrapped =
420 TransformingSource::new(Box::new(probe.clone()), vec![], Labels::for_named("test"))
421 .unwrap();
422 crate::forwarding::assert_source_forwards_every_hook(
423 &wrapped,
424 &probe,
425 &[
426 "fetch_all",
427 "fetch_all_incremental",
428 "native_output_formats",
429 "stream_native",
430 "range_digest",
431 ],
432 )
433 .await;
434 assert!(wrapped.native_output_formats().is_empty());
435 }
436
437 #[derive(Default)]
438 struct ShardedSource {
439 applied: std::sync::Mutex<Vec<String>>,
440 }
441
442 #[async_trait]
443 impl Source for ShardedSource {
444 async fn fetch_with_context(
445 &self,
446 _ctx: &HashMap<String, serde_json::Value>,
447 ) -> Result<Vec<serde_json::Value>, FaucetError> {
448 Ok(vec![])
449 }
450 fn is_shardable(&self) -> bool {
451 true
452 }
453 async fn enumerate_shards(
454 &self,
455 target: usize,
456 ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
457 Ok((0..target)
458 .map(|i| crate::shard::ShardSpec::new(format!("s{i}"), serde_json::json!(i)))
459 .collect())
460 }
461 async fn apply_shard(&self, shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
462 self.applied.lock().unwrap().push(shard.id.clone());
463 Ok(())
464 }
465 async fn range_digest(
466 &self,
467 _range: &crate::diff::KeyRange,
468 _key: &str,
469 _columns: &[String],
470 ) -> Result<Option<crate::diff::ServerDigest>, FaucetError> {
471 Err(FaucetError::Source(
472 "a raw-data digest must not be consulted".into(),
473 ))
474 }
475 fn supports_discover(&self) -> bool {
476 true
477 }
478 async fn discover(&self) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
479 Ok(vec![crate::discover::DatasetDescriptor::new(
480 "orders",
481 "table",
482 serde_json::json!({}),
483 )])
484 }
485 }
486
487 #[tokio::test]
488 async fn shard_and_discover_hooks_reach_the_inner_source() {
489 let inner = Arc::new(ShardedSource::default());
490 struct Shared(Arc<ShardedSource>);
491 #[async_trait]
492 impl Source for Shared {
493 async fn fetch_with_context(
494 &self,
495 ctx: &HashMap<String, serde_json::Value>,
496 ) -> Result<Vec<serde_json::Value>, FaucetError> {
497 self.0.fetch_with_context(ctx).await
498 }
499 fn is_shardable(&self) -> bool {
500 self.0.is_shardable()
501 }
502 async fn enumerate_shards(
503 &self,
504 target: usize,
505 ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
506 self.0.enumerate_shards(target).await
507 }
508 async fn apply_shard(
509 &self,
510 shard: &crate::shard::ShardSpec,
511 ) -> Result<(), FaucetError> {
512 self.0.apply_shard(shard).await
513 }
514 async fn range_digest(
515 &self,
516 range: &crate::diff::KeyRange,
517 key: &str,
518 columns: &[String],
519 ) -> Result<Option<crate::diff::ServerDigest>, FaucetError> {
520 self.0.range_digest(range, key, columns).await
521 }
522 fn supports_discover(&self) -> bool {
523 self.0.supports_discover()
524 }
525 async fn discover(
526 &self,
527 ) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
528 self.0.discover().await
529 }
530 }
531 let wrapped = TransformingSource::new(
532 Box::new(Shared(inner.clone())),
533 vec![],
534 Labels::for_named("t"),
535 )
536 .unwrap();
537 assert!(wrapped.is_shardable());
538 let shards = wrapped.enumerate_shards(3).await.unwrap();
539 assert_eq!(shards.len(), 3);
540 wrapped.apply_shard(&shards[1]).await.unwrap();
541 assert_eq!(*inner.applied.lock().unwrap(), vec!["s1".to_string()]);
542 assert!(wrapped.supports_discover());
543 assert_eq!(wrapped.discover().await.unwrap()[0].name, "orders");
544 let digest = wrapped
545 .range_digest(&crate::diff::KeyRange::ALL, "id", &[])
546 .await
547 .unwrap();
548 assert!(
549 digest.is_none(),
550 "transformed rows must be compared client-side, never by a raw-data digest"
551 );
552 }
553 use crate::stage::TransformStage;
554 use crate::transform::{KeyCaseMode, RecordTransform};
555 use serde_json::json;
556 use std::sync::Arc;
557 use std::sync::atomic::{AtomicBool, Ordering};
558
559 struct MockSource(Vec<Value>);
560
561 #[async_trait]
562 impl Source for MockSource {
563 async fn fetch_with_context(
564 &self,
565 _ctx: &HashMap<String, Value>,
566 ) -> Result<Vec<Value>, FaucetError> {
567 Ok(self.0.clone())
568 }
569 }
570
571 #[tokio::test]
572 async fn fetch_with_context_transforms_records() {
573 let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
574 let wrapped = TransformingSource::new(
575 inner,
576 vec![TransformStage::Map(RecordTransform::KeysCase {
577 mode: KeyCaseMode::Snake,
578 on_collision: Default::default(),
579 })],
580 Labels::for_named("test"),
581 )
582 .expect("compile succeeds");
583 let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
584 assert_eq!(out, vec![json!({"foo_bar": 1})]);
585 }
586
587 struct VersionedSource;
588
589 #[async_trait]
590 impl Source for VersionedSource {
591 async fn fetch_with_context(
592 &self,
593 _ctx: &HashMap<String, Value>,
594 ) -> Result<Vec<Value>, FaucetError> {
595 Ok(Vec::new())
596 }
597 fn state_schema(&self) -> u32 {
598 2
599 }
600 fn migrate_state(&self, from: u32, data: Value) -> Result<Value, FaucetError> {
601 Ok(json!({"from": from, "data": data}))
602 }
603 }
604
605 #[test]
606 fn state_versioning_is_forwarded_to_the_inner_source() {
607 let wrapped =
608 TransformingSource::new(Box::new(VersionedSource), vec![], Labels::for_named("test"))
609 .expect("compile succeeds");
610 assert_eq!(wrapped.state_schema(), 2);
611 assert_eq!(
612 wrapped.migrate_state(1, json!("x")).unwrap(),
613 json!({"from": 1, "data": "x"})
614 );
615 }
616
617 struct IncrementalSource {
618 records: Vec<Value>,
619 bookmark: Value,
620 }
621
622 #[async_trait]
623 impl Source for IncrementalSource {
624 async fn fetch_with_context(
625 &self,
626 _ctx: &HashMap<String, Value>,
627 ) -> Result<Vec<Value>, FaucetError> {
628 Ok(self.records.clone())
629 }
630
631 async fn fetch_with_context_incremental(
632 &self,
633 _ctx: &HashMap<String, Value>,
634 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
635 Ok((self.records.clone(), Some(self.bookmark.clone())))
636 }
637 }
638
639 #[tokio::test]
640 async fn fetch_with_context_incremental_transforms_and_preserves_bookmark() {
641 let inner: Box<dyn Source> = Box::new(IncrementalSource {
642 records: vec![json!({"FooBar": 1})],
643 bookmark: json!("2026-05-28T00:00:00Z"),
644 });
645 let wrapped = TransformingSource::new(
646 inner,
647 vec![TransformStage::Map(RecordTransform::KeysCase {
648 mode: KeyCaseMode::Snake,
649 on_collision: Default::default(),
650 })],
651 Labels::for_named("test"),
652 )
653 .unwrap();
654 let (records, bookmark) = wrapped
655 .fetch_with_context_incremental(&HashMap::new())
656 .await
657 .unwrap();
658 assert_eq!(records, vec![json!({"foo_bar": 1})]);
659 assert_eq!(bookmark, Some(json!("2026-05-28T00:00:00Z")));
660 }
661
662 struct PagedSource {
667 pages: Vec<Vec<Value>>,
668 final_bookmark: Value,
669 }
670
671 #[async_trait]
672 impl Source for PagedSource {
673 async fn fetch_with_context(
674 &self,
675 _ctx: &HashMap<String, Value>,
676 ) -> Result<Vec<Value>, FaucetError> {
677 Ok(self.pages.iter().flatten().cloned().collect())
678 }
679
680 fn stream_pages<'a>(
681 &'a self,
682 _ctx: &'a HashMap<String, Value>,
683 _batch_size: usize,
684 ) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>
685 {
686 let pages = self.pages.clone();
687 let bookmark = self.final_bookmark.clone();
688 Box::pin(async_stream::try_stream! {
689 let n = pages.len();
690 for (i, records) in pages.into_iter().enumerate() {
691 let bm = if i + 1 == n { Some(bookmark.clone()) } else { None };
692 yield StreamPage { records, bookmark: bm };
693 }
694 })
695 }
696 }
697
698 #[tokio::test]
699 async fn stream_pages_transforms_each_page_and_preserves_bookmarks() {
700 let inner: Box<dyn Source> = Box::new(PagedSource {
701 pages: vec![
702 vec![json!({"FooBar": 1})],
703 vec![json!({"FooBar": 2})],
704 vec![json!({"FooBar": 3})],
705 ],
706 final_bookmark: json!("v1"),
707 });
708 let wrapped = TransformingSource::new(
709 inner,
710 vec![TransformStage::Map(RecordTransform::KeysCase {
711 mode: KeyCaseMode::Snake,
712 on_collision: Default::default(),
713 })],
714 Labels::for_named("test"),
715 )
716 .unwrap();
717
718 let ctx = HashMap::new();
719 let mut stream = wrapped.stream_pages(&ctx, 1000);
720 let mut collected: Vec<StreamPage> = Vec::new();
721 while let Some(page) = stream.next().await {
722 collected.push(page.unwrap());
723 }
724
725 assert_eq!(collected.len(), 3);
726 assert_eq!(collected[0].records, vec![json!({"foo_bar": 1})]);
727 assert!(collected[0].bookmark.is_none());
728 assert_eq!(collected[1].records, vec![json!({"foo_bar": 2})]);
729 assert!(collected[1].bookmark.is_none());
730 assert_eq!(collected[2].records, vec![json!({"foo_bar": 3})]);
731 assert_eq!(collected[2].bookmark, Some(json!("v1")));
732 }
733
734 #[tokio::test]
742 async fn stream_pages_does_not_rechunk_large_page_below_inner_size() {
743 let big: Vec<Value> = (0..2500).map(|i| json!({"FooBar": i})).collect();
744 let inner: Box<dyn Source> = Box::new(PagedSource {
745 pages: vec![big],
746 final_bookmark: json!("v1"),
747 });
748 let wrapped = TransformingSource::new(
749 inner,
750 vec![TransformStage::Map(RecordTransform::KeysCase {
751 mode: KeyCaseMode::Snake,
752 on_collision: Default::default(),
753 })],
754 Labels::for_named("t"),
755 )
756 .unwrap();
757 let ctx = HashMap::new();
758 let mut stream = wrapped.stream_pages(&ctx, 1000);
760 let mut pages: Vec<StreamPage> = Vec::new();
761 while let Some(p) = stream.next().await {
762 pages.push(p.unwrap());
763 }
764 assert_eq!(pages.len(), 1, "one inner page must stay one page");
765 assert_eq!(pages[0].records.len(), 2500);
766 assert_eq!(pages[0].records[0], json!({"foo_bar": 0}));
767 assert_eq!(pages[0].bookmark, Some(json!("v1")));
768 }
769
770 #[tokio::test]
771 async fn stream_pages_passes_through_empty_records_page_with_bookmark() {
772 struct EmptyWithBookmark;
773 #[async_trait]
774 impl Source for EmptyWithBookmark {
775 async fn fetch_with_context(
776 &self,
777 _ctx: &HashMap<String, Value>,
778 ) -> Result<Vec<Value>, FaucetError> {
779 Ok(Vec::new())
780 }
781 fn stream_pages<'a>(
782 &'a self,
783 _ctx: &'a HashMap<String, Value>,
784 _batch_size: usize,
785 ) -> Pin<
786 Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
787 > {
788 Box::pin(async_stream::try_stream! {
789 yield StreamPage { records: Vec::new(), bookmark: Some(json!("v1")) };
790 })
791 }
792 }
793 let wrapped = TransformingSource::new(
794 Box::new(EmptyWithBookmark),
795 vec![TransformStage::Map(RecordTransform::KeysCase {
796 mode: KeyCaseMode::Snake,
797 on_collision: Default::default(),
798 })],
799 Labels::for_named("test"),
800 )
801 .unwrap();
802 let ctx = HashMap::new();
803 let mut stream = wrapped.stream_pages(&ctx, 1000);
804 let page = stream.next().await.unwrap().unwrap();
805 assert!(page.records.is_empty());
806 assert_eq!(page.bookmark, Some(json!("v1")));
807 assert!(stream.next().await.is_none());
808 }
809
810 struct InstrumentedSource {
811 started: Arc<AtomicBool>,
812 }
813
814 #[async_trait]
815 impl Source for InstrumentedSource {
816 async fn fetch_with_context(
817 &self,
818 _ctx: &HashMap<String, Value>,
819 ) -> Result<Vec<Value>, FaucetError> {
820 Ok(vec![])
821 }
822 fn connector_name(&self) -> &'static str {
823 "instrumented"
824 }
825 fn state_key(&self) -> Option<String> {
826 Some("instrumented::key".to_string())
827 }
828 async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
829 self.started.store(true, Ordering::Relaxed);
830 Ok(())
831 }
832 fn supports_exactly_once(&self) -> bool {
833 true
834 }
835 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
836 Ok(Some(json!("captured")))
837 }
838 }
839
840 struct RecorderProbe(Arc<AtomicBool>);
841
842 #[async_trait]
843 impl Source for RecorderProbe {
844 async fn fetch_with_context(
845 &self,
846 _ctx: &HashMap<String, Value>,
847 ) -> Result<Vec<Value>, FaucetError> {
848 Ok(vec![])
849 }
850 fn set_roundtrip_recorder(&self, _recorder: Arc<crate::observability::RoundtripRecorder>) {
851 self.0.store(true, Ordering::Relaxed);
852 }
853 fn set_run_clock(&self, _now: chrono::DateTime<chrono::Utc>) {
854 self.0.store(true, Ordering::Relaxed);
855 }
856 fn set_cancel(&self, _cancel: tokio_util::sync::CancellationToken) -> bool {
857 self.0.store(true, Ordering::Relaxed);
858 true
859 }
860 }
861
862 #[test]
863 fn cancel_reaches_the_wrapped_source_and_its_answer_comes_back() {
864 let got = Arc::new(AtomicBool::new(false));
865 let wrapped = TransformingSource::new(
866 Box::new(RecorderProbe(got.clone())),
867 vec![],
868 Labels::for_named("test"),
869 )
870 .unwrap();
871 assert!(wrapped.set_cancel(tokio_util::sync::CancellationToken::new()));
872 assert!(got.load(Ordering::Relaxed));
873 }
874
875 #[test]
876 fn run_clock_reaches_the_wrapped_source() {
877 let got = Arc::new(AtomicBool::new(false));
878 let wrapped = TransformingSource::new(
879 Box::new(RecorderProbe(got.clone())),
880 vec![],
881 Labels::for_named("test"),
882 )
883 .unwrap();
884 wrapped.set_run_clock(chrono::Utc::now());
885 assert!(got.load(Ordering::Relaxed));
886 }
887
888 #[test]
889 fn roundtrip_recorder_reaches_the_wrapped_source() {
890 let got = Arc::new(AtomicBool::new(false));
891 let wrapped = TransformingSource::new(
892 Box::new(RecorderProbe(got.clone())),
893 vec![],
894 Labels::for_named("test"),
895 )
896 .unwrap();
897 wrapped.set_roundtrip_recorder(Arc::new(crate::observability::RoundtripRecorder::new(
898 crate::observability::RoundtripSide::Source,
899 "p",
900 "r",
901 "probe",
902 )));
903 assert!(got.load(Ordering::Relaxed));
904 }
905
906 #[tokio::test]
907 async fn connector_name_state_key_and_start_bookmark_delegate_to_inner() {
908 let started = Arc::new(AtomicBool::new(false));
909 let inner = InstrumentedSource {
910 started: started.clone(),
911 };
912 let wrapped = TransformingSource::new(
913 Box::new(inner),
914 vec![TransformStage::Map(RecordTransform::KeysCase {
915 mode: KeyCaseMode::Snake,
916 on_collision: Default::default(),
917 })],
918 Labels::for_named("test"),
919 )
920 .unwrap();
921 assert_eq!(wrapped.connector_name(), "instrumented");
922 assert_eq!(wrapped.state_key(), Some("instrumented::key".to_string()));
923 wrapped.apply_start_bookmark(json!("bm")).await.unwrap();
924 assert!(started.load(Ordering::Relaxed));
925 assert!(wrapped.supports_exactly_once());
928 assert!(!wrapped.consumes_destructively());
929 assert_eq!(
930 wrapped.replay_guarantee(),
931 crate::idempotency::ReplayGuarantee::Deterministic
932 );
933 assert_eq!(
934 wrapped.capture_resume_position().await.unwrap(),
935 Some(json!("captured"))
936 );
937 assert_eq!(wrapped.lag().await.unwrap(), None);
938 }
939
940 #[tokio::test]
941 async fn new_fails_fast_on_invalid_regex() {
942 let inner: Box<dyn Source> = Box::new(MockSource(vec![]));
943 let result = TransformingSource::new(
944 inner,
945 vec![TransformStage::Map(RecordTransform::RenameKeys {
946 pattern: "[invalid".to_string(),
947 replacement: "x".to_string(),
948 })],
949 Labels::for_named("test"),
950 );
951 let err = match result {
952 Ok(_) => panic!("invalid regex must fail at new()"),
953 Err(e) => e,
954 };
955 assert!(matches!(err, FaucetError::Transform(_)));
956 }
957
958 #[tokio::test]
959 async fn custom_closure_transform_runs() {
960 let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"x": 1})]));
961 let wrapped = TransformingSource::new(
962 inner,
963 vec![TransformStage::Map(RecordTransform::custom(
964 |mut record| {
965 if let Some(obj) = record.as_object_mut() {
966 obj.insert("added".to_string(), json!(true));
967 }
968 record
969 },
970 ))],
971 Labels::for_named("test"),
972 )
973 .unwrap();
974 let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
975 assert_eq!(out, vec![json!({"x": 1, "added": true})]);
976 }
977
978 #[tokio::test]
979 async fn usable_as_boxed_dyn_source() {
980 let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
981 let wrapped: Box<dyn Source> = Box::new(
982 TransformingSource::new(
983 inner,
984 vec![TransformStage::Map(RecordTransform::KeysCase {
985 mode: KeyCaseMode::Snake,
986 on_collision: Default::default(),
987 })],
988 Labels::for_named("test"),
989 )
990 .unwrap(),
991 );
992 let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
993 assert_eq!(out, vec![json!({"foo_bar": 1})]);
994 }
995
996 #[allow(dead_code)]
1001 struct OnePageSource {
1002 records: Vec<Value>,
1003 bookmark: Option<Value>,
1004 }
1005
1006 #[async_trait]
1007 impl Source for OnePageSource {
1008 async fn fetch_with_context(
1009 &self,
1010 _ctx: &HashMap<String, Value>,
1011 ) -> Result<Vec<Value>, FaucetError> {
1012 Ok(self.records.clone())
1013 }
1014 async fn fetch_with_context_incremental(
1015 &self,
1016 _ctx: &HashMap<String, Value>,
1017 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1018 Ok((self.records.clone(), self.bookmark.clone()))
1019 }
1020 fn stream_pages<'a>(
1021 &'a self,
1022 _ctx: &'a HashMap<String, Value>,
1023 _batch_size: usize,
1024 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
1025 let page = StreamPage {
1026 records: self.records.clone(),
1027 bookmark: self.bookmark.clone(),
1028 };
1029 Box::pin(async_stream::stream! { yield Ok(page); })
1030 }
1031 }
1032
1033 #[cfg(feature = "transform-explode")]
1034 fn explode_stage() -> TransformStage {
1035 TransformStage::Explode(crate::stage::ExplodeSpec {
1036 path: "items".to_owned(),
1037 prefix: None,
1038 separator: "_".to_owned(),
1039 on_missing: crate::stage::OnMissing::Drop,
1040 })
1041 }
1042
1043 #[cfg(feature = "transform-explode")]
1045 fn explode_10x_records(n: usize) -> Vec<Value> {
1046 (0..n)
1047 .map(|i| {
1048 json!({
1049 "id": i,
1050 "items": (0..10).map(|j| json!({"k": j})).collect::<Vec<_>>(),
1051 })
1052 })
1053 .collect()
1054 }
1055
1056 #[cfg(feature = "transform-explode")]
1057 #[tokio::test]
1058 async fn stream_pages_never_splits_a_bookmark_carrying_page() {
1059 let inner: Box<dyn Source> = Box::new(OnePageSource {
1060 records: explode_10x_records(100), bookmark: Some(json!("bm")),
1062 });
1063 let wrapped =
1064 TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1065 let ctx = HashMap::new();
1066 let mut stream = wrapped.stream_pages(&ctx, 200);
1067 let mut sub_pages: Vec<StreamPage> = Vec::new();
1068 while let Some(p) = stream.next().await {
1069 sub_pages.push(p.unwrap());
1070 }
1071 assert_eq!(
1072 sub_pages.len(),
1073 1,
1074 "a bookmarked page stays one commit unit"
1075 );
1076 assert_eq!(sub_pages[0].records.len(), 1000);
1077 assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1078 }
1079
1080 #[cfg(feature = "transform-explode")]
1081 #[tokio::test]
1082 async fn stream_pages_rechunks_a_grown_page_without_a_bookmark() {
1083 let inner: Box<dyn Source> = Box::new(OnePageSource {
1084 records: explode_10x_records(100),
1085 bookmark: None,
1086 });
1087 let wrapped =
1088 TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1089 let ctx = HashMap::new();
1090 let mut stream = wrapped.stream_pages(&ctx, 200);
1091 let mut sub_pages: Vec<StreamPage> = Vec::new();
1092 while let Some(p) = stream.next().await {
1093 sub_pages.push(p.unwrap());
1094 }
1095 assert_eq!(sub_pages.len(), 5, "1000 records / 200 batch = 5 sub-pages");
1096 assert!(sub_pages.iter().all(|p| p.records.len() == 200));
1097 assert!(sub_pages.iter().all(|p| p.bookmark.is_none()));
1098 }
1099
1100 #[cfg(feature = "transform-explode")]
1101 #[tokio::test]
1102 async fn stream_pages_batch_size_zero_emits_one_page() {
1103 let inner: Box<dyn Source> = Box::new(OnePageSource {
1104 records: explode_10x_records(10), bookmark: Some(json!("bm")),
1106 });
1107 let wrapped =
1108 TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1109 let ctx = HashMap::new();
1110 let mut stream = wrapped.stream_pages(&ctx, 0);
1111 let mut sub_pages: Vec<StreamPage> = Vec::new();
1112 while let Some(p) = stream.next().await {
1113 sub_pages.push(p.unwrap());
1114 }
1115 assert_eq!(sub_pages.len(), 1, "batch_size=0 means one sub-page");
1116 assert_eq!(sub_pages[0].records.len(), 100);
1117 assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1118 }
1119
1120 #[cfg(feature = "transform-filter")]
1121 #[tokio::test]
1122 async fn stream_pages_filter_drops_all_still_yields_bookmark() {
1123 let inner: Box<dyn Source> = Box::new(OnePageSource {
1124 records: vec![json!({"deleted": true}), json!({"deleted": true})],
1125 bookmark: Some(json!("bm")),
1126 });
1127 let drop_all = TransformStage::Filter(crate::stage::FilterSpec {
1128 path: "deleted".to_owned(),
1129 op: crate::stage::FilterOp::Ne,
1130 value: Some(json!(true)),
1131 });
1132 let wrapped =
1133 TransformingSource::new(inner, vec![drop_all], Labels::for_named("t")).unwrap();
1134 let ctx = HashMap::new();
1135 let mut stream = wrapped.stream_pages(&ctx, 100);
1136 let mut sub_pages: Vec<StreamPage> = Vec::new();
1137 while let Some(p) = stream.next().await {
1138 sub_pages.push(p.unwrap());
1139 }
1140 assert_eq!(sub_pages.len(), 1);
1141 assert!(sub_pages[0].records.is_empty());
1142 assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1143 }
1144}
1145
1146#[cfg(all(test, feature = "arrow"))]
1147mod columnar_tests {
1148 use super::*;
1149 use crate::columnar::{ColumnarPage, record_batch_to_values, values_to_record_batch_inferred};
1150 use crate::stage::TransformStage;
1151 use serde_json::json;
1152 use std::sync::Arc;
1153
1154 struct ColumnarMock(Vec<Value>);
1156 #[async_trait]
1157 impl Source for ColumnarMock {
1158 async fn fetch_with_context(
1159 &self,
1160 _ctx: &HashMap<String, Value>,
1161 ) -> Result<Vec<Value>, FaucetError> {
1162 Ok(self.0.clone())
1163 }
1164 fn supports_columnar(&self) -> bool {
1165 true
1166 }
1167 fn stream_batches<'a>(
1168 &'a self,
1169 _ctx: &'a HashMap<String, Value>,
1170 _bs: usize,
1171 ) -> Pin<Box<dyn Stream<Item = Result<ColumnarPage, FaucetError>> + Send + 'a>> {
1172 let batch = values_to_record_batch_inferred(&self.0).unwrap();
1173 Box::pin(async_stream::stream! {
1174 yield Ok(ColumnarPage { batch, bookmark: Some(json!("bm")) });
1175 })
1176 }
1177 }
1178
1179 struct RowOnlyMock(Vec<Value>);
1181 #[async_trait]
1182 impl Source for RowOnlyMock {
1183 async fn fetch_with_context(
1184 &self,
1185 _ctx: &HashMap<String, Value>,
1186 ) -> Result<Vec<Value>, FaucetError> {
1187 Ok(self.0.clone())
1188 }
1189 }
1190
1191 fn identity_stage() -> (TransformStage, Option<crate::stage::PageFnBatchBox>) {
1194 let rows: crate::stage::PageFnBox = Arc::new(Ok);
1195 let batch: crate::stage::PageFnBatchBox = Arc::new(Ok);
1196 (TransformStage::PageFn(rows), Some(batch))
1197 }
1198
1199 #[tokio::test]
1200 async fn columnar_inner_plus_columnar_stage_is_supported_and_streams() {
1201 let inner: Box<dyn Source> =
1202 Box::new(ColumnarMock(vec![json!({"id": 1}), json!({"id": 2})]));
1203 let (stage, batch) = identity_stage();
1204 let wrapped = TransformingSource::new_with_batches(
1205 inner,
1206 vec![stage],
1207 vec![batch],
1208 Labels::for_named("t"),
1209 )
1210 .unwrap();
1211 assert!(wrapped.supports_columnar());
1212 let ctx = HashMap::new();
1213 let mut s = wrapped.stream_batches(&ctx, 0);
1214 let page = s.next().await.unwrap().unwrap();
1215 let rows = record_batch_to_values(&page.batch).unwrap();
1216 assert_eq!(rows.len(), 2);
1217 assert_eq!(page.bookmark, Some(json!("bm")));
1218 }
1219
1220 #[tokio::test]
1221 async fn value_only_stage_disables_columnar() {
1222 let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![json!({"FooBar": 1})]));
1225 let wrapped = TransformingSource::new(
1226 inner,
1227 vec![TransformStage::Map(
1228 crate::transform::RecordTransform::KeysCase {
1229 mode: crate::transform::KeyCaseMode::Snake,
1230 on_collision: crate::transform::KeyCollision::Error,
1231 },
1232 )],
1233 Labels::for_named("t"),
1234 )
1235 .unwrap();
1236 assert!(!wrapped.supports_columnar());
1237 }
1238
1239 #[cfg(all(feature = "transform-drop", feature = "transform-set"))]
1244 #[tokio::test]
1245 async fn built_in_vectorizable_transforms_keep_the_columnar_path() {
1246 use crate::transform::RecordTransform;
1247 let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![
1248 json!({"id": 1, "name": "ada", "secret": "x"}),
1249 json!({"id": 2, "name": "grace", "secret": "y"}),
1250 ]));
1251 let mut set_vals = serde_json::Map::new();
1252 set_vals.insert("stage".into(), json!("prod"));
1253 let wrapped = TransformingSource::new(
1254 inner,
1255 vec![
1256 TransformStage::Map(RecordTransform::Drop {
1257 fields: vec!["secret".into()],
1258 }),
1259 TransformStage::Map(RecordTransform::Set { values: set_vals }),
1260 ],
1261 Labels::for_named("t"),
1262 )
1263 .unwrap();
1264 assert!(
1265 wrapped.supports_columnar(),
1266 "a chain of vectorizable built-ins must stay columnar"
1267 );
1268 let ctx = HashMap::new();
1269 let mut s = wrapped.stream_batches(&ctx, 0);
1270 let page = s.next().await.unwrap().unwrap();
1271 let rows = record_batch_to_values(&page.batch).unwrap();
1272 assert_eq!(rows.len(), 2);
1273 assert!(rows[0].get("secret").is_none(), "drop ran: {:?}", rows[0]);
1276 assert_eq!(rows[0]["stage"], json!("prod"), "set ran: {:?}", rows[0]);
1277 }
1278
1279 #[cfg(all(feature = "transform-select", feature = "transform-flatten"))]
1283 #[tokio::test]
1284 async fn a_mixed_chain_with_one_opaque_transform_stays_on_value() {
1285 use crate::transform::RecordTransform;
1286 let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![json!({"id": 1})]));
1287 let wrapped = TransformingSource::new(
1288 inner,
1289 vec![
1290 TransformStage::Map(RecordTransform::Select {
1291 fields: vec!["id".into()],
1292 }),
1293 TransformStage::Map(RecordTransform::Flatten {
1295 separator: "_".into(),
1296 }),
1297 ],
1298 Labels::for_named("t"),
1299 )
1300 .unwrap();
1301 assert!(!wrapped.supports_columnar());
1302 }
1303
1304 #[tokio::test]
1305 async fn columnar_stage_over_row_only_inner_is_disabled() {
1306 let inner: Box<dyn Source> = Box::new(RowOnlyMock(vec![json!({"id": 1})]));
1307 let (stage, batch) = identity_stage();
1308 let wrapped = TransformingSource::new_with_batches(
1309 inner,
1310 vec![stage],
1311 vec![batch],
1312 Labels::for_named("t"),
1313 )
1314 .unwrap();
1315 assert!(!wrapped.supports_columnar(), "inner is not columnar");
1316 }
1317}