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