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 if let Some(a) = &self.row_accounts {
253 a.add_transform(n_in, batch.num_rows());
254 }
255 yield crate::columnar::ColumnarPage { batch, bookmark };
256 }
257 })
258 }
259
260 async fn declared_columns(
267 &self,
268 context: &HashMap<String, Value>,
269 ) -> Result<Option<crate::declared::DeclaredColumns>, FaucetError> {
270 let declared = self.inner.declared_columns(context).await?;
271 Ok(declared.map(|d| {
272 if self.stages.iter().all(is_filter) {
273 d
274 } else {
275 d.reshaped()
276 }
277 }))
278 }
279
280 crate::forward_source!(inner; except fetch_with_context, fetch_with_context_incremental,
281 stream_pages, supports_columnar, stream_batches, fetch_all, fetch_all_incremental,
282 native_output_formats, stream_native, range_digest, declared_columns);
283}
284
285fn is_filter(stage: &CompiledStage) -> bool {
286 #[cfg(feature = "transform-filter")]
287 if matches!(stage, CompiledStage::Filter(_)) {
288 return true;
289 }
290 let _ = stage;
291 false
292}
293
294#[cfg(test)]
295#[allow(clippy::disallowed_methods)] mod tests {
297 use super::*;
298
299 pub(super) struct OnePage;
300
301 #[async_trait::async_trait]
302 impl crate::Source for OnePage {
303 async fn fetch_with_context(
304 &self,
305 _ctx: &HashMap<String, Value>,
306 ) -> Result<Vec<Value>, FaucetError> {
307 Ok(vec![serde_json::json!({"a": 1})])
308 }
309 }
310
311 fn slow_page_source() -> TransformingSource {
312 let slow: crate::stage::PageFnBox = std::sync::Arc::new(|records: Vec<Value>| {
313 std::thread::sleep(std::time::Duration::from_millis(400));
314 Ok(records)
315 });
316 TransformingSource::new(
317 Box::new(OnePage),
318 vec![TransformStage::PageFn(slow)],
319 Labels::for_named("slow"),
320 )
321 .unwrap()
322 }
323
324 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
325 async fn a_slow_page_transform_does_not_pin_the_worker() {
326 use std::sync::atomic::{AtomicUsize, Ordering};
327 let ticks = std::sync::Arc::new(AtomicUsize::new(0));
328 let t = ticks.clone();
329 let ticker = tokio::spawn(async move {
330 loop {
331 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
332 t.fetch_add(1, Ordering::SeqCst);
333 }
334 });
335 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
336 let src = slow_page_source();
337 let during = tokio::spawn(async move {
338 let ctx = HashMap::new();
339 let mut pages = src.stream_pages(&ctx, 10);
340 let before = ticks.load(Ordering::SeqCst);
341 let page = pages.next().await.unwrap().unwrap();
342 assert_eq!(page.records.len(), 1);
343 let (records, _) = src.fetch_with_context_incremental(&ctx).await.unwrap();
344 assert_eq!(records.len(), 1);
345 ticks.load(Ordering::SeqCst) - before
346 })
347 .await
348 .unwrap();
349 ticker.abort();
350 assert!(during >= 20, "the ticker starved: {during} ticks");
351 }
352
353 #[tokio::test]
354 async fn a_slow_page_transform_runs_inline_on_a_current_thread_runtime() {
355 let src = slow_page_source();
356 assert_eq!(
357 src.fetch_with_context(&HashMap::new()).await.unwrap().len(),
358 1
359 );
360 assert_eq!(crate::stage::run_blocking(true, || 7), 7);
361 }
362
363 #[tokio::test]
365 async fn routed_double_fetches_nothing() {
366 use crate::Source as _;
367 assert!(
368 RoutedSource
369 .fetch_with_context(&Default::default())
370 .await
371 .unwrap()
372 .is_empty()
373 );
374 }
375
376 struct RoutedSource;
377
378 #[async_trait::async_trait]
379 impl crate::Source for RoutedSource {
380 async fn fetch_with_context(
381 &self,
382 _ctx: &std::collections::HashMap<String, serde_json::Value>,
383 ) -> Result<Vec<serde_json::Value>, crate::FaucetError> {
384 Ok(Vec::new())
385 }
386
387 fn record_table(&self, record: &serde_json::Value) -> Option<String> {
388 record.get("t")?.as_str().map(str::to_string)
389 }
390
391 fn position_le(&self, a: &serde_json::Value, b: &serde_json::Value) -> Option<bool> {
392 Some(a.as_u64()? <= b.as_u64()?)
393 }
394
395 fn position_min(&self, _positions: &[serde_json::Value]) -> Option<serde_json::Value> {
396 Some(serde_json::json!("inner-min"))
397 }
398
399 fn record_covered(
400 &self,
401 record: &serde_json::Value,
402 position: &serde_json::Value,
403 ) -> Option<bool> {
404 Some(record.get("p")?.as_u64()? < position.as_u64()?)
405 }
406 }
407
408 fn assert_forwards_multi_table_hooks(s: &dyn crate::Source) {
409 assert_eq!(
410 s.record_table(&serde_json::json!({"t": "public.a"}))
411 .as_deref(),
412 Some("public.a")
413 );
414 assert_eq!(
415 s.position_le(&serde_json::json!(1), &serde_json::json!(2)),
416 Some(true)
417 );
418 assert_eq!(
419 s.position_le(&serde_json::json!(3), &serde_json::json!(2)),
420 Some(false)
421 );
422 assert_eq!(
423 s.record_covered(&serde_json::json!({"p": 1}), &serde_json::json!(2)),
424 Some(true)
425 );
426 assert_eq!(
427 s.record_covered(&serde_json::json!({"p": 2}), &serde_json::json!(2)),
428 Some(false)
429 );
430 assert_eq!(
431 s.position_min(&[serde_json::json!(1), serde_json::json!(2)]),
432 Some(serde_json::json!("inner-min"))
433 );
434 }
435
436 #[test]
437 fn multi_table_hooks_are_forwarded_to_the_inner_source() {
438 let wrapped =
439 TransformingSource::new(Box::new(RoutedSource), vec![], Labels::for_named("test"))
440 .unwrap();
441 assert_forwards_multi_table_hooks(&wrapped);
442 }
443
444 #[tokio::test]
445 async fn forwards_every_hook_the_transforms_do_not_change() {
446 let probe = crate::forwarding::HookSource::default();
447 let wrapped =
448 TransformingSource::new(Box::new(probe.clone()), vec![], Labels::for_named("test"))
449 .unwrap();
450 crate::forwarding::assert_source_forwards_every_hook(
451 &wrapped,
452 &probe,
453 &[
454 "fetch_all",
455 "fetch_all_incremental",
456 "native_output_formats",
457 "stream_native",
458 "range_digest",
459 ],
460 )
461 .await;
462 assert!(wrapped.native_output_formats().is_empty());
463 }
464
465 #[derive(Default)]
466 struct ShardedSource {
467 applied: std::sync::Mutex<Vec<String>>,
468 }
469
470 #[async_trait]
471 impl Source for ShardedSource {
472 async fn fetch_with_context(
473 &self,
474 _ctx: &HashMap<String, serde_json::Value>,
475 ) -> Result<Vec<serde_json::Value>, FaucetError> {
476 Ok(vec![])
477 }
478 fn is_shardable(&self) -> bool {
479 true
480 }
481 async fn enumerate_shards(
482 &self,
483 target: usize,
484 ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
485 Ok((0..target)
486 .map(|i| crate::shard::ShardSpec::new(format!("s{i}"), serde_json::json!(i)))
487 .collect())
488 }
489 async fn apply_shard(&self, shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
490 self.applied.lock().unwrap().push(shard.id.clone());
491 Ok(())
492 }
493 async fn range_digest(
494 &self,
495 _range: &crate::diff::KeyRange,
496 _key: &str,
497 _columns: &[String],
498 ) -> Result<Option<crate::diff::ServerDigest>, FaucetError> {
499 Err(FaucetError::Source(
500 "a raw-data digest must not be consulted".into(),
501 ))
502 }
503 fn supports_discover(&self) -> bool {
504 true
505 }
506 async fn discover(&self) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
507 Ok(vec![crate::discover::DatasetDescriptor::new(
508 "orders",
509 "table",
510 serde_json::json!({}),
511 )])
512 }
513 }
514
515 #[tokio::test]
516 async fn shard_and_discover_hooks_reach_the_inner_source() {
517 let inner = Arc::new(ShardedSource::default());
518 struct Shared(Arc<ShardedSource>);
519 #[async_trait]
520 impl Source for Shared {
521 async fn fetch_with_context(
522 &self,
523 ctx: &HashMap<String, serde_json::Value>,
524 ) -> Result<Vec<serde_json::Value>, FaucetError> {
525 self.0.fetch_with_context(ctx).await
526 }
527 fn is_shardable(&self) -> bool {
528 self.0.is_shardable()
529 }
530 async fn enumerate_shards(
531 &self,
532 target: usize,
533 ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
534 self.0.enumerate_shards(target).await
535 }
536 async fn apply_shard(
537 &self,
538 shard: &crate::shard::ShardSpec,
539 ) -> Result<(), FaucetError> {
540 self.0.apply_shard(shard).await
541 }
542 async fn range_digest(
543 &self,
544 range: &crate::diff::KeyRange,
545 key: &str,
546 columns: &[String],
547 ) -> Result<Option<crate::diff::ServerDigest>, FaucetError> {
548 self.0.range_digest(range, key, columns).await
549 }
550 fn supports_discover(&self) -> bool {
551 self.0.supports_discover()
552 }
553 async fn discover(
554 &self,
555 ) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
556 self.0.discover().await
557 }
558 }
559 let wrapped = TransformingSource::new(
560 Box::new(Shared(inner.clone())),
561 vec![],
562 Labels::for_named("t"),
563 )
564 .unwrap();
565 assert!(wrapped.is_shardable());
566 let shards = wrapped.enumerate_shards(3).await.unwrap();
567 assert_eq!(shards.len(), 3);
568 wrapped.apply_shard(&shards[1]).await.unwrap();
569 assert_eq!(*inner.applied.lock().unwrap(), vec!["s1".to_string()]);
570 assert!(wrapped.supports_discover());
571 assert_eq!(wrapped.discover().await.unwrap()[0].name, "orders");
572 let digest = wrapped
573 .range_digest(&crate::diff::KeyRange::ALL, "id", &[])
574 .await
575 .unwrap();
576 assert!(
577 digest.is_none(),
578 "transformed rows must be compared client-side, never by a raw-data digest"
579 );
580 }
581 use crate::stage::TransformStage;
582 use crate::transform::{KeyCaseMode, RecordTransform};
583 use serde_json::json;
584 use std::sync::Arc;
585 use std::sync::atomic::{AtomicBool, Ordering};
586
587 struct MockSource(Vec<Value>);
588
589 #[async_trait]
590 impl Source for MockSource {
591 async fn fetch_with_context(
592 &self,
593 _ctx: &HashMap<String, Value>,
594 ) -> Result<Vec<Value>, FaucetError> {
595 Ok(self.0.clone())
596 }
597 }
598
599 #[tokio::test]
600 async fn fetch_with_context_transforms_records() {
601 let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
602 let wrapped = TransformingSource::new(
603 inner,
604 vec![TransformStage::Map(RecordTransform::KeysCase {
605 mode: KeyCaseMode::Snake,
606 on_collision: Default::default(),
607 suffix_keys: Vec::new(),
608 })],
609 Labels::for_named("test"),
610 )
611 .expect("compile succeeds");
612 let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
613 assert_eq!(out, vec![json!({"foo_bar": 1})]);
614 }
615
616 struct VersionedSource;
617
618 #[async_trait]
619 impl Source for VersionedSource {
620 async fn fetch_with_context(
621 &self,
622 _ctx: &HashMap<String, Value>,
623 ) -> Result<Vec<Value>, FaucetError> {
624 Ok(Vec::new())
625 }
626 fn state_schema(&self) -> u32 {
627 2
628 }
629 fn migrate_state(&self, from: u32, data: Value) -> Result<Value, FaucetError> {
630 Ok(json!({"from": from, "data": data}))
631 }
632 }
633
634 #[test]
635 fn state_versioning_is_forwarded_to_the_inner_source() {
636 let wrapped =
637 TransformingSource::new(Box::new(VersionedSource), vec![], Labels::for_named("test"))
638 .expect("compile succeeds");
639 assert_eq!(wrapped.state_schema(), 2);
640 assert_eq!(
641 wrapped.migrate_state(1, json!("x")).unwrap(),
642 json!({"from": 1, "data": "x"})
643 );
644 }
645
646 struct IncrementalSource {
647 records: Vec<Value>,
648 bookmark: Value,
649 }
650
651 #[async_trait]
652 impl Source for IncrementalSource {
653 async fn fetch_with_context(
654 &self,
655 _ctx: &HashMap<String, Value>,
656 ) -> Result<Vec<Value>, FaucetError> {
657 Ok(self.records.clone())
658 }
659
660 async fn fetch_with_context_incremental(
661 &self,
662 _ctx: &HashMap<String, Value>,
663 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
664 Ok((self.records.clone(), Some(self.bookmark.clone())))
665 }
666 }
667
668 #[tokio::test]
669 async fn fetch_with_context_incremental_transforms_and_preserves_bookmark() {
670 let inner: Box<dyn Source> = Box::new(IncrementalSource {
671 records: vec![json!({"FooBar": 1})],
672 bookmark: json!("2026-05-28T00:00:00Z"),
673 });
674 let wrapped = TransformingSource::new(
675 inner,
676 vec![TransformStage::Map(RecordTransform::KeysCase {
677 mode: KeyCaseMode::Snake,
678 on_collision: Default::default(),
679 suffix_keys: Vec::new(),
680 })],
681 Labels::for_named("test"),
682 )
683 .unwrap();
684 let (records, bookmark) = wrapped
685 .fetch_with_context_incremental(&HashMap::new())
686 .await
687 .unwrap();
688 assert_eq!(records, vec![json!({"foo_bar": 1})]);
689 assert_eq!(bookmark, Some(json!("2026-05-28T00:00:00Z")));
690 }
691
692 struct PagedSource {
697 pages: Vec<Vec<Value>>,
698 final_bookmark: Value,
699 }
700
701 #[async_trait]
702 impl Source for PagedSource {
703 async fn fetch_with_context(
704 &self,
705 _ctx: &HashMap<String, Value>,
706 ) -> Result<Vec<Value>, FaucetError> {
707 Ok(self.pages.iter().flatten().cloned().collect())
708 }
709
710 fn stream_pages<'a>(
711 &'a self,
712 _ctx: &'a HashMap<String, Value>,
713 _batch_size: usize,
714 ) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>
715 {
716 let pages = self.pages.clone();
717 let bookmark = self.final_bookmark.clone();
718 Box::pin(async_stream::try_stream! {
719 let n = pages.len();
720 for (i, records) in pages.into_iter().enumerate() {
721 let bm = if i + 1 == n { Some(bookmark.clone()) } else { None };
722 yield StreamPage { records, bookmark: bm };
723 }
724 })
725 }
726 }
727
728 #[tokio::test]
729 async fn stream_pages_transforms_each_page_and_preserves_bookmarks() {
730 let inner: Box<dyn Source> = Box::new(PagedSource {
731 pages: vec![
732 vec![json!({"FooBar": 1})],
733 vec![json!({"FooBar": 2})],
734 vec![json!({"FooBar": 3})],
735 ],
736 final_bookmark: json!("v1"),
737 });
738 let wrapped = TransformingSource::new(
739 inner,
740 vec![TransformStage::Map(RecordTransform::KeysCase {
741 mode: KeyCaseMode::Snake,
742 on_collision: Default::default(),
743 suffix_keys: Vec::new(),
744 })],
745 Labels::for_named("test"),
746 )
747 .unwrap();
748
749 let ctx = HashMap::new();
750 let mut stream = wrapped.stream_pages(&ctx, 1000);
751 let mut collected: Vec<StreamPage> = Vec::new();
752 while let Some(page) = stream.next().await {
753 collected.push(page.unwrap());
754 }
755
756 assert_eq!(collected.len(), 3);
757 assert_eq!(collected[0].records, vec![json!({"foo_bar": 1})]);
758 assert!(collected[0].bookmark.is_none());
759 assert_eq!(collected[1].records, vec![json!({"foo_bar": 2})]);
760 assert!(collected[1].bookmark.is_none());
761 assert_eq!(collected[2].records, vec![json!({"foo_bar": 3})]);
762 assert_eq!(collected[2].bookmark, Some(json!("v1")));
763 }
764
765 #[tokio::test]
773 async fn stream_pages_does_not_rechunk_large_page_below_inner_size() {
774 let big: Vec<Value> = (0..2500).map(|i| json!({"FooBar": i})).collect();
775 let inner: Box<dyn Source> = Box::new(PagedSource {
776 pages: vec![big],
777 final_bookmark: json!("v1"),
778 });
779 let wrapped = TransformingSource::new(
780 inner,
781 vec![TransformStage::Map(RecordTransform::KeysCase {
782 mode: KeyCaseMode::Snake,
783 on_collision: Default::default(),
784 suffix_keys: Vec::new(),
785 })],
786 Labels::for_named("t"),
787 )
788 .unwrap();
789 let ctx = HashMap::new();
790 let mut stream = wrapped.stream_pages(&ctx, 1000);
792 let mut pages: Vec<StreamPage> = Vec::new();
793 while let Some(p) = stream.next().await {
794 pages.push(p.unwrap());
795 }
796 assert_eq!(pages.len(), 1, "one inner page must stay one page");
797 assert_eq!(pages[0].records.len(), 2500);
798 assert_eq!(pages[0].records[0], json!({"foo_bar": 0}));
799 assert_eq!(pages[0].bookmark, Some(json!("v1")));
800 }
801
802 #[tokio::test]
803 async fn stream_pages_passes_through_empty_records_page_with_bookmark() {
804 struct EmptyWithBookmark;
805 #[async_trait]
806 impl Source for EmptyWithBookmark {
807 async fn fetch_with_context(
808 &self,
809 _ctx: &HashMap<String, Value>,
810 ) -> Result<Vec<Value>, FaucetError> {
811 Ok(Vec::new())
812 }
813 fn stream_pages<'a>(
814 &'a self,
815 _ctx: &'a HashMap<String, Value>,
816 _batch_size: usize,
817 ) -> Pin<
818 Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
819 > {
820 Box::pin(async_stream::try_stream! {
821 yield StreamPage { records: Vec::new(), bookmark: Some(json!("v1")) };
822 })
823 }
824 }
825 let wrapped = TransformingSource::new(
826 Box::new(EmptyWithBookmark),
827 vec![TransformStage::Map(RecordTransform::KeysCase {
828 mode: KeyCaseMode::Snake,
829 on_collision: Default::default(),
830 suffix_keys: Vec::new(),
831 })],
832 Labels::for_named("test"),
833 )
834 .unwrap();
835 let ctx = HashMap::new();
836 let mut stream = wrapped.stream_pages(&ctx, 1000);
837 let page = stream.next().await.unwrap().unwrap();
838 assert!(page.records.is_empty());
839 assert_eq!(page.bookmark, Some(json!("v1")));
840 assert!(stream.next().await.is_none());
841 }
842
843 struct InstrumentedSource {
844 started: Arc<AtomicBool>,
845 }
846
847 #[async_trait]
848 impl Source for InstrumentedSource {
849 async fn fetch_with_context(
850 &self,
851 _ctx: &HashMap<String, Value>,
852 ) -> Result<Vec<Value>, FaucetError> {
853 Ok(vec![])
854 }
855 fn connector_name(&self) -> &'static str {
856 "instrumented"
857 }
858 fn state_key(&self) -> Option<String> {
859 Some("instrumented::key".to_string())
860 }
861 async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
862 self.started.store(true, Ordering::Relaxed);
863 Ok(())
864 }
865 fn supports_exactly_once(&self) -> bool {
866 true
867 }
868 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
869 Ok(Some(json!("captured")))
870 }
871 }
872
873 struct RecorderProbe(Arc<AtomicBool>);
874
875 #[async_trait]
876 impl Source for RecorderProbe {
877 async fn fetch_with_context(
878 &self,
879 _ctx: &HashMap<String, Value>,
880 ) -> Result<Vec<Value>, FaucetError> {
881 Ok(vec![])
882 }
883 fn set_roundtrip_recorder(&self, _recorder: Arc<crate::observability::RoundtripRecorder>) {
884 self.0.store(true, Ordering::Relaxed);
885 }
886 fn set_run_clock(&self, _now: chrono::DateTime<chrono::Utc>) {
887 self.0.store(true, Ordering::Relaxed);
888 }
889 fn set_cancel(&self, _cancel: tokio_util::sync::CancellationToken) -> bool {
890 self.0.store(true, Ordering::Relaxed);
891 true
892 }
893 }
894
895 #[test]
896 fn cancel_reaches_the_wrapped_source_and_its_answer_comes_back() {
897 let got = Arc::new(AtomicBool::new(false));
898 let wrapped = TransformingSource::new(
899 Box::new(RecorderProbe(got.clone())),
900 vec![],
901 Labels::for_named("test"),
902 )
903 .unwrap();
904 assert!(wrapped.set_cancel(tokio_util::sync::CancellationToken::new()));
905 assert!(got.load(Ordering::Relaxed));
906 }
907
908 #[test]
909 fn run_clock_reaches_the_wrapped_source() {
910 let got = Arc::new(AtomicBool::new(false));
911 let wrapped = TransformingSource::new(
912 Box::new(RecorderProbe(got.clone())),
913 vec![],
914 Labels::for_named("test"),
915 )
916 .unwrap();
917 wrapped.set_run_clock(chrono::Utc::now());
918 assert!(got.load(Ordering::Relaxed));
919 }
920
921 #[test]
922 fn roundtrip_recorder_reaches_the_wrapped_source() {
923 let got = Arc::new(AtomicBool::new(false));
924 let wrapped = TransformingSource::new(
925 Box::new(RecorderProbe(got.clone())),
926 vec![],
927 Labels::for_named("test"),
928 )
929 .unwrap();
930 wrapped.set_roundtrip_recorder(Arc::new(crate::observability::RoundtripRecorder::new(
931 crate::observability::RoundtripSide::Source,
932 "p",
933 "r",
934 "probe",
935 )));
936 assert!(got.load(Ordering::Relaxed));
937 }
938
939 #[tokio::test]
940 async fn connector_name_state_key_and_start_bookmark_delegate_to_inner() {
941 let started = Arc::new(AtomicBool::new(false));
942 let inner = InstrumentedSource {
943 started: started.clone(),
944 };
945 let wrapped = TransformingSource::new(
946 Box::new(inner),
947 vec![TransformStage::Map(RecordTransform::KeysCase {
948 mode: KeyCaseMode::Snake,
949 on_collision: Default::default(),
950 suffix_keys: Vec::new(),
951 })],
952 Labels::for_named("test"),
953 )
954 .unwrap();
955 assert_eq!(wrapped.connector_name(), "instrumented");
956 assert_eq!(wrapped.state_key(), Some("instrumented::key".to_string()));
957 wrapped.apply_start_bookmark(json!("bm")).await.unwrap();
958 assert!(started.load(Ordering::Relaxed));
959 assert!(wrapped.supports_exactly_once());
962 assert!(!wrapped.consumes_destructively());
963 assert_eq!(
964 wrapped.replay_guarantee(),
965 crate::idempotency::ReplayGuarantee::Deterministic
966 );
967 assert_eq!(
968 wrapped.capture_resume_position().await.unwrap(),
969 Some(json!("captured"))
970 );
971 assert_eq!(wrapped.lag().await.unwrap(), None);
972 }
973
974 #[tokio::test]
975 async fn new_fails_fast_on_invalid_regex() {
976 let inner: Box<dyn Source> = Box::new(MockSource(vec![]));
977 let result = TransformingSource::new(
978 inner,
979 vec![TransformStage::Map(RecordTransform::RenameKeys {
980 pattern: "[invalid".to_string(),
981 replacement: "x".to_string(),
982 })],
983 Labels::for_named("test"),
984 );
985 let err = match result {
986 Ok(_) => panic!("invalid regex must fail at new()"),
987 Err(e) => e,
988 };
989 assert!(matches!(err, FaucetError::Transform(_)));
990 }
991
992 #[tokio::test]
993 async fn custom_closure_transform_runs() {
994 let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"x": 1})]));
995 let wrapped = TransformingSource::new(
996 inner,
997 vec![TransformStage::Map(RecordTransform::custom(
998 |mut record| {
999 if let Some(obj) = record.as_object_mut() {
1000 obj.insert("added".to_string(), json!(true));
1001 }
1002 record
1003 },
1004 ))],
1005 Labels::for_named("test"),
1006 )
1007 .unwrap();
1008 let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
1009 assert_eq!(out, vec![json!({"x": 1, "added": true})]);
1010 }
1011
1012 #[tokio::test]
1013 async fn usable_as_boxed_dyn_source() {
1014 let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
1015 let wrapped: Box<dyn Source> = Box::new(
1016 TransformingSource::new(
1017 inner,
1018 vec![TransformStage::Map(RecordTransform::KeysCase {
1019 mode: KeyCaseMode::Snake,
1020 on_collision: Default::default(),
1021 suffix_keys: Vec::new(),
1022 })],
1023 Labels::for_named("test"),
1024 )
1025 .unwrap(),
1026 );
1027 let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
1028 assert_eq!(out, vec![json!({"foo_bar": 1})]);
1029 }
1030
1031 #[allow(dead_code)]
1036 struct OnePageSource {
1037 records: Vec<Value>,
1038 bookmark: Option<Value>,
1039 }
1040
1041 #[async_trait]
1042 impl Source for OnePageSource {
1043 async fn fetch_with_context(
1044 &self,
1045 _ctx: &HashMap<String, Value>,
1046 ) -> Result<Vec<Value>, FaucetError> {
1047 Ok(self.records.clone())
1048 }
1049 async fn fetch_with_context_incremental(
1050 &self,
1051 _ctx: &HashMap<String, Value>,
1052 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1053 Ok((self.records.clone(), self.bookmark.clone()))
1054 }
1055 fn stream_pages<'a>(
1056 &'a self,
1057 _ctx: &'a HashMap<String, Value>,
1058 _batch_size: usize,
1059 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
1060 let page = StreamPage {
1061 records: self.records.clone(),
1062 bookmark: self.bookmark.clone(),
1063 };
1064 Box::pin(async_stream::stream! { yield Ok(page); })
1065 }
1066 }
1067
1068 #[cfg(feature = "transform-explode")]
1069 fn explode_stage() -> TransformStage {
1070 TransformStage::Explode(crate::stage::ExplodeSpec {
1071 path: "items".to_owned(),
1072 prefix: None,
1073 separator: "_".to_owned(),
1074 on_missing: crate::stage::OnMissing::Drop,
1075 })
1076 }
1077
1078 #[cfg(feature = "transform-explode")]
1080 fn explode_10x_records(n: usize) -> Vec<Value> {
1081 (0..n)
1082 .map(|i| {
1083 json!({
1084 "id": i,
1085 "items": (0..10).map(|j| json!({"k": j})).collect::<Vec<_>>(),
1086 })
1087 })
1088 .collect()
1089 }
1090
1091 #[cfg(feature = "transform-explode")]
1092 #[tokio::test]
1093 async fn stream_pages_never_splits_a_bookmark_carrying_page() {
1094 let inner: Box<dyn Source> = Box::new(OnePageSource {
1095 records: explode_10x_records(100), bookmark: Some(json!("bm")),
1097 });
1098 let wrapped =
1099 TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1100 let ctx = HashMap::new();
1101 let mut stream = wrapped.stream_pages(&ctx, 200);
1102 let mut sub_pages: Vec<StreamPage> = Vec::new();
1103 while let Some(p) = stream.next().await {
1104 sub_pages.push(p.unwrap());
1105 }
1106 assert_eq!(
1107 sub_pages.len(),
1108 1,
1109 "a bookmarked page stays one commit unit"
1110 );
1111 assert_eq!(sub_pages[0].records.len(), 1000);
1112 assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1113 }
1114
1115 #[cfg(feature = "transform-explode")]
1116 #[tokio::test]
1117 async fn stream_pages_rechunks_a_grown_page_without_a_bookmark() {
1118 let inner: Box<dyn Source> = Box::new(OnePageSource {
1119 records: explode_10x_records(100),
1120 bookmark: None,
1121 });
1122 let wrapped =
1123 TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1124 let ctx = HashMap::new();
1125 let mut stream = wrapped.stream_pages(&ctx, 200);
1126 let mut sub_pages: Vec<StreamPage> = Vec::new();
1127 while let Some(p) = stream.next().await {
1128 sub_pages.push(p.unwrap());
1129 }
1130 assert_eq!(sub_pages.len(), 5, "1000 records / 200 batch = 5 sub-pages");
1131 assert!(sub_pages.iter().all(|p| p.records.len() == 200));
1132 assert!(sub_pages.iter().all(|p| p.bookmark.is_none()));
1133 }
1134
1135 #[cfg(feature = "transform-explode")]
1136 #[tokio::test]
1137 async fn stream_pages_batch_size_zero_emits_one_page() {
1138 let inner: Box<dyn Source> = Box::new(OnePageSource {
1139 records: explode_10x_records(10), bookmark: Some(json!("bm")),
1141 });
1142 let wrapped =
1143 TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1144 let ctx = HashMap::new();
1145 let mut stream = wrapped.stream_pages(&ctx, 0);
1146 let mut sub_pages: Vec<StreamPage> = Vec::new();
1147 while let Some(p) = stream.next().await {
1148 sub_pages.push(p.unwrap());
1149 }
1150 assert_eq!(sub_pages.len(), 1, "batch_size=0 means one sub-page");
1151 assert_eq!(sub_pages[0].records.len(), 100);
1152 assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1153 }
1154
1155 #[cfg(feature = "transform-filter")]
1156 #[tokio::test]
1157 async fn stream_pages_filter_drops_all_still_yields_bookmark() {
1158 let inner: Box<dyn Source> = Box::new(OnePageSource {
1159 records: vec![json!({"deleted": true}), json!({"deleted": true})],
1160 bookmark: Some(json!("bm")),
1161 });
1162 let drop_all = TransformStage::Filter(crate::stage::FilterSpec {
1163 path: "deleted".to_owned(),
1164 op: crate::stage::FilterOp::Ne,
1165 value: Some(json!(true)),
1166 });
1167 let wrapped =
1168 TransformingSource::new(inner, vec![drop_all], Labels::for_named("t")).unwrap();
1169 let ctx = HashMap::new();
1170 let mut stream = wrapped.stream_pages(&ctx, 100);
1171 let mut sub_pages: Vec<StreamPage> = Vec::new();
1172 while let Some(p) = stream.next().await {
1173 sub_pages.push(p.unwrap());
1174 }
1175 assert_eq!(sub_pages.len(), 1);
1176 assert!(sub_pages[0].records.is_empty());
1177 assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1178 }
1179}
1180
1181#[cfg(all(test, feature = "arrow"))]
1182mod columnar_tests {
1183 use super::*;
1184 use crate::columnar::{ColumnarPage, record_batch_to_values, values_to_record_batch_inferred};
1185 use crate::stage::TransformStage;
1186 use serde_json::json;
1187 use std::sync::Arc;
1188
1189 struct ColumnarMock(Vec<Value>);
1191 #[async_trait]
1192 impl Source for ColumnarMock {
1193 async fn fetch_with_context(
1194 &self,
1195 _ctx: &HashMap<String, Value>,
1196 ) -> Result<Vec<Value>, FaucetError> {
1197 Ok(self.0.clone())
1198 }
1199 fn supports_columnar(&self) -> bool {
1200 true
1201 }
1202 fn stream_batches<'a>(
1203 &'a self,
1204 _ctx: &'a HashMap<String, Value>,
1205 _bs: usize,
1206 ) -> Pin<Box<dyn Stream<Item = Result<ColumnarPage, FaucetError>> + Send + 'a>> {
1207 let batch = values_to_record_batch_inferred(&self.0).unwrap();
1208 Box::pin(async_stream::stream! {
1209 yield Ok(ColumnarPage { batch, bookmark: Some(json!("bm")) });
1210 })
1211 }
1212 }
1213
1214 struct RowOnlyMock(Vec<Value>);
1216 #[async_trait]
1217 impl Source for RowOnlyMock {
1218 async fn fetch_with_context(
1219 &self,
1220 _ctx: &HashMap<String, Value>,
1221 ) -> Result<Vec<Value>, FaucetError> {
1222 Ok(self.0.clone())
1223 }
1224 }
1225
1226 fn identity_stage() -> (TransformStage, Option<crate::stage::PageFnBatchBox>) {
1229 let rows: crate::stage::PageFnBox = Arc::new(Ok);
1230 let batch: crate::stage::PageFnBatchBox = Arc::new(Ok);
1231 (TransformStage::PageFn(rows), Some(batch))
1232 }
1233
1234 #[tokio::test]
1235 async fn columnar_inner_plus_columnar_stage_is_supported_and_streams() {
1236 let inner: Box<dyn Source> =
1237 Box::new(ColumnarMock(vec![json!({"id": 1}), json!({"id": 2})]));
1238 let (stage, batch) = identity_stage();
1239 let wrapped = TransformingSource::new_with_batches(
1240 inner,
1241 vec![stage],
1242 vec![batch],
1243 Labels::for_named("t"),
1244 )
1245 .unwrap();
1246 assert!(wrapped.supports_columnar());
1247 let ctx = HashMap::new();
1248 let mut s = wrapped.stream_batches(&ctx, 0);
1249 let page = s.next().await.unwrap().unwrap();
1250 let rows = record_batch_to_values(&page.batch).unwrap();
1251 assert_eq!(rows.len(), 2);
1252 assert_eq!(page.bookmark, Some(json!("bm")));
1253 }
1254
1255 #[tokio::test]
1256 async fn columnar_path_feeds_row_accounts() {
1257 let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![
1258 json!({"id": 1}),
1259 json!({"id": 2}),
1260 json!({"id": 3}),
1261 ]));
1262 let rows: crate::stage::PageFnBox = Arc::new(Ok);
1263 let keep_first: crate::stage::PageFnBatchBox = Arc::new(|b| Ok(b.slice(0, 1)));
1264 let accounts = Arc::new(crate::accounting::RowAccounts::default());
1265 let wrapped = TransformingSource::new_with_batches(
1266 inner,
1267 vec![TransformStage::PageFn(rows)],
1268 vec![Some(keep_first)],
1269 Labels::for_named("t"),
1270 )
1271 .unwrap()
1272 .with_row_accounts(Arc::clone(&accounts));
1273 let ctx = HashMap::new();
1274 let mut s = wrapped.stream_batches(&ctx, 0);
1275 while let Some(p) = s.next().await {
1276 p.unwrap();
1277 }
1278 let snap = accounts.snapshot();
1279 assert_eq!((snap.transform_in, snap.transform_out), (3, 1));
1280 assert_eq!(snap.filtered(), 2);
1281 }
1282
1283 #[tokio::test]
1284 async fn value_only_stage_disables_columnar() {
1285 let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![json!({"FooBar": 1})]));
1288 let wrapped = TransformingSource::new(
1289 inner,
1290 vec![TransformStage::Map(
1291 crate::transform::RecordTransform::KeysCase {
1292 mode: crate::transform::KeyCaseMode::Snake,
1293 on_collision: crate::transform::KeyCollision::Error,
1294 suffix_keys: Vec::new(),
1295 },
1296 )],
1297 Labels::for_named("t"),
1298 )
1299 .unwrap();
1300 assert!(!wrapped.supports_columnar());
1301 }
1302
1303 #[cfg(all(feature = "transform-drop", feature = "transform-set"))]
1308 #[tokio::test]
1309 async fn built_in_vectorizable_transforms_keep_the_columnar_path() {
1310 use crate::transform::RecordTransform;
1311 let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![
1312 json!({"id": 1, "name": "ada", "secret": "x"}),
1313 json!({"id": 2, "name": "grace", "secret": "y"}),
1314 ]));
1315 let mut set_vals = serde_json::Map::new();
1316 set_vals.insert("stage".into(), json!("prod"));
1317 let wrapped = TransformingSource::new(
1318 inner,
1319 vec![
1320 TransformStage::Map(RecordTransform::Drop {
1321 fields: vec!["secret".into()],
1322 }),
1323 TransformStage::Map(RecordTransform::Set { values: set_vals }),
1324 ],
1325 Labels::for_named("t"),
1326 )
1327 .unwrap();
1328 assert!(
1329 wrapped.supports_columnar(),
1330 "a chain of vectorizable built-ins must stay columnar"
1331 );
1332 let ctx = HashMap::new();
1333 let mut s = wrapped.stream_batches(&ctx, 0);
1334 let page = s.next().await.unwrap().unwrap();
1335 let rows = record_batch_to_values(&page.batch).unwrap();
1336 assert_eq!(rows.len(), 2);
1337 assert!(rows[0].get("secret").is_none(), "drop ran: {:?}", rows[0]);
1340 assert_eq!(rows[0]["stage"], json!("prod"), "set ran: {:?}", rows[0]);
1341 }
1342
1343 #[cfg(all(feature = "transform-select", feature = "transform-flatten"))]
1347 #[tokio::test]
1348 async fn a_mixed_chain_with_one_opaque_transform_stays_on_value() {
1349 use crate::transform::RecordTransform;
1350 let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![json!({"id": 1})]));
1351 let wrapped = TransformingSource::new(
1352 inner,
1353 vec![
1354 TransformStage::Map(RecordTransform::Select {
1355 fields: vec!["id".into()],
1356 }),
1357 TransformStage::Map(RecordTransform::Flatten {
1359 separator: "_".into(),
1360 }),
1361 ],
1362 Labels::for_named("t"),
1363 )
1364 .unwrap();
1365 assert!(!wrapped.supports_columnar());
1366 }
1367
1368 #[tokio::test]
1369 async fn columnar_stage_over_row_only_inner_is_disabled() {
1370 let inner: Box<dyn Source> = Box::new(RowOnlyMock(vec![json!({"id": 1})]));
1371 let (stage, batch) = identity_stage();
1372 let wrapped = TransformingSource::new_with_batches(
1373 inner,
1374 vec![stage],
1375 vec![batch],
1376 Labels::for_named("t"),
1377 )
1378 .unwrap();
1379 assert!(!wrapped.supports_columnar(), "inner is not columnar");
1380 }
1381
1382 #[tokio::test]
1383 async fn a_reshaping_stage_marks_the_declaration_reshaped() {
1384 struct Declares;
1385 #[async_trait::async_trait]
1386 impl crate::Source for Declares {
1387 async fn fetch_with_context(
1388 &self,
1389 _ctx: &HashMap<String, Value>,
1390 ) -> Result<Vec<Value>, FaucetError> {
1391 Ok(vec![])
1392 }
1393 async fn declared_columns(
1394 &self,
1395 _ctx: &HashMap<String, Value>,
1396 ) -> Result<Option<crate::DeclaredColumns>, FaucetError> {
1397 Ok(Some(crate::DeclaredColumns::new([
1398 crate::DeclaredColumn::new("a", crate::LogicalType::Int64, true),
1399 ])))
1400 }
1401 }
1402 let ctx = HashMap::new();
1403 let none =
1404 TransformingSource::new(Box::new(Declares), vec![], Labels::for_named("t")).unwrap();
1405 let d = none.declared_columns(&ctx).await.unwrap().unwrap();
1406 assert!(d.is_complete());
1407 let mapped = TransformingSource::new(
1408 Box::new(Declares),
1409 vec![TransformStage::Custom(std::sync::Arc::new(|v| vec![v]))],
1410 Labels::for_named("t"),
1411 )
1412 .unwrap();
1413 let d = mapped.declared_columns(&ctx).await.unwrap().unwrap();
1414 assert!(!d.is_complete());
1415 assert!(d.get("a").is_some());
1416 let plain = TransformingSource::new(
1417 Box::new(super::tests::OnePage),
1418 vec![],
1419 Labels::for_named("t"),
1420 )
1421 .unwrap();
1422 assert!(plain.declared_columns(&ctx).await.unwrap().is_none());
1423 }
1424}