1use super::decision_ledger::{
2 FlowDecisionClaimOutcome, FlowDecisionLedger, MemoryFlowDecisionLedger,
3};
4use crate::execution_identity::{
5 ExecutionClaimV1, ExecutionResultOutcomeV1, ExecutionResultReceiptV1,
6};
7use a3s_flow::RetryPolicy;
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14use thiserror::Error;
15use tokio::sync::Mutex;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18#[serde(tag = "type", rename_all = "snake_case")]
19pub enum FlowDecision {
20 ScheduleStep { step: FlowDecisionStep },
21 ScheduleSteps { steps: Vec<FlowDecisionStep> },
22 Complete { output: Value },
23 Fail { error: String },
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27pub struct FlowDecisionStep {
28 pub step_id: String,
29 pub step_name: String,
30 pub input: Value,
31 #[serde(default)]
32 pub retry: RetryPolicy,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct FlowDecisionRequest {
38 pub decision_id: String,
39 pub run_id: String,
40 pub authority_branch_id: String,
41 pub causation_event_id: String,
42 pub decision: FlowDecision,
43}
44
45#[async_trait]
46pub trait FlowDecisionSink: Send + Sync {
47 async fn submit(
51 &self,
52 request: &FlowDecisionRequest,
53 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
54}
55
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "snake_case")]
58pub enum FlowDecisionHealthStatus {
59 Healthy,
60 Degraded,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct FlowDecisionHealthSnapshot {
65 pub status: FlowDecisionHealthStatus,
66 pub attempted: u64,
67 pub claimed: u64,
68 pub takeovers: u64,
69 pub completed: u64,
70 pub duplicates: u64,
71 pub busy: u64,
72 pub conflicts: u64,
73 pub rejected: u64,
74 pub lease_renewals: u64,
75 pub lease_lost: u64,
76 pub sink_failures: u64,
77 pub ledger_failures: u64,
78 pub failures: u64,
79 pub cancellations: u64,
80 pub in_flight: u64,
81 pub average_dispatch_micros: u64,
82 pub max_dispatch_micros: u64,
83 pub last_success_at_ms: Option<u64>,
84 pub last_failure_at_ms: Option<u64>,
85}
86
87#[derive(Default)]
88struct FlowDecisionMetrics {
89 attempted: AtomicU64,
90 claimed: AtomicU64,
91 takeovers: AtomicU64,
92 completed: AtomicU64,
93 duplicates: AtomicU64,
94 busy: AtomicU64,
95 conflicts: AtomicU64,
96 rejected: AtomicU64,
97 lease_renewals: AtomicU64,
98 lease_lost: AtomicU64,
99 sink_failures: AtomicU64,
100 ledger_failures: AtomicU64,
101 failures: AtomicU64,
102 cancellations: AtomicU64,
103 in_flight: AtomicU64,
104 total_dispatch_micros: AtomicU64,
105 max_dispatch_micros: AtomicU64,
106 last_success_at_ms: AtomicU64,
107 last_failure_at_ms: AtomicU64,
108 degraded: AtomicBool,
109}
110
111pub struct FlowDecisionDispatcher {
113 production_branch_id: String,
114 sink: Arc<dyn FlowDecisionSink>,
115 ledger: Arc<dyn FlowDecisionLedger>,
116 owner_id: String,
117 lease_ms: u64,
118 dispatch_lock: Mutex<()>,
119 metrics: Arc<FlowDecisionMetrics>,
120}
121
122impl FlowDecisionDispatcher {
123 pub fn new(production_branch_id: impl Into<String>, sink: Arc<dyn FlowDecisionSink>) -> Self {
124 Self::with_ledger(
125 production_branch_id,
126 sink,
127 Arc::new(MemoryFlowDecisionLedger::new()),
128 )
129 }
130
131 pub fn with_ledger(
132 production_branch_id: impl Into<String>,
133 sink: Arc<dyn FlowDecisionSink>,
134 ledger: Arc<dyn FlowDecisionLedger>,
135 ) -> Self {
136 Self {
137 production_branch_id: production_branch_id.into(),
138 sink,
139 ledger,
140 owner_id: format!("decision-dispatcher-{}", uuid::Uuid::new_v4()),
141 lease_ms: 30_000,
142 dispatch_lock: Mutex::new(()),
143 metrics: Arc::new(FlowDecisionMetrics::default()),
144 }
145 }
146
147 pub fn with_lease_ms(mut self, lease_ms: u64) -> Self {
148 self.lease_ms = lease_ms.max(1);
149 self
150 }
151
152 pub fn health(&self) -> FlowDecisionHealthSnapshot {
153 let attempted = self.metrics.attempted.load(Ordering::Relaxed);
154 let total_micros = self.metrics.total_dispatch_micros.load(Ordering::Relaxed);
155 let last_success_at_ms = nonzero(self.metrics.last_success_at_ms.load(Ordering::Relaxed));
156 let last_failure_at_ms = nonzero(self.metrics.last_failure_at_ms.load(Ordering::Relaxed));
157 FlowDecisionHealthSnapshot {
158 status: if self.metrics.degraded.load(Ordering::Relaxed) {
159 FlowDecisionHealthStatus::Degraded
160 } else {
161 FlowDecisionHealthStatus::Healthy
162 },
163 attempted,
164 claimed: self.metrics.claimed.load(Ordering::Relaxed),
165 takeovers: self.metrics.takeovers.load(Ordering::Relaxed),
166 completed: self.metrics.completed.load(Ordering::Relaxed),
167 duplicates: self.metrics.duplicates.load(Ordering::Relaxed),
168 busy: self.metrics.busy.load(Ordering::Relaxed),
169 conflicts: self.metrics.conflicts.load(Ordering::Relaxed),
170 rejected: self.metrics.rejected.load(Ordering::Relaxed),
171 lease_renewals: self.metrics.lease_renewals.load(Ordering::Relaxed),
172 lease_lost: self.metrics.lease_lost.load(Ordering::Relaxed),
173 sink_failures: self.metrics.sink_failures.load(Ordering::Relaxed),
174 ledger_failures: self.metrics.ledger_failures.load(Ordering::Relaxed),
175 failures: self.metrics.failures.load(Ordering::Relaxed),
176 cancellations: self.metrics.cancellations.load(Ordering::Relaxed),
177 in_flight: self.metrics.in_flight.load(Ordering::Relaxed),
178 average_dispatch_micros: total_micros.checked_div(attempted).unwrap_or(0),
179 max_dispatch_micros: self.metrics.max_dispatch_micros.load(Ordering::Relaxed),
180 last_success_at_ms,
181 last_failure_at_ms,
182 }
183 }
184
185 pub async fn dispatch(
187 &self,
188 request: &FlowDecisionRequest,
189 ) -> Result<bool, FlowDecisionDispatchError> {
190 let mut in_flight = DecisionInFlight::new(Arc::clone(&self.metrics));
191 let result = self.dispatch_inner(request).await;
192 in_flight.finish();
193 self.record_result(&result);
194 result
195 }
196
197 async fn dispatch_inner(
198 &self,
199 request: &FlowDecisionRequest,
200 ) -> Result<bool, FlowDecisionDispatchError> {
201 if request.authority_branch_id != self.production_branch_id {
202 return Err(FlowDecisionDispatchError::UnauthorizedBranch {
203 expected: self.production_branch_id.clone(),
204 actual: request.authority_branch_id.clone(),
205 });
206 }
207 if request.decision_id.trim().is_empty() || request.causation_event_id.trim().is_empty() {
208 return Err(FlowDecisionDispatchError::InvalidIdentity);
209 }
210 let _dispatch_guard = self.dispatch_lock.lock().await;
211 let request_hash = request_hash(request)?;
212 let execution_identity = request_identity(request)?;
213 let claim = ExecutionClaimV1::new(
214 execution_identity,
215 &request.decision_id,
216 &request_hash,
217 &self.owner_id,
218 )
219 .map_err(|error| FlowDecisionDispatchError::Ledger(error.to_string()))?;
220 let result_receipt = decision_result_receipt(&claim, request)?;
221 tracing::trace!(
222 decision_id = request.decision_id.as_str(),
223 identity = claim.identity().key(),
224 "Flow decision execution identity bound to claim ledger"
225 );
226 match self
227 .ledger
228 .claim_with_identity(
229 claim.record_id(),
230 claim.ledger_key(),
231 claim.identity(),
232 claim.owner_id(),
233 now_ms(),
234 self.lease_ms,
235 )
236 .await
237 .map_err(|error| FlowDecisionDispatchError::Ledger(error.to_string()))?
238 {
239 FlowDecisionClaimOutcome::Completed => return Ok(false),
240 FlowDecisionClaimOutcome::Busy {
241 lease_expires_at_ms,
242 } => {
243 return Err(FlowDecisionDispatchError::Busy {
244 lease_expires_at_ms,
245 })
246 }
247 FlowDecisionClaimOutcome::Conflict => {
248 return Err(FlowDecisionDispatchError::DecisionIdConflict(
249 request.decision_id.clone(),
250 ))
251 }
252 FlowDecisionClaimOutcome::Claimed { attempt } => {
253 self.metrics.claimed.fetch_add(1, Ordering::Relaxed);
254 if attempt > 1 {
255 self.metrics.takeovers.fetch_add(1, Ordering::Relaxed);
256 }
257 }
258 }
259 let heartbeat_period = Duration::from_millis((self.lease_ms / 3).max(1));
260 let first_heartbeat = tokio::time::Instant::now() + heartbeat_period;
261 let mut heartbeat = tokio::time::interval_at(first_heartbeat, heartbeat_period);
262 let submission = self.sink.submit(request);
263 tokio::pin!(submission);
264 let submission_result = loop {
265 tokio::select! {
266 result = &mut submission => break result,
267 _ = heartbeat.tick() => {
268 let renewed = self.ledger
269 .renew_with_identity(
270 claim.record_id(),
271 claim.ledger_key(),
272 claim.identity(),
273 claim.owner_id(),
274 now_ms(),
275 self.lease_ms,
276 )
277 .await
278 .map_err(|error| FlowDecisionDispatchError::Ledger(error.to_string()))?;
279 if !renewed {
280 return Err(FlowDecisionDispatchError::LeaseLost(
281 request.decision_id.clone(),
282 ));
283 }
284 self.metrics.lease_renewals.fetch_add(1, Ordering::Relaxed);
285 }
286 }
287 };
288 if let Err(error) = submission_result {
289 if let Err(release_error) = self
290 .ledger
291 .release_with_identity(
292 claim.record_id(),
293 claim.ledger_key(),
294 claim.identity(),
295 claim.owner_id(),
296 )
297 .await
298 {
299 tracing::warn!(decision_id = request.decision_id, error = %release_error, "failed to release Flow decision claim");
300 }
301 return Err(FlowDecisionDispatchError::Sink(error.to_string()));
302 }
303 self.ledger
304 .complete_with_receipt(
305 claim.record_id(),
306 claim.ledger_key(),
307 claim.identity(),
308 claim.owner_id(),
309 &result_receipt,
310 now_ms(),
311 )
312 .await
313 .map_err(|error| FlowDecisionDispatchError::Ledger(error.to_string()))?;
314 Ok(true)
315 }
316
317 fn record_result(&self, result: &Result<bool, FlowDecisionDispatchError>) {
318 match result {
319 Ok(true) => {
320 self.metrics.completed.fetch_add(1, Ordering::Relaxed);
321 self.metrics.degraded.store(false, Ordering::Relaxed);
322 self.metrics
323 .last_success_at_ms
324 .store(now_ms(), Ordering::Relaxed);
325 }
326 Ok(false) => {
327 self.metrics.duplicates.fetch_add(1, Ordering::Relaxed);
328 self.metrics.degraded.store(false, Ordering::Relaxed);
329 self.metrics
330 .last_success_at_ms
331 .store(now_ms(), Ordering::Relaxed);
332 }
333 Err(FlowDecisionDispatchError::Busy { .. }) => {
334 self.metrics.busy.fetch_add(1, Ordering::Relaxed);
335 }
336 Err(FlowDecisionDispatchError::DecisionIdConflict(_)) => {
337 self.metrics.conflicts.fetch_add(1, Ordering::Relaxed);
338 }
339 Err(
340 FlowDecisionDispatchError::UnauthorizedBranch { .. }
341 | FlowDecisionDispatchError::InvalidIdentity,
342 ) => {
343 self.metrics.rejected.fetch_add(1, Ordering::Relaxed);
344 }
345 Err(FlowDecisionDispatchError::LeaseLost(_)) => {
346 self.metrics.lease_lost.fetch_add(1, Ordering::Relaxed);
347 self.record_failure();
348 }
349 Err(FlowDecisionDispatchError::Ledger(_)) => {
350 self.metrics.ledger_failures.fetch_add(1, Ordering::Relaxed);
351 self.record_failure();
352 }
353 Err(FlowDecisionDispatchError::Sink(_)) => {
354 self.metrics.sink_failures.fetch_add(1, Ordering::Relaxed);
355 self.record_failure();
356 }
357 }
358 }
359
360 fn record_failure(&self) {
361 self.metrics.failures.fetch_add(1, Ordering::Relaxed);
362 self.metrics.degraded.store(true, Ordering::Relaxed);
363 self.metrics
364 .last_failure_at_ms
365 .store(now_ms(), Ordering::Relaxed);
366 }
367}
368
369struct DecisionInFlight {
370 metrics: Arc<FlowDecisionMetrics>,
371 started: Instant,
372 completed: bool,
373}
374
375impl DecisionInFlight {
376 fn new(metrics: Arc<FlowDecisionMetrics>) -> Self {
377 metrics.attempted.fetch_add(1, Ordering::Relaxed);
378 metrics.in_flight.fetch_add(1, Ordering::Relaxed);
379 Self {
380 metrics,
381 started: Instant::now(),
382 completed: false,
383 }
384 }
385
386 fn finish(&mut self) {
387 self.record_elapsed();
388 self.metrics.in_flight.fetch_sub(1, Ordering::Relaxed);
389 self.completed = true;
390 }
391
392 fn record_elapsed(&self) {
393 let elapsed = self.started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64;
394 self.metrics
395 .total_dispatch_micros
396 .fetch_add(elapsed, Ordering::Relaxed);
397 self.metrics
398 .max_dispatch_micros
399 .fetch_max(elapsed, Ordering::Relaxed);
400 }
401}
402
403impl Drop for DecisionInFlight {
404 fn drop(&mut self) {
405 if self.completed {
406 return;
407 }
408 self.record_elapsed();
409 self.metrics.in_flight.fetch_sub(1, Ordering::Relaxed);
410 self.metrics.failures.fetch_add(1, Ordering::Relaxed);
411 self.metrics.cancellations.fetch_add(1, Ordering::Relaxed);
412 self.metrics.degraded.store(true, Ordering::Relaxed);
413 self.metrics
414 .last_failure_at_ms
415 .store(now_ms(), Ordering::Relaxed);
416 }
417}
418
419#[derive(Debug, Error, PartialEq, Eq)]
420pub enum FlowDecisionDispatchError {
421 #[error("graph branch is not authorized for production Flow decisions: expected `{expected}`, got `{actual}`")]
422 UnauthorizedBranch { expected: String, actual: String },
423 #[error("decision_id and causation_event_id must be non-empty")]
424 InvalidIdentity,
425 #[error("Flow decision `{0}` reuses an existing decision id with different content")]
426 DecisionIdConflict(String),
427 #[error("Flow decision is owned by another dispatcher until {lease_expires_at_ms}")]
428 Busy { lease_expires_at_ms: u64 },
429 #[error("Flow decision `{0}` lost its lease while the sink was running")]
430 LeaseLost(String),
431 #[error("Flow decision ledger failed: {0}")]
432 Ledger(String),
433 #[error("Flow decision sink failed: {0}")]
434 Sink(String),
435}
436
437fn request_hash(request: &FlowDecisionRequest) -> Result<String, FlowDecisionDispatchError> {
438 serde_json::to_vec(request)
439 .map(sha256::digest)
440 .map_err(|error| FlowDecisionDispatchError::Ledger(error.to_string()))
441}
442
443fn request_identity(
444 request: &FlowDecisionRequest,
445) -> Result<crate::execution_identity::ExecutionIdentityV1, FlowDecisionDispatchError> {
446 crate::execution_identity::ExecutionIdentityV1::derive(
447 crate::execution_identity::FLOW_DECISION_IDENTITY_DOMAIN_V1,
448 request,
449 )
450 .map_err(|error| FlowDecisionDispatchError::Ledger(error.to_string()))
451}
452
453fn decision_result_receipt(
454 claim: &ExecutionClaimV1,
455 request: &FlowDecisionRequest,
456) -> Result<ExecutionResultReceiptV1, FlowDecisionDispatchError> {
457 let result = serde_json::to_vec(&request.decision)
458 .map_err(|error| FlowDecisionDispatchError::Ledger(error.to_string()))?;
459 let evidence_digest = crate::evaluation::digest_bytes(
460 "a3s.code.flow-decision.evidence.v1",
461 request.causation_event_id.as_bytes(),
462 );
463 let result_digest =
464 crate::evaluation::digest_bytes("a3s.code.flow-decision.result.v1", &result);
465 let result_bytes = u64::try_from(result.len())
466 .map_err(|_| FlowDecisionDispatchError::Ledger("decision result is too large".into()))?;
467 claim
468 .result_receipt(
469 evidence_digest,
470 ExecutionResultOutcomeV1::Succeeded,
471 Some(result_digest),
472 result_bytes,
473 )
474 .map_err(|error| FlowDecisionDispatchError::Ledger(error.to_string()))
475}
476
477fn now_ms() -> u64 {
478 use std::time::{SystemTime, UNIX_EPOCH};
479 SystemTime::now()
480 .duration_since(UNIX_EPOCH)
481 .unwrap_or_default()
482 .as_millis()
483 .min(u128::from(u64::MAX)) as u64
484}
485
486fn nonzero(value: u64) -> Option<u64> {
487 (value != 0).then_some(value)
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::{FileFlowDecisionLedger, FlowDecisionLedger};
494 use std::sync::atomic::{AtomicUsize, Ordering};
495
496 #[derive(Default)]
497 struct RecordingSink(Mutex<Vec<String>>);
498
499 #[async_trait]
500 impl FlowDecisionSink for RecordingSink {
501 async fn submit(
502 &self,
503 request: &FlowDecisionRequest,
504 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
505 self.0.lock().await.push(request.decision_id.clone());
506 Ok(())
507 }
508 }
509
510 fn request(branch: &str) -> FlowDecisionRequest {
511 FlowDecisionRequest {
512 decision_id: "decision-1".into(),
513 run_id: "run-1".into(),
514 authority_branch_id: branch.into(),
515 causation_event_id: "event-1".into(),
516 decision: FlowDecision::Complete {
517 output: Value::Null,
518 },
519 }
520 }
521
522 #[test]
523 fn flow_claim_keeps_legacy_ledger_key_while_binding_typed_identity() {
524 let request = request("production");
525 let legacy = request_hash(&request).unwrap();
526 let identity = request_identity(&request).unwrap();
527 let claim = crate::execution_identity::ExecutionClaimV1::new(
528 identity.clone(),
529 &request.decision_id,
530 &legacy,
531 "owner-1",
532 )
533 .unwrap();
534
535 assert_eq!(legacy.len(), 64);
536 assert!(!legacy.starts_with("sha256:"));
537 assert_eq!(claim.ledger_key(), legacy);
538 assert_ne!(claim.identity().key(), legacy);
539 claim.identity().validate_for(&request).unwrap();
540 }
541
542 #[tokio::test]
543 async fn submits_once_and_rejects_fork_branches() {
544 let sink = Arc::new(RecordingSink::default());
545 let ledger = Arc::new(MemoryFlowDecisionLedger::new());
546 let dispatcher =
547 FlowDecisionDispatcher::with_ledger("production", sink.clone(), ledger.clone());
548 let accepted = request("production");
549 assert!(dispatcher.dispatch(&accepted).await.unwrap());
550 assert!(!dispatcher.dispatch(&request("production")).await.unwrap());
551 assert_eq!(sink.0.lock().await.as_slice(), ["decision-1"]);
552 let receipt = ledger
553 .completed_receipt("decision-1")
554 .await
555 .unwrap()
556 .expect("accepted decision stores a terminal result receipt");
557 assert_eq!(receipt.identity, request_identity(&accepted).unwrap());
558 receipt.validate().unwrap();
559 assert!(matches!(
560 dispatcher.dispatch(&request("fork")).await,
561 Err(FlowDecisionDispatchError::UnauthorizedBranch { .. })
562 ));
563 let health = dispatcher.health();
564 assert_eq!(health.status, FlowDecisionHealthStatus::Healthy);
565 assert_eq!(health.attempted, 3);
566 assert_eq!(health.claimed, 1);
567 assert_eq!(health.completed, 1);
568 assert_eq!(health.duplicates, 1);
569 assert_eq!(health.rejected, 1);
570 assert_eq!(health.in_flight, 0);
571 assert!(health.last_success_at_ms.is_some());
572 }
573
574 #[tokio::test]
575 async fn completed_receipt_survives_dispatcher_restart() {
576 let sink = Arc::new(RecordingSink::default());
577 let ledger = Arc::new(MemoryFlowDecisionLedger::new());
578 let first = FlowDecisionDispatcher::with_ledger("production", sink.clone(), ledger.clone());
579 assert!(first.dispatch(&request("production")).await.unwrap());
580 let restarted = FlowDecisionDispatcher::with_ledger("production", sink.clone(), ledger);
581 assert!(!restarted.dispatch(&request("production")).await.unwrap());
582 assert_eq!(sink.0.lock().await.len(), 1);
583 }
584
585 #[tokio::test]
586 async fn decision_id_cannot_be_reused_with_different_content() {
587 let sink = Arc::new(RecordingSink::default());
588 let ledger = Arc::new(MemoryFlowDecisionLedger::new());
589 let dispatcher = FlowDecisionDispatcher::with_ledger("production", sink, ledger);
590 dispatcher.dispatch(&request("production")).await.unwrap();
591 let mut conflicting = request("production");
592 conflicting.decision = FlowDecision::Fail {
593 error: "different".into(),
594 };
595 assert!(matches!(
596 dispatcher.dispatch(&conflicting).await,
597 Err(FlowDecisionDispatchError::DecisionIdConflict(_))
598 ));
599 assert_eq!(dispatcher.health().conflicts, 1);
600 }
601
602 struct FailingOnceSink(AtomicUsize);
603
604 #[async_trait]
605 impl FlowDecisionSink for FailingOnceSink {
606 async fn submit(
607 &self,
608 _request: &FlowDecisionRequest,
609 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
610 if self.0.fetch_add(1, Ordering::SeqCst) == 0 {
611 return Err(std::io::Error::other("transient").into());
612 }
613 Ok(())
614 }
615 }
616
617 #[tokio::test]
618 async fn sink_failure_releases_claim_for_retry() {
619 let sink = Arc::new(FailingOnceSink(AtomicUsize::new(0)));
620 let dispatcher = FlowDecisionDispatcher::with_ledger(
621 "production",
622 sink.clone(),
623 Arc::new(MemoryFlowDecisionLedger::new()),
624 );
625 assert!(matches!(
626 dispatcher.dispatch(&request("production")).await,
627 Err(FlowDecisionDispatchError::Sink(_))
628 ));
629 assert!(dispatcher.dispatch(&request("production")).await.unwrap());
630 assert_eq!(sink.0.load(Ordering::SeqCst), 2);
631 let health = dispatcher.health();
632 assert_eq!(health.status, FlowDecisionHealthStatus::Healthy);
633 assert_eq!(health.sink_failures, 1);
634 assert_eq!(health.failures, 1);
635 assert_eq!(health.completed, 1);
636 }
637
638 #[tokio::test]
639 async fn independent_file_ledgers_serialize_claim_and_allow_expired_takeover() {
640 let directory = tempfile::tempdir().unwrap();
641 let left = FileFlowDecisionLedger::new(directory.path());
642 let right = FileFlowDecisionLedger::new(directory.path());
643 let (left_claim, right_claim) = tokio::join!(
644 left.claim("decision", "hash", "left", 100, 50),
645 right.claim("decision", "hash", "right", 100, 50),
646 );
647 let claims = [left_claim.unwrap(), right_claim.unwrap()];
648 assert_eq!(
649 claims
650 .iter()
651 .filter(|claim| matches!(claim, FlowDecisionClaimOutcome::Claimed { .. }))
652 .count(),
653 1
654 );
655 assert_eq!(
656 claims
657 .iter()
658 .filter(|claim| matches!(claim, FlowDecisionClaimOutcome::Busy { .. }))
659 .count(),
660 1
661 );
662 assert_eq!(
663 right
664 .claim("decision", "hash", "takeover", 151, 50)
665 .await
666 .unwrap(),
667 FlowDecisionClaimOutcome::Claimed { attempt: 2 }
668 );
669 right
670 .complete("decision", "hash", "takeover", 160)
671 .await
672 .unwrap();
673 assert_eq!(right.prune_completed(161).await.unwrap(), 1);
674 assert_eq!(
675 left.claim("decision", "hash", "new", 162, 50)
676 .await
677 .unwrap(),
678 FlowDecisionClaimOutcome::Claimed { attempt: 1 }
679 );
680 }
681
682 #[tokio::test]
683 async fn lease_renewal_requires_current_owner_and_preserves_attempt() {
684 let ledger = MemoryFlowDecisionLedger::new();
685 assert_eq!(
686 ledger
687 .claim("decision", "hash", "owner", 100, 30)
688 .await
689 .unwrap(),
690 FlowDecisionClaimOutcome::Claimed { attempt: 1 }
691 );
692 assert!(!ledger
693 .renew("decision", "hash", "other", 120, 30)
694 .await
695 .unwrap());
696 assert!(ledger
697 .renew("decision", "hash", "owner", 120, 30)
698 .await
699 .unwrap());
700 assert_eq!(
701 ledger
702 .claim("decision", "hash", "other", 145, 30)
703 .await
704 .unwrap(),
705 FlowDecisionClaimOutcome::Busy {
706 lease_expires_at_ms: 150
707 }
708 );
709 assert_eq!(
710 ledger
711 .claim("decision", "hash", "other", 151, 30)
712 .await
713 .unwrap(),
714 FlowDecisionClaimOutcome::Claimed { attempt: 2 }
715 );
716 assert!(!ledger
717 .renew("decision", "hash", "owner", 152, 30)
718 .await
719 .unwrap());
720 }
721
722 #[tokio::test]
723 async fn file_ledger_persists_renewed_lease_across_instances() {
724 let directory = tempfile::tempdir().unwrap();
725 let owner = FileFlowDecisionLedger::new(directory.path());
726 let competitor = FileFlowDecisionLedger::new(directory.path());
727 assert_eq!(
728 owner
729 .claim("decision", "hash", "owner", 100, 20)
730 .await
731 .unwrap(),
732 FlowDecisionClaimOutcome::Claimed { attempt: 1 }
733 );
734 assert!(owner
735 .renew("decision", "hash", "owner", 115, 30)
736 .await
737 .unwrap());
738 assert_eq!(
739 competitor
740 .claim("decision", "hash", "competitor", 125, 20)
741 .await
742 .unwrap(),
743 FlowDecisionClaimOutcome::Busy {
744 lease_expires_at_ms: 145
745 }
746 );
747 }
748
749 struct SlowCountingSink(AtomicUsize);
750
751 #[async_trait]
752 impl FlowDecisionSink for SlowCountingSink {
753 async fn submit(
754 &self,
755 _request: &FlowDecisionRequest,
756 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
757 self.0.fetch_add(1, Ordering::SeqCst);
758 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
759 Ok(())
760 }
761 }
762
763 #[tokio::test]
764 async fn competing_file_backed_dispatchers_submit_to_sink_once() {
765 let directory = tempfile::tempdir().unwrap();
766 let sink = Arc::new(SlowCountingSink(AtomicUsize::new(0)));
767 let left = FlowDecisionDispatcher::with_ledger(
768 "production",
769 sink.clone(),
770 Arc::new(FileFlowDecisionLedger::new(directory.path())),
771 );
772 let right = FlowDecisionDispatcher::with_ledger(
773 "production",
774 sink.clone(),
775 Arc::new(FileFlowDecisionLedger::new(directory.path())),
776 );
777 let request = request("production");
778 let (left_result, right_result) =
779 tokio::join!(left.dispatch(&request), right.dispatch(&request));
780 let results = [left_result, right_result];
781 assert_eq!(
782 results
783 .iter()
784 .filter(|result| matches!(result, Ok(true)))
785 .count(),
786 1
787 );
788 assert_eq!(
789 results
790 .iter()
791 .filter(|result| matches!(result, Err(FlowDecisionDispatchError::Busy { .. })))
792 .count(),
793 1
794 );
795 assert_eq!(sink.0.load(Ordering::SeqCst), 1);
796 }
797
798 struct RenewalNotifyingLedger {
799 inner: MemoryFlowDecisionLedger,
800 renewed: Arc<tokio::sync::Notify>,
801 }
802
803 #[async_trait]
804 impl FlowDecisionLedger for RenewalNotifyingLedger {
805 async fn claim(
806 &self,
807 decision_id: &str,
808 request_hash: &str,
809 owner_id: &str,
810 now_ms: u64,
811 lease_ms: u64,
812 ) -> anyhow::Result<FlowDecisionClaimOutcome> {
813 self.inner
814 .claim(decision_id, request_hash, owner_id, now_ms, lease_ms)
815 .await
816 }
817
818 async fn renew(
819 &self,
820 decision_id: &str,
821 request_hash: &str,
822 owner_id: &str,
823 now_ms: u64,
824 lease_ms: u64,
825 ) -> anyhow::Result<bool> {
826 let renewed = self
827 .inner
828 .renew(decision_id, request_hash, owner_id, now_ms, lease_ms)
829 .await?;
830 if renewed {
831 self.renewed.notify_one();
832 }
833 Ok(renewed)
834 }
835
836 async fn complete(
837 &self,
838 decision_id: &str,
839 request_hash: &str,
840 owner_id: &str,
841 completed_at_ms: u64,
842 ) -> anyhow::Result<()> {
843 self.inner
844 .complete(decision_id, request_hash, owner_id, completed_at_ms)
845 .await
846 }
847
848 async fn release(
849 &self,
850 decision_id: &str,
851 request_hash: &str,
852 owner_id: &str,
853 ) -> anyhow::Result<()> {
854 self.inner
855 .release(decision_id, request_hash, owner_id)
856 .await
857 }
858 }
859
860 struct ControlledSink {
861 calls: AtomicUsize,
862 finish: Arc<tokio::sync::Notify>,
863 }
864
865 #[async_trait]
866 impl FlowDecisionSink for ControlledSink {
867 async fn submit(
868 &self,
869 _request: &FlowDecisionRequest,
870 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
871 self.calls.fetch_add(1, Ordering::SeqCst);
872 self.finish.notified().await;
873 Ok(())
874 }
875 }
876
877 #[tokio::test]
878 async fn heartbeat_prevents_takeover_while_sink_is_running() {
879 let renewed = Arc::new(tokio::sync::Notify::new());
880 let finish = Arc::new(tokio::sync::Notify::new());
881 let sink = Arc::new(ControlledSink {
882 calls: AtomicUsize::new(0),
883 finish: finish.clone(),
884 });
885 let ledger = Arc::new(RenewalNotifyingLedger {
886 inner: MemoryFlowDecisionLedger::new(),
887 renewed: renewed.clone(),
888 });
889 let left = Arc::new(
890 FlowDecisionDispatcher::with_ledger("production", sink.clone(), ledger.clone())
891 .with_lease_ms(30),
892 );
893 let right = FlowDecisionDispatcher::with_ledger("production", sink.clone(), ledger)
894 .with_lease_ms(30);
895 let request = request("production");
896 let left_dispatcher = left.clone();
897 let left_request = request.clone();
898 let left_task = tokio::spawn(async move { left_dispatcher.dispatch(&left_request).await });
899 tokio::time::timeout(std::time::Duration::from_secs(2), renewed.notified())
900 .await
901 .expect("dispatcher must renew its live claim");
902 let right_result = right.dispatch(&request).await;
903 assert!(matches!(
904 right_result,
905 Err(FlowDecisionDispatchError::Busy { .. })
906 ));
907 finish.notify_one();
908 assert!(left_task.await.unwrap().unwrap());
909 assert_eq!(sink.calls.load(Ordering::SeqCst), 1);
910 let left_health = left.health();
911 assert_eq!(left_health.completed, 1);
912 assert!(left_health.lease_renewals > 0);
913 assert_eq!(right.health().busy, 1);
914 }
915
916 #[tokio::test]
917 async fn dispatcher_health_counts_expired_claim_takeover() {
918 let ledger = Arc::new(MemoryFlowDecisionLedger::new());
919 let request = request("production");
920 let hash = request_hash(&request).unwrap();
921 ledger
922 .claim(
923 &request.decision_id,
924 &hash,
925 "expired-owner",
926 now_ms().saturating_sub(10),
927 1,
928 )
929 .await
930 .unwrap();
931 let dispatcher = FlowDecisionDispatcher::with_ledger(
932 "production",
933 Arc::new(RecordingSink::default()),
934 ledger,
935 );
936 assert!(dispatcher.dispatch(&request).await.unwrap());
937 let health = dispatcher.health();
938 assert_eq!(health.claimed, 1);
939 assert_eq!(health.takeovers, 1);
940 assert_eq!(health.completed, 1);
941 }
942
943 #[derive(Default)]
944 struct LeaseLosingLedger;
945
946 #[async_trait]
947 impl FlowDecisionLedger for LeaseLosingLedger {
948 async fn claim(
949 &self,
950 _decision_id: &str,
951 _request_hash: &str,
952 _owner_id: &str,
953 _now_ms: u64,
954 _lease_ms: u64,
955 ) -> anyhow::Result<FlowDecisionClaimOutcome> {
956 Ok(FlowDecisionClaimOutcome::Claimed { attempt: 1 })
957 }
958
959 async fn renew(
960 &self,
961 _decision_id: &str,
962 _request_hash: &str,
963 _owner_id: &str,
964 _now_ms: u64,
965 _lease_ms: u64,
966 ) -> anyhow::Result<bool> {
967 Ok(false)
968 }
969
970 async fn complete(
971 &self,
972 _decision_id: &str,
973 _request_hash: &str,
974 _owner_id: &str,
975 _completed_at_ms: u64,
976 ) -> anyhow::Result<()> {
977 Ok(())
978 }
979
980 async fn release(
981 &self,
982 _decision_id: &str,
983 _request_hash: &str,
984 _owner_id: &str,
985 ) -> anyhow::Result<()> {
986 Ok(())
987 }
988 }
989
990 #[derive(Default)]
991 struct ClaimFailingLedger;
992
993 #[async_trait]
994 impl FlowDecisionLedger for ClaimFailingLedger {
995 async fn claim(
996 &self,
997 _decision_id: &str,
998 _request_hash: &str,
999 _owner_id: &str,
1000 _now_ms: u64,
1001 _lease_ms: u64,
1002 ) -> anyhow::Result<FlowDecisionClaimOutcome> {
1003 anyhow::bail!("ledger unavailable")
1004 }
1005
1006 async fn renew(
1007 &self,
1008 _decision_id: &str,
1009 _request_hash: &str,
1010 _owner_id: &str,
1011 _now_ms: u64,
1012 _lease_ms: u64,
1013 ) -> anyhow::Result<bool> {
1014 Ok(false)
1015 }
1016
1017 async fn complete(
1018 &self,
1019 _decision_id: &str,
1020 _request_hash: &str,
1021 _owner_id: &str,
1022 _completed_at_ms: u64,
1023 ) -> anyhow::Result<()> {
1024 Ok(())
1025 }
1026
1027 async fn release(
1028 &self,
1029 _decision_id: &str,
1030 _request_hash: &str,
1031 _owner_id: &str,
1032 ) -> anyhow::Result<()> {
1033 Ok(())
1034 }
1035 }
1036
1037 #[tokio::test]
1038 async fn dispatcher_health_records_ledger_failure() {
1039 let dispatcher = FlowDecisionDispatcher::with_ledger(
1040 "production",
1041 Arc::new(RecordingSink::default()),
1042 Arc::new(ClaimFailingLedger),
1043 );
1044 assert!(matches!(
1045 dispatcher.dispatch(&request("production")).await,
1046 Err(FlowDecisionDispatchError::Ledger(_))
1047 ));
1048 let health = dispatcher.health();
1049 assert_eq!(health.status, FlowDecisionHealthStatus::Degraded);
1050 assert_eq!(health.ledger_failures, 1);
1051 assert_eq!(health.in_flight, 0);
1052 }
1053
1054 struct CancellableSink {
1055 started: AtomicUsize,
1056 completed: AtomicUsize,
1057 }
1058
1059 #[async_trait]
1060 impl FlowDecisionSink for CancellableSink {
1061 async fn submit(
1062 &self,
1063 _request: &FlowDecisionRequest,
1064 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1065 self.started.fetch_add(1, Ordering::SeqCst);
1066 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1067 self.completed.fetch_add(1, Ordering::SeqCst);
1068 Ok(())
1069 }
1070 }
1071
1072 #[tokio::test]
1073 async fn lost_lease_cancels_in_flight_sink_future() {
1074 let sink = Arc::new(CancellableSink {
1075 started: AtomicUsize::new(0),
1076 completed: AtomicUsize::new(0),
1077 });
1078 let dispatcher = FlowDecisionDispatcher::with_ledger(
1079 "production",
1080 sink.clone(),
1081 Arc::new(LeaseLosingLedger),
1082 )
1083 .with_lease_ms(9);
1084 assert!(matches!(
1085 dispatcher.dispatch(&request("production")).await,
1086 Err(FlowDecisionDispatchError::LeaseLost(_))
1087 ));
1088 tokio::time::sleep(std::time::Duration::from_millis(60)).await;
1089 assert_eq!(sink.started.load(Ordering::SeqCst), 1);
1090 assert_eq!(sink.completed.load(Ordering::SeqCst), 0);
1091 let health = dispatcher.health();
1092 assert_eq!(health.status, FlowDecisionHealthStatus::Degraded);
1093 assert_eq!(health.lease_lost, 1);
1094 assert_eq!(health.in_flight, 0);
1095 assert!(health.last_failure_at_ms.is_some());
1096 }
1097
1098 struct BlockingSink(Arc<tokio::sync::Notify>);
1099
1100 #[async_trait]
1101 impl FlowDecisionSink for BlockingSink {
1102 async fn submit(
1103 &self,
1104 _request: &FlowDecisionRequest,
1105 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1106 self.0.notify_one();
1107 std::future::pending().await
1108 }
1109 }
1110
1111 #[tokio::test]
1112 async fn cancelled_dispatch_does_not_leak_in_flight_health() {
1113 let started = Arc::new(tokio::sync::Notify::new());
1114 let dispatcher = Arc::new(FlowDecisionDispatcher::new(
1115 "production",
1116 Arc::new(BlockingSink(started.clone())),
1117 ));
1118 let task_dispatcher = dispatcher.clone();
1119 let pending =
1120 tokio::spawn(async move { task_dispatcher.dispatch(&request("production")).await });
1121 started.notified().await;
1122 assert_eq!(dispatcher.health().in_flight, 1);
1123 pending.abort();
1124 assert!(pending.await.unwrap_err().is_cancelled());
1125 let health = dispatcher.health();
1126 assert_eq!(health.status, FlowDecisionHealthStatus::Degraded);
1127 assert_eq!(health.attempted, 1);
1128 assert_eq!(health.cancellations, 1);
1129 assert_eq!(health.in_flight, 0);
1130 }
1131}