Skip to main content

faucet_source_dynamodb/
stream.rs

1//! The DynamoDB `Source` implementation: parallel scan / query with
2//! per-segment cursors, and DynamoDB Streams CDC with lineage-ordered shard
3//! workers, cumulative per-shard bookmarks, gap handling and idle /
4//! max-messages termination.
5
6use 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
34/// Amazon DynamoDB source. See the crate README for semantics.
35pub 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
47/// Parse a `{segment, total_segments}` shard descriptor. Pure.
48pub(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
64/// Plan `total` scan segments as shards. Pure.
65pub(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
76/// Wait until `at` through `calls` (ended by its cancel token / deadline),
77/// or forever when there is nothing to wait for.
78async 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    /// Create a new DynamoDB source. Validates the config and builds the AWS
99    /// clients; no network I/O until streaming.
100    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    /// Segments this instance reads and the segment total.
173    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    /// Scan/query pages. `snapshot` wraps each item in an `op: "r"` envelope;
184    /// `emit_cursors` puts the cumulative cursors on intermediate pages;
185    /// `final_bookmark` goes on the last page.
186    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    /// Lag from the bookmark, without consuming: peek each open shard's
274    /// oldest unread record.
275    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    /// Whether a run that finds a gap reads the whole table (`on_gap: resnapshot`).
295    /// Whether a resnapshot is a complete read whose cleanup deletes the
296    /// destination rows of items it did not see.
297    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    /// Streams mode: every current shard opened at its trim horizon, so a
562    /// CDC run started after a snapshot replays the retained window and
563    /// converges. Other modes: `None`.
564    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    /// Streams mode: now minus the creation time of the newest record read
587    /// from each open shard, the largest across shards; before this run has
588    /// read, the age of each open shard's oldest unconsumed record at the
589    /// bookmark (one `Limit: 1` read per shard). A shard with nothing to read
590    /// counts as caught up. A failed probe is logged once and reported as no
591    /// lag. Other modes: `None`.
592    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    /// One shard per scan segment: `segments` when configured above 1,
619    /// otherwise `target`.
620    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    /// `ListTables` + `DescribeTable` per table (metadata only).
654    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    /// Side-effect-free probe: `DescribeTable`, plus `DescribeStream` in
733    /// streams mode (no records read, no iterator opened).
734    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
824/// Why a stream view type cannot feed a mirror: `KEYS_ONLY` and `OLD_IMAGE`
825/// records carry no new image, so `cdc_unwrap` has no row to upsert.
826fn 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
838/// The table a DynamoDB Streams envelope belongs to. Scan / query items are
839/// user data, so a `table` attribute there is never read as routing.
840fn 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)] // tests drive time and raw clients directly
849mod tests {
850    /// The value-fidelity corpus (#885) through the attribute-value
851    /// conversion this source reads items through. DynamoDB numbers have no negative zero.
852    #[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}