1use crate::config::{DynamoDbSourceConfig, OnGap, ReadMode};
7use crate::envelope::snapshot_envelope;
8use crate::lag::{LagTracker, now_ms, probe_starts};
9use crate::lineage::{
10 Planner, capture_bookmark, detect_gaps, ids_and_parents, stale_empty_shards, start_for,
11 unverified_empty_shards,
12};
13use crate::scan::{ScanEvent, expressions, run_segment_in};
14use crate::sched::Scheduler;
15use crate::state::{ScanBookmark, SegmentCursor, StreamBookmark, state_key};
16use crate::streams::{
17 IteratorOutcome, ShardEvent, acquire_iterator_in, describe_shards_in, peek_oldest_in,
18 read_slice_in,
19};
20use aws_sdk_dynamodb::Client;
21use aws_sdk_dynamodb::types::TableDescription;
22use aws_sdk_dynamodbstreams::Client as StreamsClient;
23use faucet_common_dynamodb::{KeyAttribute, key_of, key_schema, sdk_error_parts};
24use faucet_core::io::{CallContext, CallSlot};
25use faucet_core::{FaucetError, ShardSpec, Stream, StreamPage};
26use serde_json::{Value, json};
27use std::collections::{BTreeMap, HashMap};
28use std::pin::Pin;
29use std::sync::{Arc, Mutex};
30use std::time::{Duration, Instant};
31
32type PageStream<'a> = Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>;
33
34pub struct DynamoDbSource {
36 config: DynamoDbSourceConfig,
37 client: Client,
38 streams: StreamsClient,
39 start_bookmark: Mutex<Option<Value>>,
40 shard: Mutex<Option<(u32, u32)>>,
41 lag: Mutex<LagTracker>,
42 lag_warned: std::sync::atomic::AtomicBool,
43 calls: CallSlot,
44 complete_read: faucet_core::ReadCompleteness,
45}
46
47pub(crate) fn parse_segment_shard(spec: &ShardSpec) -> Result<(u32, u32), FaucetError> {
49 let get = |k: &str| {
50 spec.descriptor
51 .get(k)
52 .and_then(Value::as_u64)
53 .and_then(|n| u32::try_from(n).ok())
54 };
55 match (get("segment"), get("total_segments")) {
56 (Some(s), Some(t)) if t > 0 && s < t => Ok((s, t)),
57 _ => Err(FaucetError::Source(format!(
58 "dynamodb: invalid scan shard descriptor {} (expected {{segment, total_segments}})",
59 spec.descriptor
60 ))),
61 }
62}
63
64pub(crate) fn plan_segment_shards(total: u32) -> Vec<ShardSpec> {
66 (0..total)
67 .map(|i| {
68 ShardSpec::new(
69 i.to_string(),
70 json!({"segment": i, "total_segments": total}),
71 )
72 })
73 .collect()
74}
75
76async fn wait_until(calls: &CallContext, at: Option<Instant>) -> Result<(), FaucetError> {
79 match at {
80 Some(t) => {
81 calls
82 .wait(t.saturating_duration_since(Instant::now()))
83 .await
84 }
85 None => std::future::pending().await,
86 }
87}
88
89fn chunk_size(batch_size: usize) -> usize {
90 if batch_size == 0 {
91 usize::MAX
92 } else {
93 batch_size
94 }
95}
96
97impl DynamoDbSource {
98 pub async fn new(config: DynamoDbSourceConfig) -> Result<Self, FaucetError> {
101 config.validate()?;
102 let region = config.region.as_deref();
103 let endpoint = config.endpoint_url.as_deref();
104 let client =
105 faucet_common_dynamodb::build_client(region, endpoint, &config.credentials).await?;
106 let streams =
107 faucet_common_dynamodb::build_streams_client(region, endpoint, &config.credentials)
108 .await?;
109 let complete_read =
110 faucet_core::ReadCompleteness::new().with_max_keys(config.resnapshot_max_keys);
111 Ok(Self {
112 config,
113 client,
114 streams,
115 start_bookmark: Mutex::new(None),
116 shard: Mutex::new(None),
117 lag: Mutex::new(LagTracker::default()),
118 lag_warned: std::sync::atomic::AtomicBool::new(false),
119 calls: CallSlot::new(CallContext::new()),
120 complete_read,
121 })
122 }
123
124 async fn describe_table(&self) -> Result<TableDescription, FaucetError> {
125 let out = self
126 .calls
127 .call("describe_table", || async {
128 Ok(self
129 .client
130 .describe_table()
131 .table_name(&self.config.table_name)
132 .send()
133 .await)
134 })
135 .await?
136 .map_err(|e| {
137 FaucetError::Source(format!(
138 "dynamodb: DescribeTable '{}' failed: {}",
139 self.config.table_name,
140 sdk_error_parts(&e).1
141 ))
142 })?;
143 out.table().cloned().ok_or_else(|| {
144 FaucetError::Source(format!(
145 "dynamodb: DescribeTable '{}' returned no table",
146 self.config.table_name
147 ))
148 })
149 }
150
151 async fn resolve_stream_arn(&self) -> Result<String, FaucetError> {
152 if let Some(arn) = &self.config.stream_arn {
153 return Ok(arn.clone());
154 }
155 let desc = self.describe_table().await?;
156 desc.latest_stream_arn().map(str::to_string).ok_or_else(|| {
157 FaucetError::Source(format!(
158 "dynamodb: table '{}' has no stream — enable DynamoDB Streams \
159 (NEW_AND_OLD_IMAGES) or set stream_arn",
160 self.config.table_name
161 ))
162 })
163 }
164
165 fn start_bookmark(&self) -> Option<Value> {
166 self.start_bookmark
167 .lock()
168 .expect("bookmark mutex poisoned")
169 .clone()
170 }
171
172 fn scan_targets(&self) -> (Vec<u32>, u32) {
174 match *self.shard.lock().expect("shard mutex poisoned") {
175 Some((segment, total)) => (vec![segment], total),
176 None if self.config.mode == ReadMode::Scan => {
177 ((0..self.config.segments).collect(), self.config.segments)
178 }
179 None => (vec![0], 1),
180 }
181 }
182
183 fn scan_stream<'a>(
187 &'a self,
188 targets: Vec<u32>,
189 total: u32,
190 resume: BTreeMap<u32, SegmentCursor>,
191 snapshot: Option<Vec<KeyAttribute>>,
192 emit_cursors: bool,
193 final_bookmark: Value,
194 ) -> PageStream<'a> {
195 let chunk = chunk_size(self.config.batch_size);
196 Box::pin(async_stream::try_stream! {
197 let exprs = expressions(&self.config);
198 let (tx, mut rx) = tokio::sync::mpsc::channel::<ScanEvent>(16);
199 let semaphore = Arc::new(tokio::sync::Semaphore::new(self.config.max_concurrency));
200 let mut cursors: BTreeMap<u32, SegmentCursor> = resume
201 .into_iter()
202 .filter(|(s, _)| targets.contains(s))
203 .collect();
204 let mut handles = Vec::new();
205 for &segment in &targets {
206 let cursor = cursors.get(&segment).cloned();
207 if cursor == Some(SegmentCursor::Done) {
208 continue;
209 }
210 let sem = semaphore.clone();
211 let client = self.client.clone();
212 let config = self.config.clone();
213 let exprs = exprs.clone();
214 let tx = tx.clone();
215 let calls = self.calls.get();
216 handles.push(tokio::spawn(async move {
217 let _permit = sem.acquire_owned().await.expect("semaphore closed");
218 run_segment_in(calls, client, config, exprs, segment, total, cursor, tx)
219 .await;
220 }));
221 }
222 drop(tx);
223
224 let bookmark = |cursors: &BTreeMap<u32, SegmentCursor>| {
225 emit_cursors.then(|| {
226 ScanBookmark { total_segments: total, segments: cursors.clone() }.to_value()
227 })
228 };
229 let mut buffer: Vec<Value> = Vec::new();
230 let mut total_items = 0usize;
231 let mut failure = None;
232 while let Some(event) = rx.recv().await {
233 match event {
234 ScanEvent::Page { segment, items, next } => {
235 for item in items {
236 let record = match &snapshot {
237 Some(keys) => {
238 let key = key_of(&item, keys);
239 snapshot_envelope(item, key, &self.config.table_name)
240 }
241 None => item,
242 };
243 buffer.push(record);
244 total_items += 1;
245 if buffer.len() >= chunk {
246 yield StreamPage {
247 records: std::mem::take(&mut buffer),
248 bookmark: bookmark(&cursors),
249 };
250 }
251 }
252 cursors.insert(segment, next);
253 }
254 ScanEvent::Failed { segment, error } => {
255 tracing::error!(segment, error = %error, "dynamodb: segment failed");
256 failure = Some(error);
257 break;
258 }
259 }
260 }
261 for h in handles {
262 h.abort();
263 }
264 if let Some(error) = failure {
265 Err(error)?;
266 }
267 tracing::info!(table = %self.config.table_name, items = total_items,
268 segments = targets.len(), total_segments = total, "dynamodb scan complete");
269 yield StreamPage { records: buffer, bookmark: Some(final_bookmark) };
270 })
271 }
272
273 async fn probe_lag(&self) -> Result<Option<faucet_core::SourceLag>, FaucetError> {
276 let calls = self.calls.get();
277 let arn = self.resolve_stream_arn().await?;
278 let described = describe_shards_in(&calls, &self.streams, &arn).await?;
279 let bm = self
280 .start_bookmark()
281 .map(|v| StreamBookmark::parse(&v))
282 .transpose()?
283 .unwrap_or_default();
284 let mut tracker = LagTracker::default();
285 for (shard, start) in probe_starts(&bm, &described, self.config.start_position) {
286 match peek_oldest_in(&calls, &self.streams, &self.config, &arn, &shard, &start).await? {
287 Some(ts) => tracker.observe(&shard, ts),
288 None => tracker.caught_up(&shard),
289 }
290 }
291 Ok(tracker.lag(now_ms()))
292 }
293
294 fn resnapshots(&self) -> bool {
298 self.config.mode == ReadMode::Streams
299 && self.config.on_gap == OnGap::Resnapshot
300 && self.config.resnapshot_deletes
301 }
302
303 fn with_lag(&self, f: impl FnOnce(&mut LagTracker)) {
304 if let Ok(mut t) = self.lag.lock() {
305 f(&mut t);
306 }
307 }
308
309 fn cdc_stream<'a>(&'a self) -> PageStream<'a> {
310 let chunk = chunk_size(self.config.batch_size);
311 Box::pin(async_stream::try_stream! {
312 self.with_lag(|t| *t = LagTracker::default());
313 self.complete_read.reset();
314 let calls = self.calls.get();
315 let arn = self.resolve_stream_arn().await?;
316 let described = describe_shards_in(&calls, &self.streams, &arn).await?;
317 let mut bm = self
318 .start_bookmark()
319 .map(|v| StreamBookmark::parse(&v))
320 .transpose()?
321 .unwrap_or_default();
322
323 let mut gaps = detect_gaps(&bm, &arn, &described);
324 let unverified = unverified_empty_shards(&bm, &described);
325 if !unverified.is_empty() {
326 tracing::warn!(
327 connector = "dynamodb",
328 shards = %unverified.join(", "),
329 "the bookmark predates empty-shard verification, so records written to these \
330 shards and trimmed before this run cannot be detected; verification applies \
331 from this run on"
332 );
333 }
334 if gaps.is_empty() {
335 gaps.extend(stale_empty_shards(&bm, &described, crate::state::unix_now()));
336 }
337 if gaps.is_empty() {
338 let (ids, _) = ids_and_parents(&described);
339 for (id, seq) in bm.shards.iter().filter(|(id, s)| !s.is_empty() && ids.contains(*id)) {
340 let start = crate::lineage::StartAt::After(seq.clone());
341 if let IteratorOutcome::Trimmed =
342 acquire_iterator_in(&calls, &self.streams, &arn, id, &start).await?
343 {
344 gaps.push(format!("shard {id} was trimmed past sequence {seq}"));
345 }
346 }
347 }
348 if !gaps.is_empty() {
349 let reasons = gaps.join("; ");
350 match self.config.on_gap {
351 OnGap::Fail => Err(FaucetError::Source(format!(
352 "dynamodb streams: cannot resume table '{}' without losing changes: \
353 {reasons}. Set on_gap: resnapshot to re-read the table, or reset the \
354 pipeline state",
355 self.config.table_name
356 )))?,
357 OnGap::Resnapshot => {
358 if self.resnapshots() {
359 tracing::warn!(table = %self.config.table_name, reasons = %reasons,
360 "dynamodb streams: bookmark is past the trim horizon; \
361 resnapshotting (a complete read: destination rows of items it \
362 does not see are deleted after the run)");
363 } else {
364 tracing::warn!(table = %self.config.table_name, reasons = %reasons,
365 "dynamodb streams: bookmark is past the trim horizon; \
366 resnapshotting without deletes (resnapshot_deletes: false): \
367 destination rows of items deleted during the gap are kept");
368 }
369 let keys = key_schema(&self.describe_table().await?)?;
370 let capture = capture_bookmark(&arn, &described);
371 let mut snap = self.scan_stream(
372 vec![0], 1, BTreeMap::new(), Some(keys), false, capture.to_value(),
373 );
374 self.complete_read.begin();
375 while let Some(page) = futures::StreamExt::next(&mut snap).await {
376 yield page?;
377 }
378 self.complete_read.complete();
379 bm = capture;
380 }
381 }
382 }
383 bm.stream_arn = Some(arn.clone());
384 let (ids, parents) = ids_and_parents(&described);
385 bm.prune_finished(&ids, &parents);
386 let mut sched = Scheduler::new(
387 Planner::new(described, &bm.finished),
388 self.config.shard_concurrency,
389 );
390 let (tx, mut rx) = tokio::sync::mpsc::channel::<ShardEvent>(64);
391 let mut workers = tokio::task::JoinSet::new();
392 let idle = self.config.idle_termination_secs.map(Duration::from_secs);
393 let max_messages = self.config.max_messages;
394 let mut buffer: Vec<Value> = Vec::new();
395 let mut total = 0usize;
396 let mut failure: Option<FaucetError> = None;
397 let mut last_record = Instant::now();
398 let admit = |sched: &mut Scheduler, bm: &mut StreamBookmark| {
399 let now = Instant::now();
400 for id in sched.ready() {
401 let start = start_for(
402 bm.shards.get(&id).map(String::as_str),
403 sched.planner().parent_known(&id),
404 self.config.start_position,
405 );
406 bm.open(&id);
407 sched.admit(&id, start, now);
408 }
409 };
410 admit(&mut sched, &mut bm);
411
412 'consume: loop {
413 while workers.try_join_next().is_some() {}
414 let now = Instant::now();
415 let idle_deadline = idle.map(|w| last_record + w);
416 if idle_deadline.is_some_and(|d| now >= d) {
417 tracing::info!(table = %self.config.table_name,
418 "dynamodb streams: idle termination");
419 break 'consume;
420 }
421 for lease in sched.dispatch(now) {
422 workers.spawn(read_slice_in(
423 calls.clone(),
424 self.streams.clone(),
425 self.config.clone(),
426 arn.clone(),
427 lease,
428 tx.clone(),
429 ));
430 }
431 if sched.is_idle() {
432 break 'consume;
433 }
434 let wakeup = sched.next_wakeup();
435 let woke = tokio::select! {
436 ev = rx.recv() => Ok(Some(ev)),
437 r = wait_until(&calls, wakeup) => r.map(|()| None),
438 r = wait_until(&calls, idle_deadline) => r.map(|()| None),
439 };
440 let Some(event) = woke? else {
441 continue 'consume;
442 };
443 match event {
444 Some(ShardEvent::Records { shard_id, records }) => {
445 if let Some(ts) = records.iter().filter_map(|(_, r)| r["ts_ms"].as_i64()).max() {
446 self.with_lag(|t| t.observe(&shard_id, ts));
447 }
448 for (sequence, record) in records {
449 buffer.push(record);
450 total += 1;
451 bm.advance(&shard_id, &sequence);
452 if buffer.len() >= chunk {
453 yield StreamPage {
454 records: std::mem::take(&mut buffer),
455 bookmark: Some(bm.to_value()),
456 };
457 }
458 if let Some(max) = max_messages
459 && total >= max
460 {
461 tracing::info!(table = %self.config.table_name, max,
462 "dynamodb streams: max_messages reached");
463 break 'consume;
464 }
465 }
466 last_record = Instant::now();
467 }
468 Some(ShardEvent::Yielded { lease, delay, caught_up }) => {
469 if caught_up {
470 self.with_lag(|t| t.caught_up(&lease.shard_id));
471 bm.verify_empty(&lease.shard_id, crate::state::unix_now());
472 }
473 sched.yielded(lease, Instant::now() + delay);
474 }
475 Some(ShardEvent::Done { shard_id }) => {
476 self.with_lag(|t| t.close(&shard_id));
477 bm.finish(&shard_id);
478 sched.finished(&shard_id);
479 if !sched.planner().has_children(&shard_id) {
480 match describe_shards_in(&calls, &self.streams, &arn).await {
481 Ok(fresh) => sched.planner().merge(fresh),
482 Err(e) => tracing::warn!(error = %e,
483 "dynamodb streams: shard refresh failed"),
484 }
485 }
486 admit(&mut sched, &mut bm);
487 }
488 Some(ShardEvent::Failed { shard_id, error }) => {
489 tracing::error!(shard = %shard_id, error = %error,
490 "dynamodb streams: shard worker failed");
491 failure = Some(error);
492 break 'consume;
493 }
494 None => break 'consume,
495 }
496 }
497 rx.close();
498 workers.abort_all();
499 if let Some(error) = failure {
500 Err(error)?;
501 }
502 tracing::info!(table = %self.config.table_name, records = total,
503 "dynamodb streams source complete");
504 yield StreamPage { records: buffer, bookmark: Some(bm.to_value()) };
505 })
506 }
507}
508
509#[faucet_core::async_trait]
510impl faucet_core::Source for DynamoDbSource {
511 async fn fetch_with_context(
512 &self,
513 context: &HashMap<String, Value>,
514 ) -> Result<Vec<Value>, FaucetError> {
515 use futures::StreamExt;
516 let mut pages = self.stream_pages(context, self.config.batch_size);
517 let mut all = Vec::new();
518 while let Some(page) = pages.next().await {
519 all.extend(page?.records);
520 }
521 Ok(all)
522 }
523
524 fn stream_pages<'a>(
525 &'a self,
526 _context: &'a HashMap<String, Value>,
527 _batch_size: usize,
528 ) -> PageStream<'a> {
529 if self.config.mode == ReadMode::Streams {
530 return self.cdc_stream();
531 }
532 let (targets, total) = self.scan_targets();
533 let resume = self
534 .start_bookmark()
535 .map(|v| ScanBookmark::from_value(&v).for_total(total))
536 .unwrap_or_default();
537 self.scan_stream(
538 targets,
539 total,
540 resume,
541 None,
542 true,
543 ScanBookmark::default().to_value(),
544 )
545 }
546
547 fn config_schema(&self) -> Value {
548 serde_json::to_value(faucet_core::schema_for!(DynamoDbSourceConfig))
549 .expect("schema serialization")
550 }
551
552 fn state_key(&self) -> Option<String> {
553 Some(state_key(self.config.mode, &self.config.table_name))
554 }
555
556 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
557 *self.start_bookmark.lock().expect("bookmark mutex poisoned") = Some(bookmark);
558 Ok(())
559 }
560
561 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
565 if self.config.mode != ReadMode::Streams {
566 return Ok(None);
567 }
568 let arn = self.resolve_stream_arn().await?;
569 let described = describe_shards_in(&self.calls.get(), &self.streams, &arn).await?;
570 Ok(Some(capture_bookmark(&arn, &described).to_value()))
571 }
572
573 fn capabilities(&self) -> faucet_core::Capabilities {
574 faucet_core::Capabilities::of_source(self)
575 .to_builder()
576 .reports_lag(self.config.mode == ReadMode::Streams)
577 .table_routing(self.config.mode == ReadMode::Streams)
578 .complete_reads(self.resnapshots())
579 .build()
580 }
581
582 fn read_completeness(&self) -> Option<faucet_core::ReadCompleteness> {
583 self.resnapshots().then(|| self.complete_read.clone())
584 }
585
586 async fn lag(&self) -> Result<Option<faucet_core::SourceLag>, FaucetError> {
593 if self.config.mode != ReadMode::Streams {
594 return Ok(None);
595 }
596 if let Some(lag) = self.lag.lock().ok().and_then(|t| t.lag(now_ms())) {
597 return Ok(Some(lag));
598 }
599 match self.probe_lag().await {
600 Ok(lag) => Ok(lag),
601 Err(e) => {
602 if !self
603 .lag_warned
604 .swap(true, std::sync::atomic::Ordering::Relaxed)
605 {
606 tracing::warn!(error = %e,
607 "dynamodb streams: lag probe failed; lag is not reported (logged once)");
608 }
609 Ok(None)
610 }
611 }
612 }
613
614 fn is_shardable(&self) -> bool {
615 self.config.mode == ReadMode::Scan
616 }
617
618 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
621 if self.config.mode != ReadMode::Scan {
622 return Ok(vec![ShardSpec::whole()]);
623 }
624 let total = if self.config.segments > 1 {
625 self.config.segments
626 } else {
627 u32::try_from(target)
628 .unwrap_or(crate::config::MAX_SEGMENTS)
629 .clamp(1, crate::config::MAX_SEGMENTS)
630 };
631 Ok(plan_segment_shards(total))
632 }
633
634 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
635 if shard.is_whole() {
636 return Ok(());
637 }
638 if self.config.mode != ReadMode::Scan {
639 return Err(FaucetError::Source(format!(
640 "dynamodb: mode {} is not shardable",
641 self.config.mode.as_str()
642 )));
643 }
644 let parsed = parse_segment_shard(shard)?;
645 *self.shard.lock().expect("shard mutex poisoned") = Some(parsed);
646 Ok(())
647 }
648
649 fn supports_discover(&self) -> bool {
650 true
651 }
652
653 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
655 let mut names = Vec::new();
656 let mut start: Option<String> = None;
657 loop {
658 let out = self
659 .calls
660 .call("list_tables", || async {
661 Ok(self
662 .client
663 .list_tables()
664 .set_exclusive_start_table_name(start.clone())
665 .send()
666 .await)
667 })
668 .await?
669 .map_err(|e| {
670 FaucetError::Source(format!(
671 "dynamodb: ListTables failed: {}",
672 sdk_error_parts(&e).1
673 ))
674 })?;
675 names.extend(out.table_names().iter().cloned());
676 start = out.last_evaluated_table_name().map(str::to_string);
677 if start.is_none() {
678 break;
679 }
680 }
681 let mut out = Vec::with_capacity(names.len());
682 for name in names {
683 let desc = self
684 .calls
685 .call("describe_table", || async {
686 Ok(self.client.describe_table().table_name(&name).send().await)
687 })
688 .await?
689 .map_err(|e| {
690 FaucetError::Source(format!(
691 "dynamodb: DescribeTable '{name}' failed: {}",
692 sdk_error_parts(&e).1
693 ))
694 })?;
695 if let Some(d) = desc
696 .table()
697 .and_then(crate::discover::descriptor_from_table)
698 {
699 out.push(d);
700 }
701 }
702 Ok(out)
703 }
704
705 fn connector_name(&self) -> &'static str {
706 "dynamodb"
707 }
708
709 fn set_call_context(&self, ctx: CallContext) {
710 self.calls.install(ctx);
711 }
712
713 fn set_roundtrip_recorder(
714 &self,
715 recorder: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
716 ) {
717 self.calls.install_recorder(recorder);
718 }
719
720 fn record_table(&self, record: &Value) -> Option<String> {
721 stream_record_table(self.config.mode, record)
722 }
723
724 fn dataset_uri(&self) -> String {
725 format!(
726 "dynamodb://{}/{}",
727 self.config.region.as_deref().unwrap_or("default"),
728 self.config.table_name
729 )
730 }
731
732 async fn check(
735 &self,
736 ctx: &faucet_core::CheckContext,
737 ) -> Result<faucet_core::CheckReport, FaucetError> {
738 use faucet_core::{CheckReport, Probe};
739 let start = std::time::Instant::now();
740 let mut report = CheckReport::default();
741 let table = tokio::time::timeout(ctx.timeout, self.describe_table()).await;
742 let stream_arn = match table {
743 Err(_) => {
744 report
745 .probes
746 .push(Probe::fail("describe_table", start.elapsed(), "timed out"));
747 return Ok(report);
748 }
749 Ok(Err(e)) => {
750 report.probes.push(Probe::fail(
751 "describe_table",
752 start.elapsed(),
753 e.to_string(),
754 ));
755 return Ok(report);
756 }
757 Ok(Ok(desc)) => {
758 report
759 .probes
760 .push(Probe::pass("describe_table", start.elapsed()));
761 if self.config.mode == ReadMode::Streams {
762 let view = desc
763 .stream_specification()
764 .and_then(|s| s.stream_view_type());
765 if let Some(problem) = stream_view_problem(view) {
766 report.probes.push(Probe::fail(
767 "stream_view_type",
768 start.elapsed(),
769 problem,
770 ));
771 } else if view.is_some() {
772 report
773 .probes
774 .push(Probe::pass("stream_view_type", start.elapsed()));
775 }
776 }
777 self.config
778 .stream_arn
779 .clone()
780 .or_else(|| desc.latest_stream_arn().map(str::to_string))
781 }
782 };
783 if self.config.mode == ReadMode::Streams {
784 let start = std::time::Instant::now();
785 let probe = match stream_arn {
786 None => Probe::fail(
787 "describe_stream",
788 start.elapsed(),
789 "table has no stream — enable DynamoDB Streams (NEW_AND_OLD_IMAGES)",
790 ),
791 Some(arn) => {
792 let sent = self
793 .calls
794 .get()
795 .with_timeout(ctx.timeout)
796 .call("describe_stream", || async {
797 Ok(self
798 .streams
799 .describe_stream()
800 .stream_arn(&arn)
801 .limit(1)
802 .send()
803 .await)
804 })
805 .await;
806 match sent {
807 Err(FaucetError::Timeout { .. }) => {
808 Probe::fail("describe_stream", start.elapsed(), "timed out")
809 }
810 Err(e) => Probe::fail("describe_stream", start.elapsed(), e.to_string()),
811 Ok(Ok(_)) => Probe::pass("describe_stream", start.elapsed()),
812 Ok(Err(e)) => {
813 Probe::fail("describe_stream", start.elapsed(), sdk_error_parts(&e).1)
814 }
815 }
816 }
817 };
818 report.probes.push(probe);
819 }
820 Ok(report)
821 }
822}
823
824fn stream_view_problem(view: Option<&aws_sdk_dynamodb::types::StreamViewType>) -> Option<String> {
827 use aws_sdk_dynamodb::types::StreamViewType as V;
828 match view {
829 Some(V::NewImage | V::NewAndOldImages) | None => None,
830 Some(other) => Some(format!(
831 "stream view type {} carries no new image, so inserts and updates cannot be \
832 mirrored — set the table's stream to NEW_IMAGE or NEW_AND_OLD_IMAGES",
833 other.as_str()
834 )),
835 }
836}
837
838fn stream_record_table(mode: crate::config::ReadMode, record: &Value) -> Option<String> {
841 if mode != crate::config::ReadMode::Streams {
842 return None;
843 }
844 record.get("table")?.as_str().map(str::to_string)
845}
846
847#[cfg(test)]
848#[allow(clippy::disallowed_methods)] mod tests {
850 #[test]
853 fn the_fidelity_corpus_decodes_unchanged() {
854 use faucet_conformance::fidelity;
855 let sent = fidelity::corpus();
856 let landed: Vec<serde_json::Value> = sent
857 .iter()
858 .map(|r| {
859 let item = faucet_common_dynamodb::json_to_item(r).unwrap();
860 faucet_common_dynamodb::item_to_json(&item).unwrap()
861 })
862 .collect();
863 fidelity::assert_round_trip(
864 &sent,
865 &landed,
866 fidelity::Tolerance::exact().skipping("negative_zero"),
867 );
868 }
869
870 #[test]
871 fn routes_only_stream_envelopes() {
872 use crate::config::ReadMode;
873 let env = serde_json::json!({"op": "c", "table": "orders"});
874 assert_eq!(
875 stream_record_table(ReadMode::Streams, &env),
876 Some("orders".into())
877 );
878 assert_eq!(stream_record_table(ReadMode::Scan, &env), None);
879 assert_eq!(
880 stream_record_table(ReadMode::Streams, &serde_json::json!({})),
881 None
882 );
883 }
884
885 #[test]
886 fn only_views_with_a_new_image_can_feed_a_mirror() {
887 use aws_sdk_dynamodb::types::StreamViewType as V;
888 assert!(stream_view_problem(None).is_none());
889 assert!(stream_view_problem(Some(&V::NewImage)).is_none());
890 assert!(stream_view_problem(Some(&V::NewAndOldImages)).is_none());
891 let keys = stream_view_problem(Some(&V::KeysOnly)).unwrap();
892 assert!(keys.contains("KEYS_ONLY") && keys.contains("NEW_AND_OLD_IMAGES"));
893 assert!(
894 stream_view_problem(Some(&V::OldImage))
895 .unwrap()
896 .contains("OLD_IMAGE")
897 );
898 }
899 use super::*;
900 use faucet_core::Source as _;
901
902 async fn offline(mut config: DynamoDbSourceConfig) -> DynamoDbSource {
903 config.endpoint_url = Some("http://127.0.0.1:1".into());
904 config.region = Some("us-east-1".into());
905 config.credentials = faucet_common_dynamodb::DynamoDbCredentials::AccessKey {
906 access_key_id: "test".into(),
907 secret_access_key: "test".into(),
908 session_token: None,
909 };
910 config.retry.max_retries = 0;
911 DynamoDbSource::new(config).await.expect("source builds")
912 }
913
914 fn streams_config() -> DynamoDbSourceConfig {
915 let mut c = DynamoDbSourceConfig::new("t");
916 c.mode = ReadMode::Streams;
917 c.max_messages = Some(1);
918 c
919 }
920
921 #[test]
922 fn segment_shards_plan_and_parse() {
923 let shards = plan_segment_shards(3);
924 assert_eq!(shards.len(), 3);
925 assert_eq!(shards[2].id, "2");
926 assert_eq!(parse_segment_shard(&shards[1]).unwrap(), (1, 3));
927 for bad in [
928 json!({"segment": 3, "total_segments": 3}),
929 json!({"segment": 0, "total_segments": 0}),
930 json!({"segment": 0}),
931 json!(null),
932 ] {
933 assert!(parse_segment_shard(&ShardSpec::new("x", bad)).is_err());
934 }
935 assert_eq!(chunk_size(0), usize::MAX);
936 assert_eq!(chunk_size(5), 5);
937 }
938
939 #[tokio::test]
940 async fn new_validates() {
941 assert!(
942 DynamoDbSource::new(DynamoDbSourceConfig::new(""))
943 .await
944 .is_err()
945 );
946 }
947
948 #[tokio::test]
949 async fn identity_and_shard_hooks() {
950 let mut cfg = DynamoDbSourceConfig::new("orders");
951 cfg.segments = 4;
952 let source = offline(cfg).await;
953 assert_eq!(source.connector_name(), "dynamodb");
954 assert_eq!(source.dataset_uri(), "dynamodb://us-east-1/orders");
955 assert_eq!(source.state_key().as_deref(), Some("dynamodb:orders"));
956 assert!(!source.supports_exactly_once());
957 assert!(source.supports_discover());
958 assert!(source.is_shardable());
959 assert!(source.config_schema()["properties"]["table_name"].is_object());
960 assert_eq!(source.scan_targets(), (vec![0, 1, 2, 3], 4));
961 assert_eq!(source.enumerate_shards(16).await.unwrap().len(), 4);
962 source.apply_shard(&ShardSpec::whole()).await.unwrap();
963 assert_eq!(source.scan_targets().1, 4);
964 source
965 .apply_shard(&plan_segment_shards(4)[2])
966 .await
967 .unwrap();
968 assert_eq!(source.scan_targets(), (vec![2], 4));
969 assert!(source.capture_resume_position().await.unwrap().is_none());
970
971 let single = offline(DynamoDbSourceConfig::new("orders")).await;
972 assert_eq!(single.enumerate_shards(6).await.unwrap().len(), 6);
973 assert_eq!(single.enumerate_shards(0).await.unwrap().len(), 1);
974
975 let mut q = DynamoDbSourceConfig::new("orders");
976 q.mode = ReadMode::Query;
977 q.key_condition_expression = Some("pk = :p".into());
978 let q = offline(q).await;
979 assert_eq!(q.scan_targets(), (vec![0], 1));
980
981 let s = offline(streams_config()).await;
982 assert!(!s.is_shardable());
983 assert_eq!(s.state_key().as_deref(), Some("dynamodb-streams:t"));
984 assert!(s.enumerate_shards(4).await.unwrap()[0].is_whole());
985 assert!(s.apply_shard(&plan_segment_shards(2)[0]).await.is_err());
986 }
987
988 #[tokio::test]
989 async fn bookmark_is_held() {
990 let source = offline(DynamoDbSourceConfig::new("t")).await;
991 source.apply_start_bookmark(json!({"a": 1})).await.unwrap();
992 assert_eq!(source.start_bookmark(), Some(json!({"a": 1})));
993 }
994
995 #[tokio::test]
996 async fn offline_errors_are_typed() {
997 use futures::StreamExt;
998 let source = offline(DynamoDbSourceConfig::new("t")).await;
999 let err = source.fetch_all().await.unwrap_err();
1000 assert!(err.to_string().contains("scan of 't'"), "{err}");
1001 assert!(
1002 source
1003 .discover()
1004 .await
1005 .unwrap_err()
1006 .to_string()
1007 .contains("ListTables")
1008 );
1009 let report = source
1010 .check(&faucet_core::CheckContext {
1011 timeout: Duration::from_secs(30),
1012 })
1013 .await
1014 .unwrap();
1015 assert_eq!(report.failed_count(), 1);
1016
1017 let s = offline(streams_config()).await;
1018 let ctx = HashMap::new();
1019 let first = s.stream_pages(&ctx, 10).next().await.unwrap();
1020 assert!(first.unwrap_err().to_string().contains("DescribeTable"));
1021 assert!(s.capture_resume_position().await.is_err());
1022
1023 let mut with_arn = streams_config();
1024 with_arn.stream_arn = Some("arn:aws:dynamodb:us-east-1:0:table/t/stream/x".into());
1025 let s = offline(with_arn).await;
1026 let first = s.stream_pages(&ctx, 10).next().await.unwrap();
1027 assert!(first.unwrap_err().to_string().contains("DescribeStream"));
1028 }
1029
1030 #[tokio::test]
1031 async fn check_times_out_cleanly() {
1032 let source = offline(streams_config()).await;
1033 let report = source
1034 .check(&faucet_core::CheckContext {
1035 timeout: Duration::from_millis(1),
1036 })
1037 .await
1038 .unwrap();
1039 assert_eq!(report.failed_count(), 1);
1040 }
1041
1042 use crate::test_support::{dynamo, err, ok, on, streams};
1043 use wiremock::MockServer;
1044
1045 fn mock_source(uri: &str, config: DynamoDbSourceConfig) -> DynamoDbSource {
1046 DynamoDbSource {
1047 config,
1048 client: dynamo(uri),
1049 streams: streams(uri),
1050 start_bookmark: Mutex::new(None),
1051 shard: Mutex::new(None),
1052 lag: Mutex::new(LagTracker::default()),
1053 lag_warned: std::sync::atomic::AtomicBool::new(false),
1054 calls: CallSlot::default(),
1055 complete_read: faucet_core::ReadCompleteness::new(),
1056 }
1057 }
1058
1059 #[tokio::test]
1060 async fn table_and_stream_resolution_errors() {
1061 let server = MockServer::start().await;
1062 on(&server, "DynamoDB_20120810.DescribeTable", ok(json!({})), 1).await;
1063 on(
1064 &server,
1065 "DynamoDB_20120810.DescribeTable",
1066 ok(json!({"Table": {"TableName": "t"}})),
1067 1,
1068 )
1069 .await;
1070 let source = mock_source(&server.uri(), streams_config());
1071 let e = source
1072 .capture_resume_position()
1073 .await
1074 .unwrap_err()
1075 .to_string();
1076 assert!(e.contains("returned no table"), "{e}");
1077 let e = source
1078 .capture_resume_position()
1079 .await
1080 .unwrap_err()
1081 .to_string();
1082 assert!(e.contains("has no stream"), "{e}");
1083
1084 on(
1085 &server,
1086 "DynamoDB_20120810.ListTables",
1087 ok(json!({"TableNames": ["t"]})),
1088 1,
1089 )
1090 .await;
1091 on(
1092 &server,
1093 "DynamoDB_20120810.DescribeTable",
1094 err(400, "ResourceNotFoundException"),
1095 1,
1096 )
1097 .await;
1098 let e = source.discover().await.unwrap_err().to_string();
1099 assert!(e.contains("DescribeTable 't' failed"), "{e}");
1100 }
1101
1102 #[tokio::test]
1103 async fn trimmed_bookmark_is_a_gap() {
1104 let server = MockServer::start().await;
1105 on(
1106 &server,
1107 "DynamoDBStreams_20120810.DescribeStream",
1108 ok(json!({"StreamDescription": {"Shards": [{"ShardId": "s1"}]}})),
1109 1,
1110 )
1111 .await;
1112 on(
1113 &server,
1114 "DynamoDBStreams_20120810.GetShardIterator",
1115 err(400, "TrimmedDataAccessException"),
1116 1,
1117 )
1118 .await;
1119 let mut cfg = streams_config();
1120 cfg.stream_arn = Some("arn".into());
1121 let source = mock_source(&server.uri(), cfg);
1122 source
1123 .apply_start_bookmark(json!({"stream_arn": "arn", "shards": {"s1": "5"}}))
1124 .await
1125 .unwrap();
1126 let e = source.fetch_all().await.unwrap_err().to_string();
1127 assert!(e.contains("trimmed past sequence 5"), "{e}");
1128 }
1129
1130 #[tokio::test]
1131 async fn an_empty_shard_unverified_past_retention_is_a_gap() {
1132 let server = MockServer::start().await;
1133 on(
1134 &server,
1135 "DynamoDBStreams_20120810.DescribeStream",
1136 ok(json!({"StreamDescription": {"Shards": [{"ShardId": "s1"}]}})),
1137 1,
1138 )
1139 .await;
1140 let mut cfg = streams_config();
1141 cfg.stream_arn = Some("arn".into());
1142 let source = mock_source(&server.uri(), cfg);
1143 source
1144 .apply_start_bookmark(
1145 json!({"stream_arn": "arn", "shards": {"s1": ""}, "verified_at": {"s1": 0}}),
1146 )
1147 .await
1148 .unwrap();
1149 let e = source.fetch_all().await.unwrap_err().to_string();
1150 assert!(e.contains("shard s1 was last read"), "{e}");
1151 }
1152
1153 #[tokio::test]
1154 async fn sink_time_between_pages_is_not_idleness() {
1155 let server = MockServer::start().await;
1156 mock_stream(&server, json!([{"ShardId": "a"}])).await;
1157 mock_shard(&server, "a", true).await;
1158 let mut cfg = one_worker();
1159 cfg.max_messages = Some(4);
1160 cfg.batch_size = 1;
1161 cfg.idle_termination_secs = Some(1);
1162 let source = mock_source(&server.uri(), cfg);
1163 let ctx = std::collections::HashMap::new();
1164 let mut pages = faucet_core::Source::stream_pages(&source, &ctx, 1);
1165 let mut records = 0;
1166 while let Some(page) = futures::StreamExt::next(&mut pages).await {
1167 let page = page.unwrap();
1168 records += page.records.len();
1169 tokio::time::sleep(Duration::from_millis(1300)).await;
1170 }
1171 assert_eq!(records, 4);
1172 }
1173
1174 async fn mock_shard(server: &MockServer, id: &str, open: bool) {
1175 use wiremock::matchers::{body_partial_json, header, method};
1176 use wiremock::{Mock, ResponseTemplate};
1177 let respond = |body: Value| {
1178 ResponseTemplate::new(200)
1179 .insert_header("content-type", "application/x-amz-json-1.0")
1180 .set_body_string(body.to_string())
1181 };
1182 Mock::given(method("POST"))
1183 .and(header(
1184 "x-amz-target",
1185 "DynamoDBStreams_20120810.GetShardIterator",
1186 ))
1187 .and(body_partial_json(json!({"ShardId": id})))
1188 .respond_with(respond(json!({"ShardIterator": format!("it-{id}")})))
1189 .mount(server)
1190 .await;
1191 let mut page = json!({"Records": [{"eventID": id, "eventName": "INSERT", "dynamodb": {
1192 "Keys": {"pk": {"S": id}}, "NewImage": {"pk": {"S": id}}, "SequenceNumber": "1",
1193 "ApproximateCreationDateTime": 1_000_000_000}}]});
1194 if open {
1195 page["NextShardIterator"] = json!(format!("it-{id}"));
1196 }
1197 Mock::given(method("POST"))
1198 .and(header(
1199 "x-amz-target",
1200 "DynamoDBStreams_20120810.GetRecords",
1201 ))
1202 .and(body_partial_json(
1203 json!({"ShardIterator": format!("it-{id}")}),
1204 ))
1205 .respond_with(respond(page))
1206 .mount(server)
1207 .await;
1208 }
1209
1210 async fn mock_stream(server: &MockServer, shards: Value) {
1211 on(
1212 server,
1213 "DynamoDBStreams_20120810.DescribeStream",
1214 ok(json!({"StreamDescription": {"Shards": shards}})),
1215 100,
1216 )
1217 .await;
1218 }
1219
1220 fn one_worker() -> DynamoDbSourceConfig {
1221 let mut cfg = streams_config();
1222 cfg.stream_arn = Some("arn".into());
1223 cfg.shard_concurrency = 1;
1224 cfg.max_messages = Some(60);
1225 cfg.batch_size = 0;
1226 cfg
1227 }
1228
1229 #[tokio::test]
1230 async fn one_worker_rotates_over_every_busy_shard() {
1231 let server = MockServer::start().await;
1232 mock_stream(
1233 &server,
1234 json!([{"ShardId": "a"}, {"ShardId": "b"}, {"ShardId": "c"}]),
1235 )
1236 .await;
1237 for id in ["a", "b", "c"] {
1238 mock_shard(&server, id, true).await;
1239 }
1240 let source = mock_source(&server.uri(), one_worker());
1241 let records = source.fetch_all().await.unwrap();
1242 assert_eq!(records.len(), 60);
1243 for id in ["a", "b", "c"] {
1244 let n = records.iter().filter(|r| r["shard_id"] == id).count();
1245 assert!(n >= 8, "shard {id} starved: {n} records");
1246 }
1247 let lag = source.lag().await.unwrap().expect("busy shards report lag");
1248 assert!(lag.seconds.unwrap() > 1.0e8, "{lag:?}");
1249 }
1250
1251 #[tokio::test]
1252 async fn one_worker_reads_children_after_parents_drain() {
1253 let server = MockServer::start().await;
1254 mock_stream(
1255 &server,
1256 json!([{"ShardId": "p"}, {"ShardId": "child", "ParentShardId": "p"}, {"ShardId": "other"}]),
1257 )
1258 .await;
1259 mock_shard(&server, "p", false).await;
1260 mock_shard(&server, "child", true).await;
1261 mock_shard(&server, "other", true).await;
1262 let source = mock_source(&server.uri(), one_worker());
1263 let records = source.fetch_all().await.unwrap();
1264 let first = |id: &str| records.iter().position(|r| r["shard_id"] == id);
1265 assert_eq!(records.iter().filter(|r| r["shard_id"] == "p").count(), 1);
1266 assert!(first("child").unwrap() > first("p").unwrap());
1267 assert!(first("other").is_some());
1268 }
1269
1270 #[tokio::test]
1271 async fn idle_streams_terminate_and_empty_streams_end() {
1272 let server = MockServer::start().await;
1273 mock_stream(&server, json!([{"ShardId": "q"}])).await;
1274 on(
1275 &server,
1276 "DynamoDBStreams_20120810.GetShardIterator",
1277 ok(json!({"ShardIterator": "it"})),
1278 2,
1279 )
1280 .await;
1281 on(
1282 &server,
1283 "DynamoDBStreams_20120810.GetRecords",
1284 ok(json!({"Records": [], "NextShardIterator": "it"})),
1285 100,
1286 )
1287 .await;
1288 let mut cfg = one_worker();
1289 cfg.max_messages = None;
1290 cfg.idle_termination_secs = Some(1);
1291 let source = mock_source(&server.uri(), cfg.clone());
1292 assert_eq!(
1293 source.lag().await.unwrap(),
1294 Some(faucet_core::SourceLag::seconds(0.0)),
1295 "an empty shard probes as caught up"
1296 );
1297 assert!(source.fetch_all().await.unwrap().is_empty());
1298 assert_eq!(
1299 source.lag().await.unwrap(),
1300 Some(faucet_core::SourceLag::seconds(0.0))
1301 );
1302
1303 let server = MockServer::start().await;
1304 mock_stream(&server, json!([])).await;
1305 let source = mock_source(&server.uri(), cfg);
1306 assert!(source.fetch_all().await.unwrap().is_empty());
1307 assert_eq!(source.lag().await.unwrap(), None);
1308 }
1309
1310 #[tokio::test]
1311 async fn lag_probes_from_the_bookmark_before_reading() {
1312 let server = MockServer::start().await;
1313 mock_stream(&server, json!([{"ShardId": "a"}, {"ShardId": "b"}])).await;
1314 mock_shard(&server, "a", true).await;
1315 mock_shard(&server, "b", false).await;
1316 let source = mock_source(&server.uri(), one_worker());
1317 source
1318 .apply_start_bookmark(
1319 json!({"stream_arn": "arn", "shards": {"a": "0"}, "finished": []}),
1320 )
1321 .await
1322 .unwrap();
1323 let lag = source.lag().await.unwrap().expect("probe lag");
1324 assert!(lag.seconds.unwrap() > 1.0e8, "{lag:?}");
1325
1326 let failing = mock_source("http://127.0.0.1:1", one_worker());
1327 assert_eq!(failing.lag().await.unwrap(), None);
1328 assert_eq!(failing.lag().await.unwrap(), None);
1329 }
1330
1331 #[tokio::test]
1332 async fn scan_mode_reports_no_lag() {
1333 let source = offline(DynamoDbSourceConfig::new("t")).await;
1334 assert_eq!(source.lag().await.unwrap(), None);
1335 }
1336}