1use crate::dispatch::Dispatcher;
20use crate::oracle::{FinalContended, FinalUnique, OpRecord, OpResult, SoakStats};
21use gonzalo_core::{Body, Identity, Meta, PutResult, Record, RecordKey, RecordKind, Revision};
22use std::collections::BTreeMap;
23use std::sync::Arc;
24use std::sync::atomic::{AtomicU64, Ordering};
25
26#[derive(Debug, Clone)]
29pub struct WorkloadConfig {
30 pub namespace: String,
31 pub collection: String,
32 pub writers: usize,
33 pub shared_keys: usize,
34 pub ops_per_writer: usize,
35 pub unique_per_writer: usize,
36 pub max_conflict_retries: usize,
37}
38
39impl Default for WorkloadConfig {
40 fn default() -> Self {
41 Self {
42 namespace: "soak".into(),
43 collection: "ha".into(),
44 writers: 8,
45 shared_keys: 4,
46 ops_per_writer: 25,
47 unique_per_writer: 4,
48 max_conflict_retries: 50,
49 }
50 }
51}
52
53pub async fn run(dispatcher: Arc<Dispatcher>, cfg: WorkloadConfig) -> SoakStats {
57 let op_ids = Arc::new(AtomicU64::new(1));
58 let mut handles = Vec::new();
59 for w in 0..cfg.writers {
60 let d = dispatcher.clone();
61 let c = cfg.clone();
62 let ids = op_ids.clone();
63 handles.push(tokio::spawn(async move { writer(w, d, c, ids).await }));
64 }
65
66 let mut ops = Vec::new();
67 let mut unique_acked: Vec<(String, Vec<u8>)> = Vec::new();
68 let mut conflicts_observed = 0u64;
69 let mut writers_completed = 0;
70 for h in handles {
71 if let Ok(res) = h.await {
72 ops.extend(res.ops);
73 unique_acked.extend(res.unique_acked);
74 conflicts_observed += res.conflicts;
75 writers_completed += 1;
76 }
77 }
78
79 let contended = collect_contended(&dispatcher, &cfg).await;
80 let unique = collect_unique(&dispatcher, &cfg, &unique_acked).await;
81
82 SoakStats {
83 ops,
84 contended,
85 unique,
86 conflicts_observed,
87 writers_completed,
88 writers_total: cfg.writers,
89 }
90}
91
92struct WriterResult {
93 ops: Vec<OpRecord>,
94 unique_acked: Vec<(String, Vec<u8>)>,
95 conflicts: u64,
96}
97
98async fn writer(
99 w: usize,
100 d: Arc<Dispatcher>,
101 cfg: WorkloadConfig,
102 op_ids: Arc<AtomicU64>,
103) -> WriterResult {
104 let mut ops = Vec::with_capacity(cfg.ops_per_writer);
105 let mut conflicts = 0u64;
106 for _ in 0..cfg.ops_per_writer {
107 let op_id = op_ids.fetch_add(1, Ordering::Relaxed);
108 let key_id = format!("shared-{}", (op_id as usize) % cfg.shared_keys.max(1));
109 let (result, seen) = rmw_append(&d, &cfg, &key_id, op_id).await;
110 conflicts += seen;
111 ops.push(OpRecord {
112 key: key_id,
113 op_id,
114 result,
115 });
116 }
117
118 let mut unique_acked = Vec::with_capacity(cfg.unique_per_writer);
119 for i in 0..cfg.unique_per_writer {
120 let key_id = format!("unique-{w}-{i}");
121 let value = key_id.clone().into_bytes();
122 if create(&d, &cfg, &key_id, &value).await {
123 unique_acked.push((key_id, value));
124 }
125 }
126
127 WriterResult {
128 ops,
129 unique_acked,
130 conflicts,
131 }
132}
133
134async fn rmw_append(
138 d: &Dispatcher,
139 cfg: &WorkloadConfig,
140 key_id: &str,
141 op_id: u64,
142) -> (OpResult, u64) {
143 let key = RecordKey::new(&cfg.namespace, &cfg.collection, key_id);
144 let mut conflicts = 0u64;
145 for _ in 0..=cfg.max_conflict_retries {
146 let current = match d.get(&key).await {
147 Ok(c) => c,
148 Err(_) => return (OpResult::Failed, conflicts),
149 };
150 let (mut members, expected) = match ¤t {
151 Some(rec) => (parse_members(rec.body.bytes()), Some(rec.revision.clone())),
152 None => (Vec::new(), None),
153 };
154 if !members.contains(&op_id) {
155 members.push(op_id);
156 }
157 let record = build_record(&key, &encode_members(&members), expected.clone());
158 match d.put(record, expected).await {
159 Ok(PutResult::Committed(_)) => return (OpResult::Committed, conflicts),
160 Ok(PutResult::Conflict(_)) => {
161 conflicts += 1;
162 continue;
163 }
164 Err(_) => return (OpResult::Failed, conflicts),
165 }
166 }
167 (OpResult::Conflict, conflicts)
169}
170
171async fn create(d: &Dispatcher, cfg: &WorkloadConfig, key_id: &str, value: &[u8]) -> bool {
173 let key = RecordKey::new(&cfg.namespace, &cfg.collection, key_id);
174 let record = build_record(&key, value, None);
175 matches!(d.put(record, None).await, Ok(PutResult::Committed(_)))
176}
177
178async fn collect_contended(d: &Dispatcher, cfg: &WorkloadConfig) -> Vec<FinalContended> {
179 let mut out = Vec::new();
180 for i in 0..cfg.shared_keys {
181 let key_id = format!("shared-{i}");
182 let key = RecordKey::new(&cfg.namespace, &cfg.collection, &key_id);
183 let members = match d.get(&key).await {
184 Ok(Some(rec)) => parse_members(rec.body.bytes()),
185 _ => Vec::new(),
186 };
187 out.push(FinalContended {
188 key: key_id,
189 members,
190 });
191 }
192 out
193}
194
195async fn collect_unique(
196 d: &Dispatcher,
197 cfg: &WorkloadConfig,
198 acked: &[(String, Vec<u8>)],
199) -> Vec<FinalUnique> {
200 let mut out = Vec::new();
201 for (key_id, value) in acked {
202 let key = RecordKey::new(&cfg.namespace, &cfg.collection, key_id);
203 let readable_with_value = matches!(
204 d.get(&key).await,
205 Ok(Some(rec)) if rec.body.bytes() == value.as_slice()
206 );
207 out.push(FinalUnique {
208 key: key_id.clone(),
209 acked: true,
210 readable_with_value,
211 });
212 }
213 out
214}
215
216fn build_record(key: &RecordKey, body_bytes: &[u8], parent: Option<Revision>) -> Record {
217 let body = Body::Inline(body_bytes.to_vec());
218 let revision = match &parent {
219 Some(p) => p.next(body.bytes()),
220 None => Revision::initial(body.bytes()),
221 };
222 Record {
223 revision,
224 parent,
225 body,
226 kind: RecordKind::Topic,
227 meta: Meta {
228 author: Identity::new("soak"),
229 origin_system: "soak".into(),
230 created: 0,
231 updated: 0,
232 labels: BTreeMap::new(),
233 },
234 links: Vec::new(),
235 key: key.clone(),
236 }
237}
238
239fn parse_members(bytes: &[u8]) -> Vec<u64> {
240 std::str::from_utf8(bytes)
241 .unwrap_or("")
242 .split(',')
243 .filter_map(|s| s.trim().parse::<u64>().ok())
244 .collect()
245}
246
247fn encode_members(members: &[u64]) -> Vec<u8> {
248 members
249 .iter()
250 .map(u64::to_string)
251 .collect::<Vec<_>>()
252 .join(",")
253 .into_bytes()
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use gonzalo_core::Store;
260 use gonzalo_store_fs::FsStore;
261
262 #[test]
263 fn parse_round_trips() {
264 assert_eq!(parse_members(&encode_members(&[3, 1, 2])), vec![3, 1, 2]);
265 assert_eq!(parse_members(b""), Vec::<u64>::new());
266 }
267
268 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
272 async fn in_process_fs_replicas_hold_the_invariant() {
273 let dir = tempfile::tempdir().unwrap();
274 let replicas: Vec<Arc<dyn Store>> = (0..3)
275 .map(|_| Arc::new(FsStore::new(dir.path())) as Arc<dyn Store>)
276 .collect();
277 let dispatcher = Arc::new(Dispatcher::new(replicas));
278
279 let cfg = WorkloadConfig {
280 writers: 6,
281 shared_keys: 3,
282 ops_per_writer: 30,
283 unique_per_writer: 3,
284 ..Default::default()
285 };
286 let stats = run(dispatcher, cfg).await;
287
288 let violations = crate::oracle::check(&stats);
289 assert!(violations.is_empty(), "invariant violated: {violations:?}");
290 }
291}