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 { 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}