1use crate::FaucetError;
27use crate::traits::Source;
28use serde::Serialize;
29use serde_json::{Map, Value};
30
31pub const STATE_FORMAT: u32 = 1;
33
34const FORMAT_KEY: &str = "faucet_state";
35const OWNER_KEY: &str = "owner";
36const SCHEMA_KEY: &str = "schema";
37const DATA_KEY: &str = "data";
38
39#[derive(Debug, Clone, PartialEq, Serialize)]
41pub struct StoredState {
42 pub format: u32,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub owner: Option<String>,
47 pub schema: u32,
49 pub data: Value,
51}
52
53impl StoredState {
54 pub fn parse(stored: &Value) -> Self {
57 if let Some(map) = stored.as_object()
58 && let Some(format) = map.get(FORMAT_KEY).and_then(Value::as_u64)
59 && let Some(data) = map.get(DATA_KEY)
60 {
61 return Self {
62 format: u32::try_from(format).unwrap_or(u32::MAX),
63 owner: map
64 .get(OWNER_KEY)
65 .and_then(Value::as_str)
66 .map(str::to_owned),
67 schema: map
68 .get(SCHEMA_KEY)
69 .and_then(Value::as_u64)
70 .map(|s| u32::try_from(s).unwrap_or(u32::MAX))
71 .unwrap_or(0),
72 data: data.clone(),
73 };
74 }
75 Self {
76 format: 0,
77 owner: None,
78 schema: 0,
79 data: stored.clone(),
80 }
81 }
82
83 pub fn is_legacy(&self) -> bool {
85 self.format == 0
86 }
87}
88
89pub fn wrap_versioned(owner: &str, schema: u32, data: &Value) -> Value {
91 let mut map = Map::new();
92 map.insert(FORMAT_KEY.into(), Value::from(STATE_FORMAT));
93 map.insert(OWNER_KEY.into(), Value::from(owner));
94 map.insert(SCHEMA_KEY.into(), Value::from(schema));
95 map.insert(DATA_KEY.into(), data.clone());
96 Value::Object(map)
97}
98
99pub fn peel_versioned(stored: &Value) -> Value {
102 StoredState::parse(stored).data
103}
104
105pub fn is_versioned(stored: &Value) -> bool {
107 !StoredState::parse(stored).is_legacy()
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
112#[serde(tag = "status", rename_all = "snake_case")]
113pub enum StateCompat {
114 Current,
116 Migrate { from: u32, to: u32 },
118 Incompatible { found: String, expected: String },
120}
121
122pub fn check_compat(state: &StoredState, owner: &str, expected: u32) -> StateCompat {
125 if state.format > STATE_FORMAT {
126 return StateCompat::Incompatible {
127 found: format!("state format {}", state.format),
128 expected: format!("state format {STATE_FORMAT} or older"),
129 };
130 }
131 if let Some(found) = &state.owner
132 && found != owner
133 {
134 return StateCompat::Incompatible {
135 found: format!("state owned by '{found}'"),
136 expected: format!("state owned by '{owner}'"),
137 };
138 }
139 if state.schema > expected {
140 return StateCompat::Incompatible {
141 found: format!("'{owner}' state schema {}", state.schema),
142 expected: format!("'{owner}' state schema {expected} or older"),
143 };
144 }
145 if state.schema < expected {
146 return StateCompat::Migrate {
147 from: state.schema,
148 to: expected,
149 };
150 }
151 StateCompat::Current
152}
153
154pub fn incompatible(key: &str, found: String, expected: String) -> FaucetError {
156 FaucetError::StateIncompatible {
157 key: key.to_string(),
158 found,
159 expected,
160 }
161}
162
163#[derive(Debug, Clone, PartialEq)]
165pub struct ResolvedState {
166 pub data: Value,
169 pub migrated_from: Option<u32>,
171 pub legacy: bool,
173}
174
175pub fn resolve_for_source(
178 key: &str,
179 stored: &Value,
180 source: &dyn Source,
181) -> Result<ResolvedState, FaucetError> {
182 resolve_with(
183 key,
184 stored,
185 source.connector_name(),
186 source.state_schema(),
187 |from, data| source.migrate_state(from, data),
188 )
189}
190
191pub fn resolve_with(
195 key: &str,
196 stored: &Value,
197 owner: &str,
198 expected: u32,
199 migrate: impl Fn(u32, Value) -> Result<Value, FaucetError>,
200) -> Result<ResolvedState, FaucetError> {
201 let state = StoredState::parse(stored);
202 let legacy = state.is_legacy();
203 match check_compat(&state, owner, expected) {
204 StateCompat::Current => Ok(ResolvedState {
205 data: state.data,
206 migrated_from: None,
207 legacy,
208 }),
209 StateCompat::Incompatible { found, expected } => Err(incompatible(key, found, expected)),
210 StateCompat::Migrate { from, to } => {
211 let data = migrate_payload(state.data, |bm| {
212 migrate(from, bm).map_err(|e| {
213 incompatible(
214 key,
215 format!("'{owner}' state schema {from}"),
216 format!("'{owner}' state schema {to} (migration failed: {e})"),
217 )
218 })
219 })?;
220 Ok(ResolvedState {
221 data,
222 migrated_from: Some(from),
223 legacy,
224 })
225 }
226 }
227}
228
229fn migrate_payload(
233 data: Value,
234 migrate: impl Fn(Value) -> Result<Value, FaucetError>,
235) -> Result<Value, FaucetError> {
236 if crate::idempotency::is_eo_envelope(&data) {
237 let (bookmark, seq) = crate::idempotency::unwrap_state(&data);
238 let migrated = bookmark.map(migrate).transpose()?;
239 return Ok(crate::idempotency::wrap_state(migrated.as_ref(), seq));
240 }
241 if data.is_null() {
242 return Ok(data);
243 }
244 migrate(data)
245}
246
247pub fn encode_for_write(owner: &str, schema: u32, data: &Value, legacy: bool) -> Value {
251 if legacy {
252 data.clone()
253 } else {
254 wrap_versioned(owner, schema, data)
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct StateCodec {
262 pub owner: String,
264 pub schema: u32,
266 pub legacy: bool,
269}
270
271impl StateCodec {
272 pub fn for_source(source: &dyn Source, legacy: bool) -> Self {
274 Self {
275 owner: source.connector_name().to_string(),
276 schema: source.state_schema(),
277 legacy,
278 }
279 }
280
281 pub fn encode(&self, value: &Value) -> Value {
283 encode_for_write(&self.owner, self.schema, value, self.legacy)
284 }
285}
286
287pub struct VersionedStateStore {
292 inner: std::sync::Arc<dyn crate::state::StateStore>,
293 key: String,
294 codec: StateCodec,
295}
296
297impl VersionedStateStore {
298 pub fn new(
300 inner: std::sync::Arc<dyn crate::state::StateStore>,
301 key: impl Into<String>,
302 codec: StateCodec,
303 ) -> Self {
304 Self {
305 inner,
306 key: key.into(),
307 codec,
308 }
309 }
310
311 fn stored(&self, key: &str, value: &Value) -> Value {
312 if key == self.key {
313 self.codec.encode(value)
314 } else {
315 value.clone()
316 }
317 }
318}
319
320#[async_trait::async_trait]
321impl crate::state::StateStore for VersionedStateStore {
322 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
323 self.inner.get(key).await
324 }
325
326 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
327 self.inner.put(key, &self.stored(key, value)).await
328 }
329
330 async fn delete(&self, key: &str) -> Result<(), FaucetError> {
331 self.inner.delete(key).await
332 }
333
334 async fn check(
335 &self,
336 ctx: &crate::check::CheckContext,
337 ) -> Result<crate::check::CheckReport, FaucetError> {
338 self.inner.check(ctx).await
339 }
340
341 fn supports_list(&self) -> bool {
342 self.inner.supports_list()
343 }
344
345 async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
346 self.inner.list(prefix).await
347 }
348
349 fn supports_atomic_batch(&self) -> bool {
350 self.inner.supports_atomic_batch()
351 }
352
353 async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
354 let entries: Vec<(String, Value)> = entries
355 .iter()
356 .map(|(k, v)| (k.clone(), self.stored(k, v)))
357 .collect();
358 self.inner.put_batch(&entries).await
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use serde_json::json;
366
367 #[test]
368 fn legacy_values_parse_as_schema_zero() {
369 let s = StoredState::parse(&json!({"lsn": "0/1"}));
370 assert!(s.is_legacy());
371 assert_eq!((s.format, s.schema, s.owner.clone()), (0, 0, None));
372 assert_eq!(s.data, json!({"lsn": "0/1"}));
373 assert!(!is_versioned(&json!(7)));
374 assert_eq!(peel_versioned(&json!(7)), json!(7));
375 let no_data = json!({"faucet_state": 1, "owner": "x"});
376 assert!(StoredState::parse(&no_data).is_legacy());
377 }
378
379 #[test]
380 fn envelopes_round_trip() {
381 let v = wrap_versioned("kafka", 2, &json!({"o": 1}));
382 assert_eq!(
383 v,
384 json!({"faucet_state": 1, "owner": "kafka", "schema": 2, "data": {"o": 1}})
385 );
386 let s = StoredState::parse(&v);
387 assert_eq!(s.owner.as_deref(), Some("kafka"));
388 assert_eq!(s.schema, 2);
389 assert!(is_versioned(&v));
390 assert_eq!(peel_versioned(&v), json!({"o": 1}));
391 assert_eq!(encode_for_write("k", 0, &json!(1), true), json!(1));
392 assert_eq!(
393 encode_for_write("k", 0, &json!(1), false),
394 wrap_versioned("k", 0, &json!(1))
395 );
396 let huge = json!({"faucet_state": u64::MAX, "schema": u64::MAX, "data": 1});
397 let s = StoredState::parse(&huge);
398 assert_eq!((s.format, s.schema), (u32::MAX, u32::MAX));
399 }
400
401 #[test]
402 fn compat_decisions() {
403 let at = |owner: Option<&str>, schema: u32, format: u32| StoredState {
404 format,
405 owner: owner.map(str::to_owned),
406 schema,
407 data: json!(1),
408 };
409 assert_eq!(
410 check_compat(&at(Some("a"), 1, 1), "a", 1),
411 StateCompat::Current
412 );
413 assert_eq!(check_compat(&at(None, 0, 0), "a", 0), StateCompat::Current);
414 assert_eq!(
415 check_compat(&at(None, 0, 0), "a", 2),
416 StateCompat::Migrate { from: 0, to: 2 }
417 );
418 assert!(matches!(
419 check_compat(&at(Some("a"), 3, 1), "a", 2),
420 StateCompat::Incompatible { .. }
421 ));
422 assert!(matches!(
423 check_compat(&at(Some("b"), 0, 1), "a", 0),
424 StateCompat::Incompatible { .. }
425 ));
426 assert!(matches!(
427 check_compat(&at(Some("a"), 0, STATE_FORMAT + 1), "a", 0),
428 StateCompat::Incompatible { .. }
429 ));
430 }
431
432 #[test]
433 fn resolve_migrates_bare_and_exactly_once_payloads() {
434 let add_v = |from: u32, v: Value| -> Result<Value, FaucetError> {
435 assert_eq!(from, 0);
436 let mut m = v.as_object().cloned().unwrap_or_default();
437 m.insert("v".into(), json!(1));
438 Ok(Value::Object(m))
439 };
440 let r = resolve_with("k", &json!({"a": 1}), "src", 1, add_v).unwrap();
441 assert_eq!(r.data, json!({"a": 1, "v": 1}));
442 assert_eq!(r.migrated_from, Some(0));
443 assert!(r.legacy);
444
445 let eo = crate::idempotency::wrap_state(Some(&json!({"a": 2})), 9);
446 let r = resolve_with("k", &wrap_versioned("src", 0, &eo), "src", 1, add_v).unwrap();
447 assert_eq!(
448 crate::idempotency::unwrap_state(&r.data),
449 (Some(json!({"a": 2, "v": 1})), 9)
450 );
451 assert!(!r.legacy);
452
453 let empty_eo = crate::idempotency::wrap_state(None, 3);
454 let r = resolve_with("k", &empty_eo, "src", 1, add_v).unwrap();
455 assert_eq!(crate::idempotency::unwrap_state(&r.data), (None, 3));
456 let r = resolve_with("k", &Value::Null, "src", 1, add_v).unwrap();
457 assert_eq!(r.data, Value::Null);
458
459 let current =
460 resolve_with("k", &wrap_versioned("src", 1, &json!(5)), "src", 1, add_v).unwrap();
461 assert_eq!((current.data, current.migrated_from), (json!(5), None));
462 }
463
464 #[test]
465 fn resolve_refuses_with_a_typed_error() {
466 let never = |_: u32, _: Value| -> Result<Value, FaucetError> { unreachable!() };
467 let err = resolve_with(
468 "p::r",
469 &wrap_versioned("other", 0, &json!(1)),
470 "src",
471 0,
472 never,
473 )
474 .unwrap_err();
475 match &err {
476 FaucetError::StateIncompatible {
477 key,
478 found,
479 expected,
480 } => {
481 assert_eq!(key, "p::r");
482 assert!(found.contains("'other'") && expected.contains("'src'"));
483 }
484 other => panic!("{other:?}"),
485 }
486 assert!(err.to_string().contains("p::r"), "{err}");
487
488 let failing = |_: u32, _: Value| -> Result<Value, FaucetError> {
489 Err(FaucetError::State("bad shape".into()))
490 };
491 let err = resolve_with("k", &json!({"a": 1}), "src", 1, failing).unwrap_err();
492 assert!(err.to_string().contains("migration failed"), "{err}");
493 }
494
495 #[tokio::test]
496 async fn versioned_store_envelopes_only_the_bookmark_key() {
497 use crate::state::{MemoryStateStore, StateStore};
498 let inner: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
499 let codec = StateCodec {
500 owner: "src".into(),
501 schema: 2,
502 legacy: false,
503 };
504 let store = VersionedStateStore::new(std::sync::Arc::clone(&inner), "p::r", codec.clone());
505 store.put("p::r", &json!({"a": 1})).await.unwrap();
506 store.put("p::r::__sla__", &json!({"x": 1})).await.unwrap();
507 assert_eq!(
508 inner.get("p::r").await.unwrap(),
509 Some(wrap_versioned("src", 2, &json!({"a": 1})))
510 );
511 assert_eq!(
512 store.get("p::r::__sla__").await.unwrap(),
513 Some(json!({"x": 1}))
514 );
515 store
516 .put_batch(&[("p::r".into(), json!(2)), ("other".into(), json!(3))])
517 .await
518 .unwrap();
519 assert_eq!(
520 inner.get("p::r").await.unwrap(),
521 Some(wrap_versioned("src", 2, &json!(2)))
522 );
523 assert_eq!(inner.get("other").await.unwrap(), Some(json!(3)));
524 assert_eq!(store.supports_list(), inner.supports_list());
525 assert_eq!(store.supports_atomic_batch(), inner.supports_atomic_batch());
526 assert!(
527 store
528 .list("p::")
529 .await
530 .unwrap()
531 .contains(&"p::r".to_string())
532 );
533 store.delete("other").await.unwrap();
534 assert_eq!(inner.get("other").await.unwrap(), None);
535 assert!(
536 store
537 .check(&crate::check::CheckContext::default())
538 .await
539 .is_ok()
540 );
541 let legacy = StateCodec {
542 legacy: true,
543 ..codec
544 };
545 assert_eq!(legacy.encode(&json!(5)), json!(5));
546 }
547}