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 {
112 claimed_epoch: epoch,
113 }),
114 Some(lease) => {
115 if lease.holder == device_id && lease.epoch == epoch {
116 Ok(FenceDecision::Proceed)
117 } else {
118 Ok(FenceDecision::StaleEpoch {
119 claimed_epoch: epoch,
120 current_epoch: lease.epoch,
121 current_holder: Some(lease.holder),
122 })
123 }
124 }
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::fold::fold;
132 use crate::lease::{InMemoryLeaseCoordinator, Intent, IntentStatus};
133 use crate::oplog::{logical_clock, DeviceLog, Scope, Surface};
134 use std::sync::atomic::{AtomicU64, Ordering};
135 use std::sync::Arc;
136
137 fn coord() -> (Arc<AtomicU64>, InMemoryLeaseCoordinator) {
138 let t = Arc::new(AtomicU64::new(0));
139 let reader = t.clone();
140 (
141 t,
142 InMemoryLeaseCoordinator::new(Arc::new(move || reader.load(Ordering::SeqCst))),
143 )
144 }
145
146 /// A committed intent op for `run` at `epoch`, folded into a state.
147 fn state_with_commit(run: &str, epoch: u64) -> SyncState {
148 let mut dev = DeviceLog::new("dev-a");
149 dev.set_wall_clock(logical_clock());
150 let op = dev.append(
151 Scope::Personal,
152 Surface::Intent,
153 Intent::new("milo", run, epoch, IntentStatus::Committed).payload(),
154 );
155 fold(&[op])
156 }
157
158 #[test]
159 fn holder_at_current_epoch_may_dispatch() {
160 let (_t, mut c) = coord();
161 let lease = c.acquire("milo", "dev-a", 100).unwrap();
162 let empty = SyncState::default();
163 let decision =
164 check_dispatch(&mut c, &empty, "milo", "run-1", "dev-a", lease.epoch).unwrap();
165 assert_eq!(decision, FenceDecision::Proceed);
166 assert!(decision.may_dispatch());
167 }
168
169 #[test]
170 fn stale_epoch_holder_is_refused() {
171 // dev-a holds epoch 1, pauses past TTL; dev-b steals epoch 2. dev-a's
172 // dispatch at epoch 1 must be refused — it is no longer the holder.
173 let (t, mut c) = coord();
174 c.acquire("milo", "dev-a", 100).unwrap();
175 t.store(200, Ordering::SeqCst);
176 let stolen = c.acquire("milo", "dev-b", 100).unwrap();
177 assert_eq!(stolen.epoch, 2);
178
179 let empty = SyncState::default();
180 let decision = check_dispatch(&mut c, &empty, "milo", "run-1", "dev-a", 1).unwrap();
181 assert_eq!(
182 decision,
183 FenceDecision::StaleEpoch {
184 claimed_epoch: 1,
185 current_epoch: 2,
186 current_holder: Some("dev-b".to_string()),
187 }
188 );
189 assert!(
190 !decision.may_dispatch(),
191 "a stale-epoch holder must NOT dispatch"
192 );
193 }
194
195 #[test]
196 fn already_committed_run_is_not_re_executed_even_by_the_current_holder() {
197 // The idempotency oracle dominates: even the legitimate current holder
198 // declines a run that already committed (the failover double-run kill).
199 let (_t, mut c) = coord();
200 let lease = c.acquire("milo", "dev-a", 100).unwrap();
201 let state = state_with_commit("run-nightly", 1);
202 let decision =
203 check_dispatch(&mut c, &state, "milo", "run-nightly", "dev-a", lease.epoch).unwrap();
204 match decision {
205 FenceDecision::AlreadyCommitted {
206 ref committed_op_id,
207 } => {
208 assert_eq!(
209 *committed_op_id,
210 state.committed_run("milo", "run-nightly").unwrap().op_id
211 );
212 }
213 other => panic!("expected AlreadyCommitted, got {other:?}"),
214 }
215 assert!(!decision.may_dispatch());
216 }
217
218 #[test]
219 fn committed_oracle_beats_a_stale_epoch_check() {
220 // Both hazards present: the run committed AND the caller is stale. The
221 // oracle read runs first, so the verdict is AlreadyCommitted (the more
222 // specific "it already ran" — either way, do not dispatch).
223 let (t, mut c) = coord();
224 c.acquire("milo", "dev-a", 100).unwrap();
225 t.store(200, Ordering::SeqCst);
226 c.acquire("milo", "dev-b", 100).unwrap();
227 let state = state_with_commit("run-x", 2);
228 let decision = check_dispatch(&mut c, &state, "milo", "run-x", "dev-a", 1).unwrap();
229 assert!(matches!(decision, FenceDecision::AlreadyCommitted { .. }));
230 }
231
232 #[test]
233 fn unheld_agent_is_not_authorized() {
234 let (_t, mut c) = coord();
235 let empty = SyncState::default();
236 let decision = check_dispatch(&mut c, &empty, "milo", "run-1", "dev-a", 1).unwrap();
237 assert_eq!(decision, FenceDecision::NotHeld { claimed_epoch: 1 });
238 assert!(!decision.may_dispatch());
239 }
240}