Skip to main content

faucet_core/
state_key.rs

1//! Typed state keys (#882, RFC 0013).
2//!
3//! Every key a pipeline reads or writes in its [`StateStore`] is built here
4//! from its components and rendered one way:
5//!
6//! ```text
7//! [{tenant}::]{pipeline}::{row}[::{sub}][::{shard}][::{suffix}]
8//! [{tenant}::]{pipeline}::{pipeline-level suffix}
9//! ```
10//!
11//! A changed rendering silently looks like a first run — a full re-sync or a
12//! skipped incremental range — so the rendering is versioned
13//! ([`KEY_SCHEME_VERSION`]) and every key can list what earlier schemes would
14//! have rendered for it ([`StateKey::legacy_renderings`]).
15//! [`migrate_state_key`] moves a value stored under one of those keys to the
16//! current key at run start.
17
18use crate::error::FaucetError;
19use crate::state::StateStore;
20
21/// Version of the key rendering this build writes.
22///
23/// * `1` — a child invocation's parent-key value rendered verbatim.
24/// * `2` — the parent-key value is encoded by [`encode_component`] (#840), so a
25///   value holding `::`, spaces or non-ASCII cannot split or break the key.
26/// * `3` — the parent-key value is typed: a non-string value is tagged by its
27///   JSON type (`_n.42` for the number 42, `_b.true`, `_z.null`, `_a.`/`_o.`
28///   for arrays and objects), so the number `1` and the string `"1"` no longer
29///   share one key. A string renders as in scheme 2 unless it starts like a
30///   tag (`_n.`, `_s.`, …), which is prefixed `_s.`.
31pub const KEY_SCHEME_VERSION: u32 = 3;
32
33/// Separator between key components.
34pub const KEY_SEPARATOR: &str = "::";
35
36/// Longest raw value [`encode_component`] keeps verbatim.
37pub const COMPONENT_MAX: usize = 64;
38
39/// What a key holds besides a plain resume position.
40#[derive(Debug, Clone, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum KeySuffix {
43    /// `::__sla__` — freshness / volume SLA history of an invocation.
44    Sla,
45    /// `::__profiling__` — column-profile baseline of an invocation.
46    Profiling,
47    /// `::__rollback__` — the retained undoable runs of an invocation.
48    RollbackIndex,
49    /// `::__rollback__::{run}` — one undoable run's marker.
50    RollbackRun(String),
51    /// `::__status__` — the last run outcomes of an invocation.
52    Status,
53    /// `::__lease__` — held while a run of the invocation is in flight.
54    Lease,
55    /// `{pipeline}::__replication__` — the snapshot→CDC phase marker.
56    Replication,
57    /// `{pipeline}::__backfill__::{range hash}` — a backfill's progress marker.
58    Backfill(String),
59    /// `{pipeline}::__serve_backfill__::{range hash}` — a served backfill range.
60    ServeBackfill(String),
61}
62
63impl KeySuffix {
64    /// Whether the suffix hangs off the pipeline rather than an invocation.
65    pub fn is_pipeline_level(&self) -> bool {
66        matches!(
67            self,
68            Self::Replication | Self::Backfill(_) | Self::ServeBackfill(_)
69        )
70    }
71
72    fn render_into(&self, out: &mut String) {
73        let (marker, tail) = match self {
74            Self::Sla => ("__sla__", None),
75            Self::Profiling => ("__profiling__", None),
76            Self::RollbackIndex => ("__rollback__", None),
77            Self::RollbackRun(run) => ("__rollback__", Some(run.as_str())),
78            Self::Status => ("__status__", None),
79            Self::Lease => ("__lease__", None),
80            Self::Replication => ("__replication__", None),
81            Self::Backfill(h) => ("__backfill__", Some(h.as_str())),
82            Self::ServeBackfill(h) => ("__serve_backfill__", Some(h.as_str())),
83        };
84        push(out, marker);
85        if let Some(tail) = tail {
86            push(out, tail);
87        }
88    }
89
90    /// The suffix's rendering, without the leading separator.
91    pub fn render(&self) -> String {
92        let mut s = String::new();
93        self.render_into(&mut s);
94        s
95    }
96
97    /// The suffix as it ends a key, separator included (`::__lease__`).
98    pub fn tail(&self) -> String {
99        format!("{KEY_SEPARATOR}{}", self.render())
100    }
101}
102
103fn push(out: &mut String, seg: &str) {
104    if !out.is_empty() {
105        out.push_str(KEY_SEPARATOR);
106    }
107    out.push_str(seg);
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Hash)]
111enum Sub {
112    /// A child invocation's parent-key value, as the parent produced it.
113    Parent(String),
114    /// A component already in key form (a product tuple, a parsed segment).
115    Encoded(String),
116    /// A child invocation's parent-key value with its JSON type: its scheme-3
117    /// rendering and the text earlier schemes rendered it from.
118    Typed { typed: String, text: String },
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Hash)]
122enum Head {
123    Parts {
124        tenant: Option<String>,
125        pipeline: String,
126        row: Option<String>,
127        sub: Option<Sub>,
128    },
129    /// An invocation key [`StateKey::render`] produced earlier.
130    Rendered(String),
131}
132
133/// A state key built from its components. See the [module docs](self).
134#[derive(Debug, Clone, PartialEq, Eq, Hash)]
135pub struct StateKey {
136    head: Head,
137    shard: Option<String>,
138    suffix: Option<KeySuffix>,
139}
140
141impl StateKey {
142    /// The pipeline-level key namespace of `pipeline` (no row yet).
143    pub fn pipeline(pipeline: impl Into<String>) -> Self {
144        Self {
145            head: Head::Parts {
146                tenant: None,
147                pipeline: pipeline.into(),
148                row: None,
149                sub: None,
150            },
151            shard: None,
152            suffix: None,
153        }
154    }
155
156    /// `{pipeline}::{row}` — a matrix row's (or topology sink node's) key.
157    pub fn invocation(pipeline: impl Into<String>, row: impl Into<String>) -> Self {
158        Self::pipeline(pipeline).with_row(row)
159    }
160
161    /// A key derived from an invocation key [`render`](Self::render) already
162    /// produced — the base the per-invocation markers hang off.
163    pub fn from_rendered(base: impl Into<String>) -> Self {
164        Self {
165            head: Head::Rendered(base.into()),
166            shard: None,
167            suffix: None,
168        }
169    }
170
171    /// Place the key under `tenant` (`{tenant}::{pipeline}::…`, #709).
172    pub fn with_tenant(mut self, tenant: Option<impl Into<String>>) -> Self {
173        if let Head::Parts { tenant: t, .. } = &mut self.head {
174            *t = tenant.map(Into::into);
175        }
176        self
177    }
178
179    /// The matrix row / topology node id.
180    pub fn with_row(mut self, id: impl Into<String>) -> Self {
181        if let Head::Parts { row, .. } = &mut self.head {
182            *row = Some(id.into());
183        }
184        self
185    }
186
187    /// A child invocation's parent-key value, raw (a string); rendered through
188    /// [`encode_component`] (and tagged `_s.` under scheme 3 when it starts
189    /// like a type tag). Prefer [`with_parent_value`](Self::with_parent_value)
190    /// when the value's JSON type is known.
191    pub fn with_parent_key(mut self, raw: impl Into<String>) -> Self {
192        if let Head::Parts { sub, .. } = &mut self.head {
193            *sub = Some(Sub::Parent(raw.into()));
194        }
195        self
196    }
197
198    /// A child invocation's parent-key value, typed: the number `1` and the
199    /// string `"1"` render as different keys (scheme 3). Earlier schemes
200    /// rendered both from the value's text (a string unquoted, anything else
201    /// as JSON), which [`legacy_renderings`](Self::legacy_renderings) keeps.
202    pub fn with_parent_value(mut self, value: &serde_json::Value) -> Self {
203        use serde_json::Value;
204        let (typed, text) = match value {
205            Value::String(s) => (typed_string(s), s.clone()),
206            other => {
207                let tag = match other {
208                    Value::Number(_) => 'n',
209                    Value::Bool(_) => 'b',
210                    Value::Null => 'z',
211                    Value::Array(_) => 'a',
212                    _ => 'o',
213                };
214                let text = other.to_string();
215                (encode_component(&format!("_{tag}.{text}"), true), text)
216            }
217        };
218        if let Head::Parts { sub, .. } = &mut self.head {
219            *sub = Some(Sub::Typed { typed, text });
220        }
221        self
222    }
223
224    /// A sub-invocation component already in key form (a product tuple suffix).
225    pub fn with_sub(mut self, encoded: impl Into<String>) -> Self {
226        if let Head::Parts { sub, .. } = &mut self.head {
227            *sub = Some(Sub::Encoded(encoded.into()));
228        }
229        self
230    }
231
232    /// A shard of the invocation (`{base}::{shard}`).
233    pub fn with_shard(mut self, shard: impl Into<String>) -> Self {
234        self.shard = Some(shard.into());
235        self
236    }
237
238    /// A marker suffix.
239    pub fn with_suffix(mut self, suffix: KeySuffix) -> Self {
240        self.suffix = Some(suffix);
241        self
242    }
243
244    /// The tenant, when the key is built from components.
245    pub fn tenant(&self) -> Option<&str> {
246        match &self.head {
247            Head::Parts { tenant, .. } => tenant.as_deref(),
248            Head::Rendered(_) => None,
249        }
250    }
251
252    /// The pipeline name, when the key is built from components.
253    pub fn pipeline_name(&self) -> Option<&str> {
254        match &self.head {
255            Head::Parts { pipeline, .. } => Some(pipeline),
256            Head::Rendered(_) => None,
257        }
258    }
259
260    /// The row id, when the key is built from components.
261    pub fn row(&self) -> Option<&str> {
262        match &self.head {
263            Head::Parts { row, .. } => row.as_deref(),
264            Head::Rendered(_) => None,
265        }
266    }
267
268    /// The rendered sub-invocation component.
269    pub fn sub(&self) -> Option<String> {
270        match &self.head {
271            Head::Parts { sub: Some(s), .. } => Some(render_sub(s, KEY_SCHEME_VERSION)),
272            _ => None,
273        }
274    }
275
276    /// The shard id.
277    pub fn shard(&self) -> Option<&str> {
278        self.shard.as_deref()
279    }
280
281    /// The marker suffix.
282    pub fn suffix(&self) -> Option<&KeySuffix> {
283        self.suffix.as_ref()
284    }
285
286    /// `[{tenant}::]{pipeline}` — the first segments of every key of the
287    /// pipeline; for a key built from a rendering, that rendering.
288    pub fn namespace(&self) -> String {
289        match &self.head {
290            Head::Parts {
291                tenant, pipeline, ..
292            } => {
293                let mut s = String::new();
294                if let Some(t) = tenant {
295                    push(&mut s, t);
296                }
297                push(&mut s, pipeline);
298                s
299            }
300            Head::Rendered(base) => base.clone(),
301        }
302    }
303
304    /// The prefix every key under this one starts with (`{key}::`).
305    pub fn children_prefix(&self) -> String {
306        format!("{}{KEY_SEPARATOR}", self.render())
307    }
308
309    /// The key under the current scheme.
310    pub fn render(&self) -> String {
311        self.render_scheme(KEY_SCHEME_VERSION)
312    }
313
314    /// The key as scheme `version` rendered it.
315    pub fn render_scheme(&self, version: u32) -> String {
316        let mut s = match &self.head {
317            Head::Parts { row, sub, .. } => {
318                let mut s = self.namespace();
319                if let Some(row) = row {
320                    push(&mut s, row);
321                }
322                if let Some(sub) = sub {
323                    push(&mut s, &render_sub(sub, version));
324                }
325                s
326            }
327            Head::Rendered(base) => base.clone(),
328        };
329        if let Some(shard) = &self.shard {
330            push(&mut s, shard);
331        }
332        if let Some(suffix) = &self.suffix {
333            suffix.render_into(&mut s);
334        }
335        s
336    }
337
338    /// Every distinct, valid key an earlier scheme rendered for this key, newest
339    /// scheme first — where [`migrate_state_key`] looks when the current key
340    /// holds nothing.
341    pub fn legacy_renderings(&self) -> Vec<String> {
342        let current = self.render();
343        let mut out: Vec<String> = Vec::new();
344        for version in (1..KEY_SCHEME_VERSION).rev() {
345            let old = self.render_scheme(version);
346            if old != current
347                && !out.contains(&old)
348                && crate::state::validate_state_key(&old).is_ok()
349            {
350                out.push(old);
351            }
352        }
353        out
354    }
355
356    /// Decode `key` under the namespace `[{tenant}::]{pipeline}`; `None` when it
357    /// is outside it. A shard id cannot be told from a sub component, so it is
358    /// returned as part of [`sub`](Self::sub); the rendering round-trips.
359    pub fn parse(tenant: Option<&str>, pipeline: &str, key: &str) -> Option<Self> {
360        let ns = StateKey::pipeline(pipeline).with_tenant(tenant).namespace();
361        let rest = key.strip_prefix(&ns)?.strip_prefix(KEY_SEPARATOR)?;
362        if rest.is_empty() {
363            return None;
364        }
365        let segs: Vec<&str> = rest.split(KEY_SEPARATOR).collect();
366        let join = |s: &[&str]| s.join(KEY_SEPARATOR);
367        let base = StateKey::pipeline(pipeline).with_tenant(tenant);
368        match segs[0] {
369            "__replication__" if segs.len() == 1 => {
370                return Some(base.with_suffix(KeySuffix::Replication));
371            }
372            "__backfill__" if segs.len() > 1 => {
373                return Some(base.with_suffix(KeySuffix::Backfill(join(&segs[1..]))));
374            }
375            "__serve_backfill__" if segs.len() > 1 => {
376                return Some(base.with_suffix(KeySuffix::ServeBackfill(join(&segs[1..]))));
377            }
378            _ => {}
379        }
380        let marker = segs.iter().enumerate().skip(1).find_map(|(i, s)| {
381            let suffix = match *s {
382                "__sla__" if i + 1 == segs.len() => KeySuffix::Sla,
383                "__profiling__" if i + 1 == segs.len() => KeySuffix::Profiling,
384                "__status__" if i + 1 == segs.len() => KeySuffix::Status,
385                "__lease__" if i + 1 == segs.len() => KeySuffix::Lease,
386                "__rollback__" if i + 1 < segs.len() => {
387                    KeySuffix::RollbackRun(join(&segs[i + 1..]))
388                }
389                "__rollback__" => KeySuffix::RollbackIndex,
390                _ => return None,
391            };
392            Some((i, suffix))
393        });
394        let end = marker.as_ref().map_or(segs.len(), |(i, _)| *i);
395        // A backfill unit's row id is `backfill::{range hash}::{unit}`.
396        let row_end = if segs[0] == "backfill" && end > 1 {
397            end
398        } else {
399            1
400        };
401        let mut parsed = base.with_row(join(&segs[..row_end]));
402        if end > row_end {
403            parsed = parsed.with_sub(join(&segs[row_end..end]));
404        }
405        if let Some((_, suffix)) = marker {
406            parsed = parsed.with_suffix(suffix);
407        }
408        Some(parsed)
409    }
410}
411
412fn render_sub(sub: &Sub, version: u32) -> String {
413    match sub {
414        Sub::Parent(raw) if version >= 3 => typed_string(raw),
415        Sub::Parent(raw) if version >= 2 => encode_component(raw, true),
416        Sub::Parent(raw) => raw.clone(),
417        Sub::Typed { typed, .. } if version >= 3 => typed.clone(),
418        Sub::Typed { text, .. } if version >= 2 => encode_component(text, true),
419        Sub::Typed { text, .. } => text.clone(),
420        Sub::Encoded(s) => s.clone(),
421    }
422}
423
424/// The type tags scheme 3 renders a non-string parent key with (`_n.42`).
425const TYPE_TAGS: &[char] = &['s', 'n', 'b', 'z', 'a', 'o'];
426
427/// A string parent key under scheme 3: as scheme 2 rendered it, unless it
428/// starts like a type tag, which would let it collide with a non-string
429/// value — then it is tagged `_s.` itself.
430fn typed_string(raw: &str) -> String {
431    let mut chars = raw.chars();
432    let tag_like = chars.next() == Some('_')
433        && chars.next().is_some_and(|c| TYPE_TAGS.contains(&c))
434        && chars.next() == Some('.');
435    if tag_like {
436        encode_component(&format!("_s.{raw}"), true)
437    } else {
438        encode_component(raw, true)
439    }
440}
441
442impl std::fmt::Display for StateKey {
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        f.write_str(&self.render())
445    }
446}
447
448/// Render one raw value as a state-key component (#789 CLI-52/CLI-53). A value
449/// made only of state-key characters is kept as is; anything else (an email, a
450/// space, non-ASCII, `::`, an over-long value) becomes a readable prefix plus a
451/// stable 64-bit hash of the raw value. `allow_separators` admits `:` and `/`
452/// (a child's parent key, which stands alone after `::`); a path-like value
453/// whose `/` segments a state key may not hold (empty, `.`, `..`, dot-leading)
454/// is hashed too, so the rendered key always passes
455/// [`validate_state_key`](crate::state::validate_state_key).
456pub fn encode_component(raw: &str, allow_separators: bool) -> String {
457    let plain = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.');
458    let legal = |c: char| plain(c) || (allow_separators && matches!(c, ':' | '/'));
459    if !raw.is_empty()
460        && raw.len() <= COMPONENT_MAX
461        && raw.chars().all(legal)
462        && !raw.contains(KEY_SEPARATOR)
463        && !raw.starts_with(':')
464        && !raw.ends_with(':')
465        && (!raw.contains('/')
466            || raw
467                .split('/')
468                .all(|seg| !seg.is_empty() && !seg.starts_with('.')))
469    {
470        return raw.to_owned();
471    }
472    let readable: String = raw.chars().filter(|c| plain(*c)).take(32).collect();
473    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
474    for b in raw.as_bytes() {
475        hash ^= u64::from(*b);
476        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
477    }
478    format!("{readable}.h{hash:016x}")
479}
480
481/// The row id of one backfill unit's invocation, `backfill::{range hash}::{unit}`
482/// — namespaced away from every live row's key.
483pub fn backfill_unit_row(range_hash: &str, unit: &str) -> String {
484    ["backfill", range_hash, unit].join(KEY_SEPARATOR)
485}
486
487/// What [`migrate_state_key`] did.
488#[derive(Debug, Clone, PartialEq, Eq)]
489#[non_exhaustive]
490pub enum KeyMigration {
491    /// The current key already holds a value; nothing was looked up.
492    Current,
493    /// No candidate holds a value: a genuine first run.
494    Fresh,
495    /// The value under `from` now lives under the current key.
496    Moved {
497        /// The earlier key the value was found under.
498        from: String,
499    },
500}
501
502/// Move a value an earlier key scheme (or a renamed row) left under one of
503/// `candidates` to `key`, when `key` holds nothing.
504///
505/// The first candidate holding a value wins. The value is claimed with
506/// [`StateStore::compare_and_put`] against an absent entry, so a run that wrote
507/// `key` meanwhile is never overwritten; the old key is deleted afterwards. A
508/// crash in between leaves the value under both keys, and the next run sees the
509/// current key and moves nothing. Counted in
510/// `faucet_state_key_migrations_total{outcome}`.
511pub async fn migrate_state_key(
512    store: &dyn StateStore,
513    key: &str,
514    candidates: &[String],
515) -> Result<KeyMigration, FaucetError> {
516    if candidates.is_empty() {
517        return Ok(KeyMigration::Fresh);
518    }
519    if store.get(key).await?.is_some() {
520        return Ok(KeyMigration::Current);
521    }
522    for old in candidates.iter().filter(|c| c.as_str() != key) {
523        let Some(value) = store.get(old).await? else {
524            continue;
525        };
526        if !store.compare_and_put(key, None, &value).await? {
527            metrics::counter!("faucet_state_key_migrations_total", "outcome" => "raced")
528                .increment(1);
529            return Ok(KeyMigration::Current);
530        }
531        if let Err(e) = store.delete(old).await {
532            tracing::warn!(key = %old, error = %e, "legacy state key could not be removed");
533        }
534        metrics::counter!("faucet_state_key_migrations_total", "outcome" => "moved").increment(1);
535        tracing::info!(from = %old, to = key, "moved state from the key an earlier release used");
536        return Ok(KeyMigration::Moved { from: old.clone() });
537    }
538    Ok(KeyMigration::Fresh)
539}
540
541#[cfg(test)]
542mod tests {
543    #[test]
544    fn typed_parent_keys_tag_null_and_arrays() {
545        let inv = || StateKey::invocation("p", "r");
546        let null = inv().with_parent_value(&json!(null)).render();
547        assert!(null.starts_with("p::r::_z."), "{null}");
548        let arr = inv().with_parent_value(&json!([1])).render();
549        assert!(arr.starts_with("p::r::_a."), "{arr}");
550        assert_ne!(null, arr);
551        crate::state::validate_state_key(&arr).unwrap();
552    }
553    use super::*;
554    use crate::state::MemoryStateStore;
555    use serde_json::json;
556
557    #[test]
558    fn a_path_like_parent_key_always_renders_a_valid_state_key() {
559        for raw in ["exports/2026/", "a//b", "a/./b", "a/../b", "x/.cfg", "/abs"] {
560            let key = StateKey::invocation("p", "files")
561                .with_parent_key(raw)
562                .render();
563            crate::state::validate_state_key(&key).unwrap_or_else(|e| panic!("{raw}: {e}"));
564            assert!(key.contains(".h"), "{raw} is hashed: {key}");
565        }
566        // A well-formed path is still kept readable.
567        assert_eq!(
568            StateKey::invocation("p", "files")
569                .with_parent_key("exports/2026")
570                .render(),
571            "p::files::exports/2026"
572        );
573    }
574
575    #[test]
576    fn renders_every_kind() {
577        let inv = StateKey::invocation("p", "r");
578        assert_eq!(inv.render(), "p::r");
579        assert_eq!(StateKey::pipeline("p").render(), "p");
580        assert_eq!(
581            StateKey::invocation("p", "r")
582                .with_tenant(Some("acme"))
583                .render(),
584            "acme::p::r"
585        );
586        assert_eq!(inv.clone().with_parent_key("42").render(), "p::r::42");
587        assert_eq!(inv.clone().with_sub("a:1/b:2").render(), "p::r::a:1/b:2");
588        assert_eq!(inv.clone().with_shard("s3").render(), "p::r::s3");
589        let cases = [
590            (KeySuffix::Sla, "p::r::__sla__"),
591            (KeySuffix::Profiling, "p::r::__profiling__"),
592            (KeySuffix::RollbackIndex, "p::r::__rollback__"),
593            (KeySuffix::RollbackRun("x".into()), "p::r::__rollback__::x"),
594            (KeySuffix::Status, "p::r::__status__"),
595            (KeySuffix::Lease, "p::r::__lease__"),
596        ];
597        for (suffix, want) in cases {
598            assert!(!suffix.is_pipeline_level());
599            assert_eq!(inv.clone().with_suffix(suffix).render(), want);
600        }
601        for (suffix, want) in [
602            (KeySuffix::Replication, "p::__replication__"),
603            (KeySuffix::Backfill("h".into()), "p::__backfill__::h"),
604            (
605                KeySuffix::ServeBackfill("h".into()),
606                "p::__serve_backfill__::h",
607            ),
608        ] {
609            assert!(suffix.is_pipeline_level());
610            assert_eq!(StateKey::pipeline("p").with_suffix(suffix).render(), want);
611        }
612        assert_eq!(
613            StateKey::from_rendered("p::r::7")
614                .with_suffix(KeySuffix::Status)
615                .to_string(),
616            "p::r::7::__status__"
617        );
618        assert_eq!(
619            KeySuffix::RollbackRun("x".into()).render(),
620            "__rollback__::x"
621        );
622        assert_eq!(inv.children_prefix(), "p::r::");
623        assert_eq!(KeySuffix::Lease.tail(), "::__lease__");
624    }
625
626    #[test]
627    fn accessors_report_components() {
628        let k = StateKey::invocation("p", "r")
629            .with_tenant(Some("t"))
630            .with_parent_key("a b")
631            .with_shard("s")
632            .with_suffix(KeySuffix::Lease);
633        assert_eq!(k.tenant(), Some("t"));
634        assert_eq!(k.pipeline_name(), Some("p"));
635        assert_eq!(k.row(), Some("r"));
636        assert_eq!(k.sub(), Some(encode_component("a b", true)));
637        assert_eq!(k.shard(), Some("s"));
638        assert_eq!(k.suffix(), Some(&KeySuffix::Lease));
639        assert_eq!(k.namespace(), "t::p");
640        let r = StateKey::from_rendered("x::y")
641            .with_tenant(Some("ignored"))
642            .with_row("ignored")
643            .with_parent_key("ignored")
644            .with_sub("ignored");
645        assert_eq!(r.render(), "x::y");
646        assert_eq!(
647            (r.tenant(), r.pipeline_name(), r.row(), r.sub()),
648            (None, None, None, None)
649        );
650        assert_eq!(r.namespace(), "x::y");
651        assert_eq!(StateKey::invocation("p", "r").sub(), None);
652    }
653
654    #[test]
655    fn parent_keys_are_encoded_and_remember_the_verbatim_scheme() {
656        let k = StateKey::invocation("p", "r").with_parent_key("a b");
657        assert_ne!(k.render(), "p::r::a b");
658        assert_eq!(k.render_scheme(1), "p::r::a b");
659        // `a b` is not a valid key, so scheme 1 has nothing to offer.
660        assert!(k.legacy_renderings().is_empty());
661        let k = StateKey::invocation("p", "r").with_parent_key("x::y");
662        assert_eq!(k.legacy_renderings(), vec!["p::r::x::y".to_string()]);
663        let k = StateKey::invocation("p", "r").with_parent_key("plain");
664        assert!(k.legacy_renderings().is_empty());
665    }
666
667    #[test]
668    fn typed_parent_keys_keep_numbers_and_strings_apart() {
669        let inv = || StateKey::invocation("p", "r");
670        let num = inv().with_parent_value(&json!(1));
671        let text = inv().with_parent_value(&json!("1"));
672        assert_eq!(num.render(), "p::r::_n.1");
673        assert_eq!(text.render(), "p::r::1");
674        assert_ne!(num.render(), text.render());
675        // Both rendered `p::r::1` under scheme 2, where the number migrates from.
676        assert_eq!(num.render_scheme(2), "p::r::1");
677        assert_eq!(num.legacy_renderings(), vec!["p::r::1".to_string()]);
678        assert!(
679            text.legacy_renderings().is_empty(),
680            "a plain string never moves"
681        );
682        // A string parent key equals `with_parent_key` of the same text.
683        assert_eq!(text, inv().with_parent_value(&json!("1")));
684        assert_eq!(text.render(), inv().with_parent_key("1").render());
685        // A string shaped like a tag is tagged itself.
686        let fake = inv().with_parent_value(&json!("_n.1"));
687        assert_eq!(fake.render(), "p::r::_s._n.1");
688        assert_eq!(fake.legacy_renderings(), vec!["p::r::_n.1".to_string()]);
689        assert_eq!(inv().with_parent_key("_n.1").render(), "p::r::_s._n.1");
690        for (v, want) in [
691            (json!(true), "p::r::_b.true"),
692            (json!(-2.5), "p::r::_n.-2.5"),
693        ] {
694            assert_eq!(inv().with_parent_value(&v).render(), want);
695        }
696        let obj = inv().with_parent_value(&json!({"a": 1})).render();
697        assert!(obj.starts_with("p::r::_o.a1.h"), "{obj}");
698        crate::state::validate_state_key(&obj).unwrap();
699        assert_eq!(num.render_scheme(1), "p::r::1");
700    }
701
702    #[test]
703    fn encode_component_keeps_legal_values_and_hashes_the_rest() {
704        assert_eq!(encode_component("cust-42_a.b", false), "cust-42_a.b");
705        assert_eq!(encode_component("a/b:c", true), "a/b:c");
706        assert_ne!(encode_component("a/b:c", false), "a/b:c");
707        for raw in ["", "x::y", ":x", "y:", "émoji", &"z".repeat(65)] {
708            let c = encode_component(raw, true);
709            assert!(c.contains(".h"), "{raw:?} → {c}");
710            crate::state::validate_state_key(&format!("p::r::{c}")).unwrap();
711        }
712    }
713
714    #[test]
715    fn parse_round_trips_every_kind() {
716        let keys = [
717            "p::r",
718            "p::r::42",
719            "p::r::a:1/b:2",
720            "p::r::__sla__",
721            "p::r::__profiling__",
722            "p::r::__rollback__",
723            "p::r::__rollback__::run1",
724            "p::r::7::__status__",
725            "p::r::__lease__",
726            "p::__replication__",
727            "p::__backfill__::abc",
728            "p::__serve_backfill__::abc",
729            "p::backfill::h::2026-01-01",
730            "p::backfill::h::u1::__status__",
731            "p::backfill",
732        ];
733        for key in keys {
734            let k = StateKey::parse(None, "p", key).unwrap_or_else(|| panic!("{key}"));
735            assert_eq!(k.render(), key);
736        }
737        assert_eq!(backfill_unit_row("h", "u"), "backfill::h::u");
738        let k = StateKey::parse(None, "p", "p::backfill::h::u1::__lease__").unwrap();
739        assert_eq!(k.row(), Some("backfill::h::u1"));
740        assert_eq!(k.suffix(), Some(&KeySuffix::Lease));
741        let k = StateKey::parse(Some("t"), "p", "t::p::r::9").unwrap();
742        assert_eq!(
743            (k.tenant(), k.row(), k.sub()),
744            (Some("t"), Some("r"), Some("9".into()))
745        );
746        assert!(StateKey::parse(None, "p", "q::r").is_none());
747        assert!(StateKey::parse(None, "p", "p::").is_none());
748        assert!(StateKey::parse(None, "p", "px::r").is_none());
749    }
750
751    #[tokio::test]
752    async fn migration_moves_once_and_never_clobbers() {
753        let store = MemoryStateStore::new();
754        assert_eq!(
755            migrate_state_key(&store, "p::new", &[]).await.unwrap(),
756            KeyMigration::Fresh
757        );
758        assert_eq!(
759            migrate_state_key(&store, "p::new", &["p::old".into()])
760                .await
761                .unwrap(),
762            KeyMigration::Fresh
763        );
764        store.put("p::old", &json!({"page": 3})).await.unwrap();
765        assert_eq!(
766            migrate_state_key(&store, "p::new", &["p::new".into(), "p::old".into()])
767                .await
768                .unwrap(),
769            KeyMigration::Moved {
770                from: "p::old".into()
771            }
772        );
773        assert_eq!(store.get("p::new").await.unwrap(), Some(json!({"page": 3})));
774        assert_eq!(store.get("p::old").await.unwrap(), None);
775        store.put("p::old", &json!(1)).await.unwrap();
776        assert_eq!(
777            migrate_state_key(&store, "p::new", &["p::old".into()])
778                .await
779                .unwrap(),
780            KeyMigration::Current
781        );
782        assert_eq!(store.get("p::new").await.unwrap(), Some(json!({"page": 3})));
783    }
784
785    struct Racing(MemoryStateStore);
786
787    #[async_trait::async_trait]
788    impl StateStore for Racing {
789        async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, FaucetError> {
790            self.0.get(key).await
791        }
792        async fn put(&self, key: &str, value: &serde_json::Value) -> Result<(), FaucetError> {
793            self.0.put(key, value).await
794        }
795        async fn delete(&self, key: &str) -> Result<(), FaucetError> {
796            self.0.delete(key).await
797        }
798        async fn compare_and_put(
799            &self,
800            _key: &str,
801            _expected: Option<&serde_json::Value>,
802            _value: &serde_json::Value,
803        ) -> Result<bool, FaucetError> {
804            Ok(false)
805        }
806    }
807
808    /// Removes nothing: every delete fails.
809    struct Sticky(MemoryStateStore);
810
811    #[async_trait::async_trait]
812    impl StateStore for Sticky {
813        async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, FaucetError> {
814            self.0.get(key).await
815        }
816        async fn put(&self, key: &str, value: &serde_json::Value) -> Result<(), FaucetError> {
817            self.0.put(key, value).await
818        }
819        async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
820            Err(FaucetError::State("read-only".into()))
821        }
822    }
823
824    /// A legacy key that cannot be removed still moves: the value is under the
825    /// current key, and the stale copy is left behind (a later run sees the
826    /// current key first).
827    #[tokio::test]
828    async fn a_legacy_key_that_cannot_be_removed_still_moves() {
829        let store = Sticky(MemoryStateStore::new());
830        store.put("p::old", &json!(2)).await.unwrap();
831        migrate_state_key(&store, "p::new", &["p::old".into()])
832            .await
833            .unwrap();
834        assert_eq!(store.get("p::new").await.unwrap(), Some(json!(2)));
835        assert_eq!(store.get("p::old").await.unwrap(), Some(json!(2)));
836        let racing = Racing(MemoryStateStore::new());
837        racing.put("k", &json!(1)).await.unwrap();
838        racing.delete("k").await.unwrap();
839        assert_eq!(racing.get("k").await.unwrap(), None);
840    }
841
842    #[tokio::test]
843    async fn a_concurrent_writer_wins_the_race() {
844        let store = Racing(MemoryStateStore::new());
845        store.put("p::old", &json!(1)).await.unwrap();
846        assert_eq!(
847            migrate_state_key(&store, "p::new", &["p::old".into()])
848                .await
849                .unwrap(),
850            KeyMigration::Current
851        );
852        assert_eq!(store.get("p::old").await.unwrap(), Some(json!(1)));
853    }
854}