1use gonzalo_core::{PutResult, Record, RecordKey, Result, Revision, Store};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicUsize, Ordering};
13
14pub struct Dispatcher {
16 replicas: Vec<Arc<dyn Store>>,
17 next: AtomicUsize,
18}
19
20impl Dispatcher {
21 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 pub fn len(&self) -> usize {
35 self.replicas.len()
36 }
37
38 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 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 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 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 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 #[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}