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}