1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use tokio::io::AsyncWriteExt;
7use tokio::sync::Mutex;
8
9const MAX_DECISION_LEDGER_BYTES: usize = 16 * 1024 * 1024;
10const DECISION_LEDGER_SCHEMA_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum FlowDecisionClaimOutcome {
14 Claimed { attempt: u32 },
15 Completed,
16 Busy { lease_expires_at_ms: u64 },
17 Conflict,
18}
19
20#[async_trait]
21pub trait FlowDecisionLedger: Send + Sync {
22 async fn claim(
23 &self,
24 decision_id: &str,
25 request_hash: &str,
26 owner_id: &str,
27 now_ms: u64,
28 lease_ms: u64,
29 ) -> Result<FlowDecisionClaimOutcome>;
30
31 async fn renew(
34 &self,
35 decision_id: &str,
36 request_hash: &str,
37 owner_id: &str,
38 now_ms: u64,
39 lease_ms: u64,
40 ) -> Result<bool>;
41
42 async fn complete(
43 &self,
44 decision_id: &str,
45 request_hash: &str,
46 owner_id: &str,
47 completed_at_ms: u64,
48 ) -> Result<()>;
49
50 async fn release(&self, decision_id: &str, request_hash: &str, owner_id: &str) -> Result<()>;
51
52 async fn prune_completed(&self, _before_ms: u64) -> Result<usize> {
54 anyhow::bail!("Flow decision ledger does not support receipt pruning")
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60enum ClaimStatus {
61 Pending,
62 Completed,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66struct ClaimRecord {
67 request_hash: String,
68 status: ClaimStatus,
69 owner_id: String,
70 lease_expires_at_ms: u64,
71 attempts: u32,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 completed_at_ms: Option<u64>,
74}
75
76#[derive(Debug, Default, Serialize, Deserialize)]
77struct DecisionLedgerFile {
78 schema_version: u32,
79 records: BTreeMap<String, ClaimRecord>,
80}
81
82#[derive(Serialize)]
83struct DecisionLedgerFileRef<'a> {
84 schema_version: u32,
85 records: &'a BTreeMap<String, ClaimRecord>,
86}
87
88#[derive(Debug, Default)]
89pub struct MemoryFlowDecisionLedger {
90 records: Mutex<BTreeMap<String, ClaimRecord>>,
91}
92
93impl MemoryFlowDecisionLedger {
94 pub fn new() -> Self {
95 Self::default()
96 }
97}
98
99#[async_trait]
100impl FlowDecisionLedger for MemoryFlowDecisionLedger {
101 async fn claim(
102 &self,
103 decision_id: &str,
104 request_hash: &str,
105 owner_id: &str,
106 now_ms: u64,
107 lease_ms: u64,
108 ) -> Result<FlowDecisionClaimOutcome> {
109 let mut records = self.records.lock().await;
110 claim_record(
111 &mut records,
112 decision_id,
113 request_hash,
114 owner_id,
115 now_ms,
116 lease_ms,
117 )
118 }
119
120 async fn renew(
121 &self,
122 decision_id: &str,
123 request_hash: &str,
124 owner_id: &str,
125 now_ms: u64,
126 lease_ms: u64,
127 ) -> Result<bool> {
128 let mut records = self.records.lock().await;
129 Ok(renew_record(
130 &mut records,
131 decision_id,
132 request_hash,
133 owner_id,
134 now_ms,
135 lease_ms,
136 ))
137 }
138
139 async fn complete(
140 &self,
141 decision_id: &str,
142 request_hash: &str,
143 owner_id: &str,
144 completed_at_ms: u64,
145 ) -> Result<()> {
146 let mut records = self.records.lock().await;
147 complete_record(
148 &mut records,
149 decision_id,
150 request_hash,
151 owner_id,
152 completed_at_ms,
153 )
154 }
155
156 async fn release(&self, decision_id: &str, request_hash: &str, owner_id: &str) -> Result<()> {
157 let mut records = self.records.lock().await;
158 release_record(&mut records, decision_id, request_hash, owner_id)
159 }
160
161 async fn prune_completed(&self, before_ms: u64) -> Result<usize> {
162 let mut records = self.records.lock().await;
163 Ok(prune_records(&mut records, before_ms))
164 }
165}
166
167#[derive(Debug)]
168pub struct FileFlowDecisionLedger {
169 root: PathBuf,
170 process_lock: Mutex<()>,
171}
172
173impl FileFlowDecisionLedger {
174 pub fn new(root: impl Into<PathBuf>) -> Self {
175 Self {
176 root: root.into(),
177 process_lock: Mutex::new(()),
178 }
179 }
180
181 fn data_path(&self) -> PathBuf {
182 self.root.join("flow-decisions.json")
183 }
184
185 async fn acquire_file_lock(&self) -> Result<std::fs::File> {
186 tokio::fs::create_dir_all(&self.root)
187 .await
188 .with_context(|| format!("create decision ledger `{}`", self.root.display()))?;
189 let path = self.root.join(".flow-decisions.lock");
190 tokio::task::spawn_blocking(move || {
191 use fs2::FileExt;
192 let file = std::fs::OpenOptions::new()
193 .create(true)
194 .truncate(false)
195 .read(true)
196 .write(true)
197 .open(&path)
198 .with_context(|| format!("open decision ledger lock `{}`", path.display()))?;
199 file.lock_exclusive()
200 .with_context(|| format!("lock decision ledger `{}`", path.display()))?;
201 Ok(file)
202 })
203 .await
204 .context("decision ledger lock task failed")?
205 }
206
207 async fn mutate<T>(
208 &self,
209 mutation: impl FnOnce(&mut BTreeMap<String, ClaimRecord>) -> Result<T>,
210 ) -> Result<T> {
211 let _process_guard = self.process_lock.lock().await;
212 let _file_guard = self.acquire_file_lock().await?;
213 let mut records = read_records(&self.data_path()).await?;
214 let result = mutation(&mut records)?;
215 write_records(&self.data_path(), &records).await?;
216 Ok(result)
217 }
218}
219
220#[async_trait]
221impl FlowDecisionLedger for FileFlowDecisionLedger {
222 async fn claim(
223 &self,
224 decision_id: &str,
225 request_hash: &str,
226 owner_id: &str,
227 now_ms: u64,
228 lease_ms: u64,
229 ) -> Result<FlowDecisionClaimOutcome> {
230 self.mutate(|records| {
231 claim_record(
232 records,
233 decision_id,
234 request_hash,
235 owner_id,
236 now_ms,
237 lease_ms,
238 )
239 })
240 .await
241 }
242
243 async fn renew(
244 &self,
245 decision_id: &str,
246 request_hash: &str,
247 owner_id: &str,
248 now_ms: u64,
249 lease_ms: u64,
250 ) -> Result<bool> {
251 self.mutate(|records| {
252 Ok(renew_record(
253 records,
254 decision_id,
255 request_hash,
256 owner_id,
257 now_ms,
258 lease_ms,
259 ))
260 })
261 .await
262 }
263
264 async fn complete(
265 &self,
266 decision_id: &str,
267 request_hash: &str,
268 owner_id: &str,
269 completed_at_ms: u64,
270 ) -> Result<()> {
271 self.mutate(|records| {
272 complete_record(
273 records,
274 decision_id,
275 request_hash,
276 owner_id,
277 completed_at_ms,
278 )
279 })
280 .await
281 }
282
283 async fn release(&self, decision_id: &str, request_hash: &str, owner_id: &str) -> Result<()> {
284 self.mutate(|records| release_record(records, decision_id, request_hash, owner_id))
285 .await
286 }
287
288 async fn prune_completed(&self, before_ms: u64) -> Result<usize> {
289 self.mutate(|records| Ok(prune_records(records, before_ms)))
290 .await
291 }
292}
293
294fn claim_record(
295 records: &mut BTreeMap<String, ClaimRecord>,
296 decision_id: &str,
297 request_hash: &str,
298 owner_id: &str,
299 now_ms: u64,
300 lease_ms: u64,
301) -> Result<FlowDecisionClaimOutcome> {
302 let lease_expires_at_ms = now_ms.saturating_add(lease_ms.max(1));
303 match records.get_mut(decision_id) {
304 None => {
305 records.insert(
306 decision_id.to_string(),
307 ClaimRecord {
308 request_hash: request_hash.to_string(),
309 status: ClaimStatus::Pending,
310 owner_id: owner_id.to_string(),
311 lease_expires_at_ms,
312 attempts: 1,
313 completed_at_ms: None,
314 },
315 );
316 Ok(FlowDecisionClaimOutcome::Claimed { attempt: 1 })
317 }
318 Some(record) if record.request_hash != request_hash => {
319 Ok(FlowDecisionClaimOutcome::Conflict)
320 }
321 Some(record) if record.status == ClaimStatus::Completed => {
322 Ok(FlowDecisionClaimOutcome::Completed)
323 }
324 Some(record) if record.lease_expires_at_ms > now_ms && record.owner_id != owner_id => {
325 Ok(FlowDecisionClaimOutcome::Busy {
326 lease_expires_at_ms: record.lease_expires_at_ms,
327 })
328 }
329 Some(record) => {
330 record.owner_id = owner_id.to_string();
331 record.lease_expires_at_ms = lease_expires_at_ms;
332 record.attempts = record.attempts.saturating_add(1);
333 Ok(FlowDecisionClaimOutcome::Claimed {
334 attempt: record.attempts,
335 })
336 }
337 }
338}
339
340fn complete_record(
341 records: &mut BTreeMap<String, ClaimRecord>,
342 decision_id: &str,
343 request_hash: &str,
344 owner_id: &str,
345 completed_at_ms: u64,
346) -> Result<()> {
347 let record = records
348 .get_mut(decision_id)
349 .with_context(|| format!("decision claim `{decision_id}` does not exist"))?;
350 if record.request_hash != request_hash {
351 anyhow::bail!("decision `{decision_id}` request hash conflicts with its claim");
352 }
353 if record.status == ClaimStatus::Completed {
354 return Ok(());
355 }
356 if record.owner_id != owner_id {
357 anyhow::bail!("decision `{decision_id}` is owned by another dispatcher");
358 }
359 record.status = ClaimStatus::Completed;
360 record.lease_expires_at_ms = 0;
361 record.completed_at_ms = Some(completed_at_ms);
362 Ok(())
363}
364
365fn renew_record(
366 records: &mut BTreeMap<String, ClaimRecord>,
367 decision_id: &str,
368 request_hash: &str,
369 owner_id: &str,
370 now_ms: u64,
371 lease_ms: u64,
372) -> bool {
373 let Some(record) = records.get_mut(decision_id) else {
374 return false;
375 };
376 if record.request_hash != request_hash
377 || record.status != ClaimStatus::Pending
378 || record.owner_id != owner_id
379 {
380 return false;
381 }
382 record.lease_expires_at_ms = now_ms.saturating_add(lease_ms.max(1));
383 true
384}
385
386fn release_record(
387 records: &mut BTreeMap<String, ClaimRecord>,
388 decision_id: &str,
389 request_hash: &str,
390 owner_id: &str,
391) -> Result<()> {
392 let Some(record) = records.get_mut(decision_id) else {
393 return Ok(());
394 };
395 if record.request_hash != request_hash || record.status == ClaimStatus::Completed {
396 return Ok(());
397 }
398 if record.owner_id == owner_id {
399 record.owner_id.clear();
400 record.lease_expires_at_ms = 0;
401 }
402 Ok(())
403}
404
405fn prune_records(records: &mut BTreeMap<String, ClaimRecord>, before_ms: u64) -> usize {
406 let before = records.len();
407 records.retain(|_, record| {
408 record.status != ClaimStatus::Completed
409 || record.completed_at_ms.unwrap_or(u64::MAX) >= before_ms
410 });
411 before - records.len()
412}
413
414async fn read_records(path: &Path) -> Result<BTreeMap<String, ClaimRecord>> {
415 let bytes = match tokio::fs::read(path).await {
416 Ok(bytes) => bytes,
417 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
418 Err(error) => return Err(error).context("read decision ledger"),
419 };
420 if bytes.len() > MAX_DECISION_LEDGER_BYTES {
421 anyhow::bail!("decision ledger exceeds {MAX_DECISION_LEDGER_BYTES} bytes");
422 }
423 let ledger: DecisionLedgerFile =
424 serde_json::from_slice(&bytes).context("decode decision ledger")?;
425 if ledger.schema_version > DECISION_LEDGER_SCHEMA_VERSION {
426 anyhow::bail!(
427 "decision ledger schema {} is newer than supported schema {}",
428 ledger.schema_version,
429 DECISION_LEDGER_SCHEMA_VERSION
430 );
431 }
432 Ok(ledger.records)
433}
434
435async fn write_records(path: &Path, records: &BTreeMap<String, ClaimRecord>) -> Result<()> {
436 let bytes = serde_json::to_vec(&DecisionLedgerFileRef {
437 schema_version: DECISION_LEDGER_SCHEMA_VERSION,
438 records,
439 })
440 .context("encode decision ledger")?;
441 if bytes.len() > MAX_DECISION_LEDGER_BYTES {
442 anyhow::bail!("decision ledger exceeds {MAX_DECISION_LEDGER_BYTES} bytes");
443 }
444 let temp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
445 let result = async {
446 let mut file = tokio::fs::File::create(&temp)
447 .await
448 .context("create decision ledger generation")?;
449 file.write_all(&bytes)
450 .await
451 .context("write decision ledger")?;
452 file.sync_all().await.context("sync decision ledger")?;
453 drop(file);
454 let temp_copy = temp.clone();
455 let path = path.to_path_buf();
456 tokio::task::spawn_blocking(move || {
457 tempfile::TempPath::try_from_path(temp_copy)?
458 .persist(path)
459 .map_err(|error| error.error)
460 })
461 .await
462 .context("publish decision ledger task failed")??;
463 Ok::<_, anyhow::Error>(())
464 }
465 .await;
466 if result.is_err() {
467 let _ = tokio::fs::remove_file(temp).await;
468 }
469 result
470}