Skip to main content

faucet_source_dynamodb/
state.rs

1//! Bookmark formats.
2//!
3//! **Scan / query** — `{ "total_segments": N, "segments": { "<i>": {"after": <typed key>} | "done" } }`.
4//! Mid-run pages carry the cumulative per-segment `LastEvaluatedKey`
5//! cursors, so a crash resumes each segment where it stopped. The final page
6//! carries an empty map, so the next run re-reads the table from the start (a
7//! scan is a snapshot, not an incremental feed).
8//!
9//! **Streams** — `{ "stream_arn": "…", "shards": { "<shard>": "<seq>" }, "finished": [ … ] }`.
10//! Every page carries the cumulative map. An empty sequence string marks a
11//! shard that was opened but yielded nothing yet (resumes from its trim
12//! horizon, never skipping). `finished` lists closed shards fully drained.
13//! `verified_at` holds, for each such empty shard, the unix time it was last
14//! known to hold nothing unread (opened, or read to its end at the trim
15//! horizon), so a resume after the 24 h retention is reported as a gap.
16
17use crate::config::ReadMode;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::collections::{BTreeMap, BTreeSet};
21
22/// Where one scan segment (or the single query cursor) stands.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum SegmentCursor {
26    /// Resume after this `LastEvaluatedKey` (DynamoDB typed JSON).
27    After(Value),
28    /// Segment fully read.
29    Done,
30}
31
32/// Scan / query bookmark.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct ScanBookmark {
35    /// `TotalSegments` the cursors belong to.
36    #[serde(default)]
37    pub total_segments: u32,
38    /// Segment index → cursor.
39    #[serde(default)]
40    pub segments: BTreeMap<u32, SegmentCursor>,
41}
42
43impl ScanBookmark {
44    /// Parse; malformed values are treated as absent (a fresh scan is
45    /// at-least-once-safe).
46    pub fn from_value(v: &Value) -> Self {
47        serde_json::from_value(v.clone()).unwrap_or_default()
48    }
49
50    /// Serialize.
51    pub fn to_value(&self) -> Value {
52        serde_json::to_value(self).unwrap_or(Value::Null)
53    }
54
55    /// The cursors to resume with for a scan of `total` segments. Cursors from
56    /// a different segment count are unusable and dropped (fresh scan).
57    pub fn for_total(&self, total: u32) -> BTreeMap<u32, SegmentCursor> {
58        if self.total_segments == total {
59            self.segments.clone()
60        } else {
61            if !self.segments.is_empty() {
62                tracing::warn!(
63                    bookmarked = self.total_segments,
64                    configured = total,
65                    "dynamodb: scan bookmark was taken with a different segment count; \
66                     restarting the scan"
67                );
68            }
69            BTreeMap::new()
70        }
71    }
72}
73
74/// Streams bookmark.
75#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
76pub struct StreamBookmark {
77    /// Stream the positions belong to.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub stream_arn: Option<String>,
80    /// Shard id → last emitted sequence number (`""` = opened, nothing read).
81    #[serde(default)]
82    pub shards: BTreeMap<String, String>,
83    /// Closed shards fully drained.
84    #[serde(default)]
85    pub finished: BTreeSet<String>,
86    /// Empty-position shard → unix seconds it was last known to hold nothing unread.
87    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
88    pub verified_at: BTreeMap<String, i64>,
89}
90
91/// Seconds since the unix epoch.
92pub(crate) fn unix_now() -> i64 {
93    std::time::SystemTime::now()
94        .duration_since(std::time::UNIX_EPOCH)
95        .map(|d| d.as_secs() as i64)
96        .unwrap_or(0)
97}
98
99impl StreamBookmark {
100    /// Parse; malformed values are treated as absent.
101    pub fn from_value(v: &Value) -> Self {
102        serde_json::from_value(v.clone()).unwrap_or_default()
103    }
104
105    /// Parse a stored bookmark; `null` is absent, anything unparseable is an
106    /// error so a stream never silently restarts at `start_position`. A field
107    /// a streams bookmark never carries (a scan bookmark's `segments`, say) is
108    /// refused too: every known field is optional, so such an object would
109    /// otherwise read as an empty bookmark.
110    pub fn parse(v: &Value) -> Result<Self, faucet_core::FaucetError> {
111        if v.is_null() {
112            return Ok(Self::default());
113        }
114        #[derive(Deserialize)]
115        #[serde(deny_unknown_fields)]
116        #[allow(dead_code)] // only its field set is used
117        struct Fields {
118            #[serde(default)]
119            stream_arn: Option<Value>,
120            #[serde(default)]
121            shards: Option<Value>,
122            #[serde(default)]
123            finished: Option<Value>,
124            #[serde(default)]
125            verified_at: Option<Value>,
126        }
127        serde_json::from_value::<Fields>(v.clone())
128            .and_then(|_| serde_json::from_value(v.clone()))
129            .map_err(|e| {
130                faucet_core::FaucetError::State(format!(
131                    "dynamodb: the stored streams bookmark cannot be read ({e}); fix or delete \
132                 the state entry so the read does not restart at start_position"
133                ))
134            })
135    }
136
137    /// Serialize.
138    pub fn to_value(&self) -> Value {
139        serde_json::to_value(self).unwrap_or(Value::Null)
140    }
141
142    /// Whether this bookmark records any progress.
143    pub fn is_empty(&self) -> bool {
144        self.shards.is_empty() && self.finished.is_empty()
145    }
146
147    /// Record that a shard was opened (keeps an existing position).
148    pub fn open(&mut self, shard: &str) {
149        self.open_at(shard, unix_now());
150    }
151
152    /// [`open`](Self::open) at an explicit time.
153    pub fn open_at(&mut self, shard: &str, now: i64) {
154        let seq = self.shards.entry(shard.to_string()).or_default();
155        if seq.is_empty() {
156            self.verified_at.entry(shard.to_string()).or_insert(now);
157        }
158    }
159
160    /// An empty-position shard was read to its end: nothing retained is unread.
161    pub fn verify_empty(&mut self, shard: &str, now: i64) {
162        if self.shards.get(shard).is_some_and(String::is_empty) {
163            self.verified_at.insert(shard.to_string(), now);
164        }
165    }
166
167    /// Record a shard's newest emitted sequence.
168    pub fn advance(&mut self, shard: &str, sequence: &str) {
169        self.shards.insert(shard.to_string(), sequence.to_string());
170        self.verified_at.remove(shard);
171    }
172
173    /// Record a closed shard as fully drained.
174    pub fn finish(&mut self, shard: &str) {
175        self.shards.remove(shard);
176        self.verified_at.remove(shard);
177        self.finished.insert(shard.to_string());
178    }
179
180    /// Drop finished entries no longer needed: keep a finished shard while it
181    /// still exists or is the parent of one that does.
182    pub fn prune_finished(&mut self, existing: &BTreeSet<String>, parents: &BTreeSet<String>) {
183        self.finished
184            .retain(|s| existing.contains(s) || parents.contains(s));
185    }
186}
187
188/// The source's stable state key for a mode.
189pub fn state_key(mode: ReadMode, table: &str) -> String {
190    match mode {
191        ReadMode::Streams => format!("dynamodb-streams:{table}"),
192        ReadMode::Scan | ReadMode::Query => format!("dynamodb:{table}"),
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use serde_json::json;
200
201    #[test]
202    fn scan_bookmark_round_trips() {
203        let mut b = ScanBookmark {
204            total_segments: 2,
205            ..Default::default()
206        };
207        b.segments
208            .insert(0, SegmentCursor::After(json!({"pk": {"S": "a"}})));
209        b.segments.insert(1, SegmentCursor::Done);
210        let v = b.to_value();
211        assert_eq!(v["segments"]["1"], "done");
212        assert_eq!(v["segments"]["0"]["after"]["pk"]["S"], "a");
213        assert_eq!(ScanBookmark::from_value(&v), b);
214        assert_eq!(b.for_total(2).len(), 2);
215        assert!(b.for_total(4).is_empty());
216        assert!(ScanBookmark::default().for_total(3).is_empty());
217        assert_eq!(
218            ScanBookmark::from_value(&json!("x")),
219            ScanBookmark::default()
220        );
221    }
222
223    #[test]
224    fn stream_bookmark_tracks_positions() {
225        let mut b = StreamBookmark::default();
226        assert!(b.is_empty());
227        b.open("s1");
228        assert_eq!(b.shards["s1"], "");
229        b.advance("s1", "100");
230        b.open("s1");
231        assert_eq!(b.shards["s1"], "100", "open keeps a position");
232        b.finish("s1");
233        assert!(b.shards.is_empty() && b.finished.contains("s1"));
234        assert!(!b.is_empty());
235        b.finish("old");
236        b.finish("parent");
237        let existing: BTreeSet<String> = ["s1".to_string()].into();
238        let parents: BTreeSet<String> = ["parent".to_string()].into();
239        b.prune_finished(&existing, &parents);
240        assert_eq!(
241            b.finished.iter().cloned().collect::<Vec<_>>(),
242            vec!["parent", "s1"]
243        );
244        b.stream_arn = Some("arn:x".into());
245        let v = b.to_value();
246        assert_eq!(StreamBookmark::from_value(&v), b);
247        assert_eq!(
248            StreamBookmark::from_value(&json!(3)),
249            StreamBookmark::default()
250        );
251        assert_eq!(
252            StreamBookmark::parse(&Value::Null).unwrap(),
253            StreamBookmark::default()
254        );
255        assert_eq!(StreamBookmark::parse(&v).unwrap(), b);
256        assert!(StreamBookmark::parse(&json!(3)).is_err());
257        let scan = json!({"total_segments": 4, "segments": {"0": {"id": {"S": "a"}}}});
258        let err = StreamBookmark::parse(&scan).unwrap_err().to_string();
259        assert!(err.contains("unknown field"), "{err}");
260        assert_eq!(
261            StreamBookmark::parse(&json!({})).unwrap(),
262            StreamBookmark::default()
263        );
264    }
265
266    #[test]
267    fn empty_shards_carry_their_verification_time() {
268        let mut b = StreamBookmark::default();
269        b.open_at("s1", 100);
270        b.open_at("s1", 200);
271        assert_eq!(b.verified_at["s1"], 100, "re-opening keeps the first time");
272        b.verify_empty("s1", 300);
273        assert_eq!(b.verified_at["s1"], 300);
274        b.verify_empty("unknown", 300);
275        assert!(!b.verified_at.contains_key("unknown"));
276        let v = b.to_value();
277        assert_eq!(v["verified_at"]["s1"], 300);
278        assert_eq!(StreamBookmark::from_value(&v), b);
279        b.advance("s1", "7");
280        assert!(b.verified_at.is_empty());
281        b.open_at("s1", 400);
282        assert!(b.verified_at.is_empty(), "a positioned shard needs no time");
283        b.verify_empty("s1", 500);
284        assert!(b.verified_at.is_empty());
285        b.open_at("s2", 1);
286        b.finish("s2");
287        assert!(b.verified_at.is_empty());
288        assert!(
289            StreamBookmark::default()
290                .to_value()
291                .get("verified_at")
292                .is_none()
293        );
294    }
295
296    #[test]
297    fn state_keys_are_valid() {
298        for mode in [ReadMode::Scan, ReadMode::Query, ReadMode::Streams] {
299            faucet_core::state::validate_state_key(&state_key(mode, "orders")).unwrap();
300        }
301        assert_eq!(state_key(ReadMode::Streams, "t"), "dynamodb-streams:t");
302        assert_eq!(state_key(ReadMode::Query, "t"), "dynamodb:t");
303    }
304}