car_sync/fence.rs
1//! The executor **dispatch fence** (slice B6 of
2//! `docs/proposals/multi-device-sync.md`, §"Layer 2 (safety): the idempotency
3//! / fencing gradient" + §"Deep dive: the execution-lease / fencing
4//! protocol").
5//!
6//! B5 shipped **deterministic ledger convergence + a durable idempotency
7//! oracle** and was explicit that this is **NOT** exactly-once execution:
8//!
9//! > **The exactly-once EXECUTION gate is B6's dispatch fence** — a
10//! > dispatch-time linearizable "am I still epoch N?" read plus the durable
11//! > non-fenced oracle read **before** the external side effect. That is
12//! > necessary, not an optimization; the fold gates the *ledger*, not the
13//! > effect.
14//!
15//! This module is that gate. [`check_dispatch`] runs the two checks the
16//! proposal requires at the **point of effect** (immediately before the
17//! executor performs a side-effecting tool call for a leased run):
18//!
19//! 1. **The durable non-fenced idempotency read** —
20//! [`crate::fold::SyncState::committed_run`]. This is checked **first** and
21//! is **fence-independent**: a run that has ever committed (per the keep-all
22//! oracle carried in the checkpoint) must never re-execute, whatever the
23//! current epoch. This closes the failover-double-run: a new holder that
24//! legitimately steals the lease still sees the old holder's committed
25//! record and declines.
26//! 2. **The linearizable epoch read** — [`LeaseCoordinator::current`]. Only a
27//! caller that is *still* the current holder at the epoch it claims may
28//! proceed. A stale-epoch zombie (paused past its TTL while another site
29//! stole the lease) is rejected here — the Kleppmann fence: a holder that
30//! *knows* it is stale never acts.
31//!
32//! The residual the proposal is honest about is unchanged: the fence cannot
33//! stop a holder that pauses *after* passing this check and before its
34//! external write lands (the external resource does not honor CAR's token).
35//! That window is bounded by TTL + the write-ahead intent ledger +
36//! reversibility, not eliminated — see the proposal's tier-3 discussion. This
37//! module implements the part that IS closeable.
38
39use crate::fold::SyncState;
40use crate::lease::{LeaseCoordinator, LeaseError};
41use serde::{Deserialize, Serialize};
42
43/// The fence verdict for one dispatch attempt.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "decision", rename_all = "snake_case")]
46pub enum FenceDecision {
47 /// The oracle shows no prior commit AND the caller still holds the lease
48 /// at the claimed epoch — the side effect may run. This is the ONLY
49 /// verdict that authorizes an external write.
50 Proceed,
51 /// The durable idempotency oracle already holds a committed record for
52 /// `(agent_id, run_id)` — the run executed (here or on another site) and
53 /// must not run again. Fence-independent: returned whatever the epoch.
54 AlreadyCommitted {
55 /// The `op_id` of the committed oracle record (for logging / dedup).
56 committed_op_id: String,
57 },
58 /// The caller is no longer the current holder at the claimed epoch — a
59 /// stale zombie. Do not execute; re-acquire or stand by.
60 StaleEpoch {
61 /// The epoch the caller believed it held.
62 claimed_epoch: u64,
63 /// The coordinator's current epoch (`0` if unheld).
64 current_epoch: u64,
65 /// The device that currently holds the lease.
66 current_holder: Option<String>,
67 },
68 /// No lease is held for this agent at all (never acquired, or released).
69 /// Not authorized to execute a leased side effect.
70 NotHeld { claimed_epoch: u64 },
71}
72
73impl FenceDecision {
74 /// Is this the one verdict that authorizes the external side effect?
75 pub fn may_dispatch(&self) -> bool {
76 matches!(self, FenceDecision::Proceed)
77 }
78}
79
80/// Run the dispatch fence for one leased run at the point of effect.
81///
82/// `state` is the caller's folded [`SyncState`] (checkpoint base + journal
83/// tail — e.g. [`crate::session::SyncSession::state`]); it supplies the
84/// fence-independent committed-run oracle. `coordinator` is the linearizable
85/// lease register; [`LeaseCoordinator::current`] is the "am I still epoch N?"
86/// read. The oracle read comes **first** so an already-committed run is
87/// declined even by a caller that legitimately holds the current lease
88/// (idempotency dominates liveness — the whole point of a keep-all oracle).
89///
90/// Returns [`FenceDecision::Proceed`] **iff** no prior commit exists AND the
91/// caller `device_id` holds the current lease at `epoch`. Any other outcome
92/// means *do not perform the side effect*.
93pub fn check_dispatch(
94 coordinator: &mut dyn LeaseCoordinator,
95 state: &SyncState,
96 agent_id: &str,
97 run_id: &str,
98 device_id: &str,
99 epoch: u64,
100) -> Result<FenceDecision, LeaseError> {
101 // 1. Durable, fence-independent idempotency read FIRST. A committed run
102 // never re-executes regardless of who now holds the lease.
103 if let Some(committed) = state.committed_run(agent_id, run_id) {
104 return Ok(FenceDecision::AlreadyCommitted {
105 committed_op_id: committed.op_id.clone(),
106 });
107 }
108
109 // 2. Linearizable "am I still epoch N?" read.
110 match coordinator.current(agent_id)? {
111 None => Ok(FenceDecision::NotHeld { claimed_epoch: epoch }),
112 Some(lease) => {
113 if lease.holder == device_id && lease.epoch == epoch {
114 Ok(FenceDecision::Proceed)
115 } else {
116 Ok(FenceDecision::StaleEpoch {
117 claimed_epoch: epoch,
118 current_epoch: lease.epoch,
119 current_holder: Some(lease.holder),
120 })
121 }
122 }
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::fold::fold;
130 use crate::lease::{InMemoryLeaseCoordinator, Intent, IntentStatus};
131 use crate::oplog::{logical_clock, DeviceLog, Scope, Surface};
132 use std::sync::atomic::{AtomicU64, Ordering};
133 use std::sync::Arc;
134
135 fn coord() -> (Arc<AtomicU64>, InMemoryLeaseCoordinator) {
136 let t = Arc::new(AtomicU64::new(0));
137 let reader = t.clone();
138 (t, InMemoryLeaseCoordinator::new(Arc::new(move || reader.load(Ordering::SeqCst))))
139 }
140
141 /// A committed intent op for `run` at `epoch`, folded into a state.
142 fn state_with_commit(run: &str, epoch: u64) -> SyncState {
143 let mut dev = DeviceLog::new("dev-a");
144 dev.set_wall_clock(logical_clock());
145 let op = dev.append(
146 Scope::Personal,
147 Surface::Intent,
148 Intent::new("milo", run, epoch, IntentStatus::Committed).payload(),
149 );
150 fold(&[op])
151 }
152
153 #[test]
154 fn holder_at_current_epoch_may_dispatch() {
155 let (_t, mut c) = coord();
156 let lease = c.acquire("milo", "dev-a", 100).unwrap();
157 let empty = SyncState::default();
158 let decision =
159 check_dispatch(&mut c, &empty, "milo", "run-1", "dev-a", lease.epoch).unwrap();
160 assert_eq!(decision, FenceDecision::Proceed);
161 assert!(decision.may_dispatch());
162 }
163
164 #[test]
165 fn stale_epoch_holder_is_refused() {
166 // dev-a holds epoch 1, pauses past TTL; dev-b steals epoch 2. dev-a's
167 // dispatch at epoch 1 must be refused — it is no longer the holder.
168 let (t, mut c) = coord();
169 c.acquire("milo", "dev-a", 100).unwrap();
170 t.store(200, Ordering::SeqCst);
171 let stolen = c.acquire("milo", "dev-b", 100).unwrap();
172 assert_eq!(stolen.epoch, 2);
173
174 let empty = SyncState::default();
175 let decision = check_dispatch(&mut c, &empty, "milo", "run-1", "dev-a", 1).unwrap();
176 assert_eq!(
177 decision,
178 FenceDecision::StaleEpoch {
179 claimed_epoch: 1,
180 current_epoch: 2,
181 current_holder: Some("dev-b".to_string()),
182 }
183 );
184 assert!(!decision.may_dispatch(), "a stale-epoch holder must NOT dispatch");
185 }
186
187 #[test]
188 fn already_committed_run_is_not_re_executed_even_by_the_current_holder() {
189 // The idempotency oracle dominates: even the legitimate current holder
190 // declines a run that already committed (the failover double-run kill).
191 let (_t, mut c) = coord();
192 let lease = c.acquire("milo", "dev-a", 100).unwrap();
193 let state = state_with_commit("run-nightly", 1);
194 let decision =
195 check_dispatch(&mut c, &state, "milo", "run-nightly", "dev-a", lease.epoch).unwrap();
196 match decision {
197 FenceDecision::AlreadyCommitted { ref committed_op_id } => {
198 assert_eq!(
199 *committed_op_id,
200 state.committed_run("milo", "run-nightly").unwrap().op_id
201 );
202 }
203 other => panic!("expected AlreadyCommitted, got {other:?}"),
204 }
205 assert!(!decision.may_dispatch());
206 }
207
208 #[test]
209 fn committed_oracle_beats_a_stale_epoch_check() {
210 // Both hazards present: the run committed AND the caller is stale. The
211 // oracle read runs first, so the verdict is AlreadyCommitted (the more
212 // specific "it already ran" — either way, do not dispatch).
213 let (t, mut c) = coord();
214 c.acquire("milo", "dev-a", 100).unwrap();
215 t.store(200, Ordering::SeqCst);
216 c.acquire("milo", "dev-b", 100).unwrap();
217 let state = state_with_commit("run-x", 2);
218 let decision = check_dispatch(&mut c, &state, "milo", "run-x", "dev-a", 1).unwrap();
219 assert!(matches!(decision, FenceDecision::AlreadyCommitted { .. }));
220 }
221
222 #[test]
223 fn unheld_agent_is_not_authorized() {
224 let (_t, mut c) = coord();
225 let empty = SyncState::default();
226 let decision = check_dispatch(&mut c, &empty, "milo", "run-1", "dev-a", 1).unwrap();
227 assert_eq!(decision, FenceDecision::NotHeld { claimed_epoch: 1 });
228 assert!(!decision.may_dispatch());
229 }
230}