Skip to main content

gonzalo_soak/
dispatch.rs

1//! The replica dispatcher — the harness's stand-in for the k8s Service.
2//!
3//! Holds one [`Store`] handle per `gonzalod` replica, round-robins each op to a
4//! replica, and on a **transport error** (a dead/killed replica) fails over to
5//! the next one. A [`PutResult::Conflict`] is a valid answer from a live replica
6//! and is returned unchanged — it is never a failover trigger (the caller
7//! re-reads and retries the RMW). This is exactly the failover path a k8s agent
8//! pod relies on when a `gonzalod` pod dies behind the Service.
9
10use gonzalo_core::{PutResult, Record, RecordKey, Result, Revision, Store};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicUsize, Ordering};
13
14/// Fans record ops across N replica stores with round-robin + failover.
15pub struct Dispatcher {
16    replicas: Vec<Arc<dyn Store>>,
17    next: AtomicUsize,
18}
19
20impl Dispatcher {
21    /// Build a dispatcher over one or more replica stores.
22    pub fn new(replicas: Vec<Arc<dyn Store>>) -> Self {
23        assert!(
24            !replicas.is_empty(),
25            "dispatcher needs at least one replica"
26        );
27        Self {
28            replicas,
29            next: AtomicUsize::new(0),
30        }
31    }
32
33    /// Number of replica handles (live or not — deadness is discovered per op).
34    pub fn len(&self) -> usize {
35        self.replicas.len()
36    }
37
38    /// Always false; provided to satisfy clippy's `len`-without-`is_empty` lint.
39    pub fn is_empty(&self) -> bool {
40        self.replicas.is_empty()
41    }
42
43    fn start(&self) -> usize {
44        self.next.fetch_add(1, Ordering::Relaxed) % self.replicas.len()
45    }
46
47    /// `get` with round-robin start + failover across all replicas.
48    pub async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
49        let n = self.replicas.len();
50        let start = self.start();
51        let mut last_err = None;
52        for offset in 0..n {
53            let idx = (start + offset) % n;
54            match self.replicas[idx].get(key).await {
55                Ok(v) => return Ok(v),
56                Err(e) => last_err = Some(e),
57            }
58        }
59        Err(last_err.expect("at least one replica was attempted"))
60    }
61
62    /// `put` with round-robin start + failover across all replicas. The record is
63    /// cloned per attempt so a failover can re-issue it to another replica. A
64    /// `Conflict` is a valid `Ok` outcome and returns immediately (not a failover).
65    pub async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
66        let n = self.replicas.len();
67        let start = self.start();
68        let mut last_err = None;
69        for offset in 0..n {
70            let idx = (start + offset) % n;
71            match self.replicas[idx]
72                .put(record.clone(), expected.clone())
73                .await
74            {
75                Ok(v) => return Ok(v),
76                Err(e) => last_err = Some(e),
77            }
78        }
79        Err(last_err.expect("at least one replica was attempted"))
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use async_trait::async_trait;
87    use gonzalo_core::{
88        Body, CoreError, DeleteResult, Identity, KeyPrefix, Meta, RecordKind, store::Conflict,
89    };
90    use std::collections::BTreeMap;
91    use std::sync::atomic::AtomicBool;
92
93    /// A `Store` double whose liveness and canned outcome are controllable.
94    struct MockStore {
95        alive: AtomicBool,
96        calls: AtomicUsize,
97    }
98    impl MockStore {
99        fn alive() -> Arc<Self> {
100            Arc::new(Self {
101                alive: AtomicBool::new(true),
102                calls: AtomicUsize::new(0),
103            })
104        }
105        fn dead() -> Arc<Self> {
106            Arc::new(Self {
107                alive: AtomicBool::new(false),
108                calls: AtomicUsize::new(0),
109            })
110        }
111        fn calls(&self) -> usize {
112            self.calls.load(Ordering::SeqCst)
113        }
114    }
115    #[async_trait]
116    impl Store for MockStore {
117        async fn get(&self, _key: &RecordKey) -> Result<Option<Record>> {
118            self.calls.fetch_add(1, Ordering::SeqCst);
119            if self.alive.load(Ordering::SeqCst) {
120                Ok(None)
121            } else {
122                Err(CoreError::Backend("connection refused".into()))
123            }
124        }
125        async fn put(&self, _record: Record, _expected: Option<Revision>) -> Result<PutResult> {
126            self.calls.fetch_add(1, Ordering::SeqCst);
127            if self.alive.load(Ordering::SeqCst) {
128                Ok(PutResult::Committed(Revision::initial(b"x")))
129            } else {
130                Err(CoreError::Backend("connection refused".into()))
131            }
132        }
133        async fn list(&self, _prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
134            Ok(Vec::new())
135        }
136        async fn delete(
137            &self,
138            _key: &RecordKey,
139            _expected: Option<Revision>,
140        ) -> Result<DeleteResult> {
141            self.calls.fetch_add(1, Ordering::SeqCst);
142            if self.alive.load(Ordering::SeqCst) {
143                Ok(DeleteResult::Deleted)
144            } else {
145                Err(CoreError::Backend("connection refused".into()))
146            }
147        }
148    }
149
150    fn as_store(m: &Arc<MockStore>) -> Arc<dyn Store> {
151        m.clone()
152    }
153
154    fn rec() -> Record {
155        let body = Body::Inline(b"x".to_vec());
156        Record {
157            revision: Revision::initial(body.bytes()),
158            parent: None,
159            body,
160            kind: RecordKind::Topic,
161            meta: Meta {
162                author: Identity::new("soak"),
163                origin_system: "soak".into(),
164                created: 0,
165                updated: 0,
166                labels: BTreeMap::new(),
167            },
168            links: Vec::new(),
169            key: RecordKey::new("ns", "col", "k"),
170        }
171    }
172
173    #[tokio::test]
174    async fn failover_skips_dead_replica() {
175        let dead = MockStore::dead();
176        let live = MockStore::alive();
177        let d = Dispatcher::new(vec![as_store(&dead), as_store(&live)]);
178        // Round-robin starts at replica 0 (dead) → must fail over to 1 (live).
179        let out = d.get(&RecordKey::new("ns", "col", "k")).await;
180        assert!(
181            out.is_ok(),
182            "should have failed over to the live replica: {out:?}"
183        );
184        assert_eq!(dead.calls(), 1, "dead replica was tried");
185        assert_eq!(live.calls(), 1, "then the live replica served it");
186    }
187
188    #[tokio::test]
189    async fn all_dead_returns_error() {
190        let a = MockStore::dead();
191        let b = MockStore::dead();
192        let d = Dispatcher::new(vec![as_store(&a), as_store(&b)]);
193        assert!(d.get(&RecordKey::new("ns", "col", "k")).await.is_err());
194        assert_eq!(a.calls() + b.calls(), 2, "both replicas were attempted");
195    }
196
197    #[tokio::test]
198    async fn live_replica_short_circuits() {
199        let a = MockStore::alive();
200        let b = MockStore::alive();
201        let c = MockStore::alive();
202        let d = Dispatcher::new(vec![as_store(&a), as_store(&b), as_store(&c)]);
203        let _ = d.get(&RecordKey::new("ns", "col", "k")).await.unwrap();
204        assert_eq!(
205            a.calls() + b.calls() + c.calls(),
206            1,
207            "exactly one replica served the op"
208        );
209    }
210
211    #[tokio::test]
212    async fn round_robin_distributes_load() {
213        let a = MockStore::alive();
214        let b = MockStore::alive();
215        let c = MockStore::alive();
216        let d = Dispatcher::new(vec![as_store(&a), as_store(&b), as_store(&c)]);
217        for _ in 0..3 {
218            d.get(&RecordKey::new("ns", "col", "k")).await.unwrap();
219        }
220        assert_eq!(
221            (a.calls(), b.calls(), c.calls()),
222            (1, 1, 1),
223            "round-robin over 3 replicas"
224        );
225    }
226
227    #[tokio::test]
228    async fn put_fails_over_too() {
229        let dead = MockStore::dead();
230        let live = MockStore::alive();
231        let d = Dispatcher::new(vec![as_store(&dead), as_store(&live)]);
232        let out = d.put(rec(), None).await;
233        assert!(
234            matches!(out, Ok(PutResult::Committed(_))),
235            "put failed over: {out:?}"
236        );
237    }
238
239    // A conflict from a live replica is returned as-is, never retried elsewhere.
240    #[tokio::test]
241    async fn conflict_is_returned_not_retried() {
242        struct Conflicter(AtomicUsize);
243        #[async_trait]
244        impl Store for Conflicter {
245            async fn get(&self, _k: &RecordKey) -> Result<Option<Record>> {
246                Ok(None)
247            }
248            async fn put(&self, _r: Record, _e: Option<Revision>) -> Result<PutResult> {
249                self.0.fetch_add(1, Ordering::SeqCst);
250                Ok(PutResult::Conflict(Box::new(Conflict {
251                    key: RecordKey::new("ns", "col", "k"),
252                    expected: None,
253                    current: rec(),
254                })))
255            }
256            async fn list(&self, _p: &KeyPrefix) -> Result<Vec<RecordKey>> {
257                Ok(Vec::new())
258            }
259            async fn delete(&self, _k: &RecordKey, _e: Option<Revision>) -> Result<DeleteResult> {
260                Ok(DeleteResult::Deleted)
261            }
262        }
263        let c0 = Arc::new(Conflicter(AtomicUsize::new(0)));
264        let c1 = Arc::new(Conflicter(AtomicUsize::new(0)));
265        let d = Dispatcher::new(vec![
266            c0.clone() as Arc<dyn Store>,
267            c1.clone() as Arc<dyn Store>,
268        ]);
269        let out = d.put(rec(), None).await;
270        assert!(
271            matches!(out, Ok(PutResult::Conflict(_))),
272            "conflict returned: {out:?}"
273        );
274        assert_eq!(
275            c0.0.load(Ordering::SeqCst) + c1.0.load(Ordering::SeqCst),
276            1,
277            "a conflict is a definitive answer — not retried on another replica"
278        );
279    }
280}