Skip to main content

faucet_core/
state_version.rs

1//! Versioned pipeline state (#736): upgrade-safe bookmarks and CDC positions.
2//!
3//! A bookmark outlives the release that wrote it. Stored bare, a value written
4//! by release N is read by N+1 as whatever shape N+1 expects — so a changed
5//! bookmark shape turns into a silent full re-sync or a skipped range. The
6//! pipeline therefore stores every bookmark inside an envelope naming the
7//! connector that owns the shape and that connector's shape version:
8//!
9//! ```json
10//! { "faucet_state": 1, "owner": "postgres-cdc", "schema": 0, "data": { "last_lsn": "0/16B3748" } }
11//! ```
12//!
13//! On read the envelope is checked before the source sees anything:
14//!
15//! - an un-enveloped value is a legacy bookmark at schema `0` (read as-is and
16//!   rewritten in the envelope by the next bookmark write);
17//! - an older `schema` is migrated forward by the source
18//!   ([`Source::migrate_state`]) — a pure step, so a crash before the next
19//!   write leaves the old value valid;
20//! - a newer `schema`, a newer envelope `faucet_state`, or another `owner` is
21//!   refused with [`FaucetError::StateIncompatible`]: never guessed.
22//!
23//! The exactly-once wrapper (`{"__faucet_eo": 1, "bookmark", "seq"}`) lives
24//! *inside* `data`, and a migration rewrites only its bookmark.
25
26use crate::FaucetError;
27use crate::traits::Source;
28use serde::Serialize;
29use serde_json::{Map, Value};
30
31/// The envelope version this release writes and the newest it reads.
32pub 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/// A stored state value, taken apart.
40#[derive(Debug, Clone, PartialEq, Serialize)]
41pub struct StoredState {
42    /// Envelope version; `0` for a legacy, un-enveloped value.
43    pub format: u32,
44    /// The connector that owns the shape of `data` (`None` for legacy values).
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub owner: Option<String>,
47    /// The owner's shape version (`0` for legacy values).
48    pub schema: u32,
49    /// The bookmark (or exactly-once wrapper) itself.
50    pub data: Value,
51}
52
53impl StoredState {
54    /// Take a stored value apart. Anything that is not an envelope is a legacy
55    /// bookmark at schema 0.
56    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    /// Whether the value predates the envelope.
84    pub fn is_legacy(&self) -> bool {
85        self.format == 0
86    }
87}
88
89/// Wrap `data` in the envelope for `owner` at shape version `schema`.
90pub 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
99/// The payload of a stored value — `data` of an envelope, or the value itself.
100/// For readers that only display or pass the value through.
101pub fn peel_versioned(stored: &Value) -> Value {
102    StoredState::parse(stored).data
103}
104
105/// Whether a stored value is an envelope.
106pub fn is_versioned(stored: &Value) -> bool {
107    !StoredState::parse(stored).is_legacy()
108}
109
110/// How a stored value relates to what the reading source expects.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
112#[serde(tag = "status", rename_all = "snake_case")]
113pub enum StateCompat {
114    /// Readable as-is.
115    Current,
116    /// Readable after the source migrates it from `from` to `to`.
117    Migrate { from: u32, to: u32 },
118    /// Not readable by this release or this connector.
119    Incompatible { found: String, expected: String },
120}
121
122/// Compare a stored value with the `owner` and shape version `expected` the
123/// reading source declares.
124pub 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
154/// The refusal for a stored value this release or connector cannot read.
155pub 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/// A stored value made readable for the source that will resume from it.
164#[derive(Debug, Clone, PartialEq)]
165pub struct ResolvedState {
166    /// The payload at the source's current shape — a bare bookmark or an
167    /// exactly-once wrapper around one.
168    pub data: Value,
169    /// The shape version it was migrated from, when a migration ran.
170    pub migrated_from: Option<u32>,
171    /// Whether the stored value predated the envelope.
172    pub legacy: bool,
173}
174
175/// Check and, when needed, migrate the value stored under `key` for `source`.
176/// Refuses (typed) rather than letting the source misread it.
177pub 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
191/// [`resolve_for_source`] with the owner, expected version and migration
192/// passed explicitly — for callers holding a connector kind rather than a
193/// built source.
194pub 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
229/// Apply `migrate` to the bookmark in `data`: inside an exactly-once wrapper
230/// when there is one, keeping its sequence. A null bookmark has nothing to
231/// migrate.
232fn 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
247/// The value to store: the envelope, or — when `legacy` (a mixed-version
248/// cluster whose older members cannot read the envelope yet) — the bare
249/// payload.
250pub 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/// How one pipeline's bookmark is written (#736): the owner and shape version
259/// the envelope carries, or bare for a mixed-version cluster.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct StateCodec {
262    /// The connector that owns the bookmark shape.
263    pub owner: String,
264    /// Its shape version.
265    pub schema: u32,
266    /// Write the bare payload (a cluster member that predates the envelope is
267    /// live).
268    pub legacy: bool,
269}
270
271impl StateCodec {
272    /// The codec for bookmarks `source` produces.
273    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    /// The stored form of `value`.
282    pub fn encode(&self, value: &Value) -> Value {
283        encode_for_write(&self.owner, self.schema, value, self.legacy)
284    }
285}
286
287/// A [`StateStore`](crate::state::StateStore) that stores one key — the
288/// pipeline's bookmark — in the versioned envelope and passes everything else
289/// through. Reads return the stored value; resolving it is the reader's job
290/// ([`resolve_for_source`]).
291pub struct VersionedStateStore {
292    inner: std::sync::Arc<dyn crate::state::StateStore>,
293    key: String,
294    codec: StateCodec,
295}
296
297impl VersionedStateStore {
298    /// Version writes of `key` into `inner` with `codec`.
299    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}