Skip to main content

kevy_scope/
scope.rs

1//! `Scope` — one declared `[[cluster.scope]]` entry. Pure data; the
2//! ownership table holds a vec of these.
3
4/// One scope declaration: a key-prefix slice owned by `writer`, with
5/// an optional `fallback` server that takes over writes when the
6/// writer is flagged DOWN by `kevy-elect`.
7///
8/// The prefix is `Vec<u8>` (not `String`) because keys are arbitrary
9/// bytes in kevy; restricting to UTF-8 would be a stricter contract
10/// than the RESP wire offers.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Scope {
13    pub(crate) prefix: Vec<u8>,
14    pub(crate) writer: String,
15    pub(crate) fallback: Option<String>,
16}
17
18impl Scope {
19    /// Build a minimal scope: prefix + writer. Add a fallback via
20    /// [`Self::with_fallback`] if F4 is in play.
21    #[must_use]
22    pub fn new(prefix: Vec<u8>, writer: String) -> Self {
23        Self { prefix, writer, fallback: None }
24    }
25
26    /// Declare a fallback node-id. When the writer is flagged DOWN by
27    /// `kevy-elect`, the fallback starts accepting writes for this
28    /// scope (F4). The fallback is one specific server — not "any
29    /// alive node" — so its identity is operator-visible and not the
30    /// cluster's discretion.
31    #[must_use]
32    pub fn with_fallback(mut self, fallback: String) -> Self {
33        self.fallback = Some(fallback);
34        self
35    }
36
37    /// Key-prefix slice this scope owns. Lifetime tied to the scope
38    /// (not a clone) so longest-prefix routing avoids allocation per
39    /// lookup.
40    #[must_use]
41    pub fn prefix(&self) -> &[u8] {
42        &self.prefix
43    }
44
45    /// Declared writer's node id.
46    #[must_use]
47    pub fn writer(&self) -> &str {
48        &self.writer
49    }
50
51    /// Declared fallback's node id, if any. `None` means "no
52    /// fallback" — when the writer is DOWN, writes for this scope
53    /// fail (the operator chose availability < strict ownership).
54    #[must_use]
55    pub fn fallback(&self) -> Option<&str> {
56        self.fallback.as_deref()
57    }
58
59    /// `true` if `key` starts with this scope's prefix.
60    #[must_use]
61    pub fn matches(&self, key: &[u8]) -> bool {
62        key.starts_with(&self.prefix)
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn matches_starts_with() {
72        let s = Scope::new(b"app:billing:".to_vec(), "w1".to_string());
73        assert!(s.matches(b"app:billing:invoice:42"));
74        assert!(s.matches(b"app:billing:"));
75        assert!(!s.matches(b"app:auth:user:1"));
76        assert!(!s.matches(b"app:billin")); // shorter than prefix
77    }
78
79    #[test]
80    fn with_fallback_sets_fallback() {
81        let s = Scope::new(b"p:".to_vec(), "w".to_string()).with_fallback("f".to_string());
82        assert_eq!(s.fallback(), Some("f"));
83    }
84
85    #[test]
86    fn empty_prefix_matches_anything() {
87        // Edge case: an operator declaring an empty prefix claims the
88        // entire keyspace. Useful for "single-writer cluster" config
89        // where you want one node to own everything by default.
90        let s = Scope::new(Vec::new(), "w".to_string());
91        assert!(s.matches(b"anything"));
92        assert!(s.matches(b""));
93    }
94}