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_compare_and_put(&self) -> bool {
350 self.inner.supports_compare_and_put()
351 }
352
353 async fn compare_and_put(
354 &self,
355 key: &str,
356 expected: Option<&Value>,
357 value: &Value,
358 ) -> Result<bool, FaucetError> {
359 self.inner
360 .compare_and_put(key, expected, &self.stored(key, value))
361 .await
362 }
363
364 fn supports_atomic_batch(&self) -> bool {
365 self.inner.supports_atomic_batch()
366 }
367
368 async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
369 let entries: Vec<(String, Value)> = entries
370 .iter()
371 .map(|(k, v)| (k.clone(), self.stored(k, v)))
372 .collect();
373 self.inner.put_batch(&entries).await
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use serde_json::json;
381
382 #[tokio::test]
383 async fn compare_and_put_reaches_the_inner_store() {
384 use crate::state::StateStore as _;
385 let inner = std::sync::Arc::new(crate::state::MemoryStateStore::new());
386 let codec = StateCodec {
387 owner: "x".into(),
388 schema: 1,
389 legacy: false,
390 };
391 let v = VersionedStateStore::new(inner.clone(), "bm", codec);
392 assert!(v.supports_compare_and_put());
393 assert!(v.compare_and_put("lease", None, &json!(1)).await.unwrap());
394 assert!(!v.compare_and_put("lease", None, &json!(2)).await.unwrap());
395 assert_eq!(inner.get("lease").await.unwrap(), Some(json!(1)));
396 }
397
398 #[test]
399 fn legacy_values_parse_as_schema_zero() {
400 let s = StoredState::parse(&json!({"lsn": "0/1"}));
401 assert!(s.is_legacy());
402 assert_eq!((s.format, s.schema, s.owner.clone()), (0, 0, None));
403 assert_eq!(s.data, json!({"lsn": "0/1"}));
404 assert!(!is_versioned(&json!(7)));
405 assert_eq!(peel_versioned(&json!(7)), json!(7));
406 let no_data = json!({"faucet_state": 1, "owner": "x"});
407 assert!(StoredState::parse(&no_data).is_legacy());
408 }
409
410 #[test]
411 fn envelopes_round_trip() {
412 let v = wrap_versioned("kafka", 2, &json!({"o": 1}));
413 assert_eq!(
414 v,
415 json!({"faucet_state": 1, "owner": "kafka", "schema": 2, "data": {"o": 1}})
416 );
417 let s = StoredState::parse(&v);
418 assert_eq!(s.owner.as_deref(), Some("kafka"));
419 assert_eq!(s.schema, 2);
420 assert!(is_versioned(&v));
421 assert_eq!(peel_versioned(&v), json!({"o": 1}));
422 assert_eq!(encode_for_write("k", 0, &json!(1), true), json!(1));
423 assert_eq!(
424 encode_for_write("k", 0, &json!(1), false),
425 wrap_versioned("k", 0, &json!(1))
426 );
427 let huge = json!({"faucet_state": u64::MAX, "schema": u64::MAX, "data": 1});
428 let s = StoredState::parse(&huge);
429 assert_eq!((s.format, s.schema), (u32::MAX, u32::MAX));
430 }
431
432 #[test]
433 fn compat_decisions() {
434 let at = |owner: Option<&str>, schema: u32, format: u32| StoredState {
435 format,
436 owner: owner.map(str::to_owned),
437 schema,
438 data: json!(1),
439 };
440 assert_eq!(
441 check_compat(&at(Some("a"), 1, 1), "a", 1),
442 StateCompat::Current
443 );
444 assert_eq!(check_compat(&at(None, 0, 0), "a", 0), StateCompat::Current);
445 assert_eq!(
446 check_compat(&at(None, 0, 0), "a", 2),
447 StateCompat::Migrate { from: 0, to: 2 }
448 );
449 assert!(matches!(
450 check_compat(&at(Some("a"), 3, 1), "a", 2),
451 StateCompat::Incompatible { .. }
452 ));
453 assert!(matches!(
454 check_compat(&at(Some("b"), 0, 1), "a", 0),
455 StateCompat::Incompatible { .. }
456 ));
457 assert!(matches!(
458 check_compat(&at(Some("a"), 0, STATE_FORMAT + 1), "a", 0),
459 StateCompat::Incompatible { .. }
460 ));
461 }
462
463 #[test]
464 fn resolve_migrates_bare_and_exactly_once_payloads() {
465 let add_v = |from: u32, v: Value| -> Result<Value, FaucetError> {
466 assert_eq!(from, 0);
467 let mut m = v.as_object().cloned().unwrap_or_default();
468 m.insert("v".into(), json!(1));
469 Ok(Value::Object(m))
470 };
471 let r = resolve_with("k", &json!({"a": 1}), "src", 1, add_v).unwrap();
472 assert_eq!(r.data, json!({"a": 1, "v": 1}));
473 assert_eq!(r.migrated_from, Some(0));
474 assert!(r.legacy);
475
476 let eo = crate::idempotency::wrap_state(Some(&json!({"a": 2})), 9);
477 let r = resolve_with("k", &wrap_versioned("src", 0, &eo), "src", 1, add_v).unwrap();
478 assert_eq!(
479 crate::idempotency::unwrap_state(&r.data),
480 (Some(json!({"a": 2, "v": 1})), 9)
481 );
482 assert!(!r.legacy);
483
484 let empty_eo = crate::idempotency::wrap_state(None, 3);
485 let r = resolve_with("k", &empty_eo, "src", 1, add_v).unwrap();
486 assert_eq!(crate::idempotency::unwrap_state(&r.data), (None, 3));
487 let r = resolve_with("k", &Value::Null, "src", 1, add_v).unwrap();
488 assert_eq!(r.data, Value::Null);
489
490 let current =
491 resolve_with("k", &wrap_versioned("src", 1, &json!(5)), "src", 1, add_v).unwrap();
492 assert_eq!((current.data, current.migrated_from), (json!(5), None));
493 }
494
495 #[test]
496 fn resolve_refuses_with_a_typed_error() {
497 let never = |_: u32, _: Value| -> Result<Value, FaucetError> { unreachable!() };
498 let err = resolve_with(
499 "p::r",
500 &wrap_versioned("other", 0, &json!(1)),
501 "src",
502 0,
503 never,
504 )
505 .unwrap_err();
506 match &err {
507 FaucetError::StateIncompatible {
508 key,
509 found,
510 expected,
511 } => {
512 assert_eq!(key, "p::r");
513 assert!(found.contains("'other'") && expected.contains("'src'"));
514 }
515 other => panic!("{other:?}"),
516 }
517 assert!(err.to_string().contains("p::r"), "{err}");
518
519 let failing = |_: u32, _: Value| -> Result<Value, FaucetError> {
520 Err(FaucetError::State("bad shape".into()))
521 };
522 let err = resolve_with("k", &json!({"a": 1}), "src", 1, failing).unwrap_err();
523 assert!(err.to_string().contains("migration failed"), "{err}");
524 }
525
526 #[tokio::test]
527 async fn versioned_store_envelopes_only_the_bookmark_key() {
528 use crate::state::{MemoryStateStore, StateStore};
529 let inner: std::sync::Arc<dyn StateStore> = std::sync::Arc::new(MemoryStateStore::new());
530 let codec = StateCodec {
531 owner: "src".into(),
532 schema: 2,
533 legacy: false,
534 };
535 let store = VersionedStateStore::new(std::sync::Arc::clone(&inner), "p::r", codec.clone());
536 store.put("p::r", &json!({"a": 1})).await.unwrap();
537 store.put("p::r::__sla__", &json!({"x": 1})).await.unwrap();
538 assert_eq!(
539 inner.get("p::r").await.unwrap(),
540 Some(wrap_versioned("src", 2, &json!({"a": 1})))
541 );
542 assert_eq!(
543 store.get("p::r::__sla__").await.unwrap(),
544 Some(json!({"x": 1}))
545 );
546 store
547 .put_batch(&[("p::r".into(), json!(2)), ("other".into(), json!(3))])
548 .await
549 .unwrap();
550 assert_eq!(
551 inner.get("p::r").await.unwrap(),
552 Some(wrap_versioned("src", 2, &json!(2)))
553 );
554 assert_eq!(inner.get("other").await.unwrap(), Some(json!(3)));
555 assert_eq!(store.supports_list(), inner.supports_list());
556 assert_eq!(store.supports_atomic_batch(), inner.supports_atomic_batch());
557 assert!(
558 store
559 .list("p::")
560 .await
561 .unwrap()
562 .contains(&"p::r".to_string())
563 );
564 store.delete("other").await.unwrap();
565 assert_eq!(inner.get("other").await.unwrap(), None);
566 assert!(
567 store
568 .check(&crate::check::CheckContext::default())
569 .await
570 .is_ok()
571 );
572 let legacy = StateCodec {
573 legacy: true,
574 ..codec
575 };
576 assert_eq!(legacy.encode(&json!(5)), json!(5));
577 }
578}