1use crate::config::ReadMode;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::collections::{BTreeMap, BTreeSet};
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum SegmentCursor {
26 After(Value),
28 Done,
30}
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct ScanBookmark {
35 #[serde(default)]
37 pub total_segments: u32,
38 #[serde(default)]
40 pub segments: BTreeMap<u32, SegmentCursor>,
41}
42
43impl ScanBookmark {
44 pub fn from_value(v: &Value) -> Self {
47 serde_json::from_value(v.clone()).unwrap_or_default()
48 }
49
50 pub fn to_value(&self) -> Value {
52 serde_json::to_value(self).unwrap_or(Value::Null)
53 }
54
55 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
76pub struct StreamBookmark {
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub stream_arn: Option<String>,
80 #[serde(default)]
82 pub shards: BTreeMap<String, String>,
83 #[serde(default)]
85 pub finished: BTreeSet<String>,
86 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
88 pub verified_at: BTreeMap<String, i64>,
89}
90
91pub(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 pub fn from_value(v: &Value) -> Self {
102 serde_json::from_value(v.clone()).unwrap_or_default()
103 }
104
105 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)] 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 pub fn to_value(&self) -> Value {
139 serde_json::to_value(self).unwrap_or(Value::Null)
140 }
141
142 pub fn is_empty(&self) -> bool {
144 self.shards.is_empty() && self.finished.is_empty()
145 }
146
147 pub fn open(&mut self, shard: &str) {
149 self.open_at(shard, unix_now());
150 }
151
152 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 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 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 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 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
188pub 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}