Skip to main content

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}