Skip to main content

stackless_core/state/
lock.rs

1//! Per-instance operation locks (§2): one operation at a time, holder
2//! identified by PID + process start time so a crashed holder is
3//! detected and taken over rather than blocking forever.
4//!
5//! ## Fleet-mode claim flow (M9)
6//!
7//! The claim is restructured from the old `BEGIN IMMEDIATE` read-then-
8//! upsert into single-statement compare-and-swap, which is correct on
9//! both the local (rusqlite) and remote (libsql) backends — interactive
10//! transactions over remote libsql are fragile, and §2 decided
11//! lock/lease claims become single-statement CAS against the primary:
12//!
13//! 1. **Self-reclaim / free-claim** — one `INSERT … ON CONFLICT DO
14//!    UPDATE … WHERE` that updates *only* when the existing row already
15//!    names this exact holder (or inserts when the row is absent). Rows
16//!    changed ⇒ we hold it.
17//! 2. **Takeover** — if step 1 changed nothing, the lock is held by
18//!    someone else. Read the holder row and decide host-side:
19//!    - **Same host** (`holder_host` == ours): the recorded PID's
20//!      liveness is meaningful here. If the
21//!      holder is dead, a second CAS keyed on the *exact* dead holder
22//!      identity (`holder_host`, `holder_pid`, `holder_start_time`)
23//!      takes it over atomically — only one racer can win.
24//!    - **Foreign host**: a remote PID cannot be probed from here, so
25//!      the lock is respected until its `acquired_at` is older than the
26//!      [`FOREIGN_STALE_BUDGET`] (the fleet-mode staleness rule), then
27//!      the same exact-identity CAS takes it over.
28//!
29//! In either takeover case, zero rows changed by the CAS ⇒ another
30//! claimant won the race first ⇒ `LockHeld`.
31
32use std::time::Duration;
33
34use super::error::StateError;
35use super::row::Row;
36use super::store::Store;
37use crate::process::ProcessStamp;
38use crate::types::{Pid, ProcessStartTime};
39
40/// Fleet-mode staleness rule (§2): a lock held by a *foreign* host —
41/// whose PID this machine cannot probe for liveness — is respected until
42/// it is older than this budget, then taken over. Local (same-host)
43/// holders are never subject to it; their liveness is checked directly.
44pub const FOREIGN_STALE_BUDGET: Duration = Duration::from_secs(30 * 60);
45
46#[derive(Debug)]
47pub struct LockClaim {
48    pub instance: String,
49    pub operation: String,
50    holder: ProcessStamp,
51    host: String,
52}
53
54impl Store {
55    /// Claim the operation lock for `instance`, taking over a dead
56    /// (same-host) or stale (foreign-host) holder's claim. Fails fast
57    /// with a stable code if a live holder has it — agents retry stuck
58    /// commands, so overlapping operations are an expected event.
59    pub fn claim_lock(&self, instance: &str, operation: &str) -> Result<LockClaim, StateError> {
60        let me = ProcessStamp::current();
61        let host = Self::hostname();
62
63        // Step 1: self-reclaim or free-claim in one statement. The
64        // DO UPDATE only fires when the existing row is already ours;
65        // otherwise the conflict makes the upsert a no-op (0 rows).
66        let claimed = self.execute(
67            "INSERT INTO op_locks
68               (instance, operation, holder_pid, holder_start_time, holder_host, acquired_at)
69             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
70             ON CONFLICT(instance) DO UPDATE SET
71               operation = excluded.operation,
72               acquired_at = excluded.acquired_at
73             WHERE op_locks.holder_pid = excluded.holder_pid
74               AND op_locks.holder_start_time = excluded.holder_start_time
75               AND op_locks.holder_host = excluded.holder_host",
76            &[
77                instance.into(),
78                operation.into(),
79                me.pid.into(),
80                me.start_time.into(),
81                host.as_str().into(),
82                Self::now().into(),
83            ],
84        )?;
85        if claimed > 0 {
86            return Ok(LockClaim {
87                instance: instance.into(),
88                operation: operation.into(),
89                holder: me,
90                host,
91            });
92        }
93
94        // Step 2: the row is held by someone else. Read it and decide.
95        let Some(existing) = self.query_row(
96            "SELECT operation, holder_pid, holder_start_time, holder_host, acquired_at
97             FROM op_locks WHERE instance = ?1",
98            &[instance.into()],
99            |row: &Row| {
100                Ok((
101                    row.get_string(0)?,
102                    row.get_u32(1)?,
103                    row.get_i64(2)?,
104                    row.get_string(3)?,
105                    row.get_i64(4)?,
106                ))
107            },
108        )?
109        else {
110            // The holder released between step 1 and the read; retry
111            // the self-claim once — now the row is absent so it inserts.
112            return self.takeover_or_held(instance, operation, &me, &host, None);
113        };
114
115        let (held_op, holder_pid, holder_start, holder_host, acquired_at) = existing;
116        let holder = ProcessStamp {
117            pid: Pid::from_os(holder_pid),
118            start_time: ProcessStartTime::from_os(holder_start as u64),
119        };
120        let same_host = holder_host == host;
121
122        let takeover_ok = if same_host {
123            // Same liveness check the daemon uses for service processes.
124            !holder.is_alive()
125        } else {
126            // Foreign PID is unprobeable; respect until past the budget.
127            Self::now() - acquired_at >= FOREIGN_STALE_BUDGET.as_secs() as i64
128        };
129
130        if !takeover_ok {
131            return Err(StateError::LockHeld {
132                instance: instance.into(),
133                operation: held_op,
134                holder_pid,
135                acquired_at,
136            });
137        }
138
139        self.takeover_or_held(
140            instance,
141            operation,
142            &me,
143            &host,
144            Some((holder_pid, holder_start, holder_host)),
145        )
146    }
147
148    /// Atomically take the lock from a specific holder (or claim a freed
149    /// row). The CAS is keyed on the *exact* holder identity so only one
150    /// racer wins; zero rows ⇒ another claimant beat us ⇒ `LockHeld`.
151    fn takeover_or_held(
152        &self,
153        instance: &str,
154        operation: &str,
155        me: &ProcessStamp,
156        host: &str,
157        dead_holder: Option<(u32, i64, String)>,
158    ) -> Result<LockClaim, StateError> {
159        let won = match dead_holder {
160            Some((dead_pid, dead_start, dead_host)) => self.execute(
161                "UPDATE op_locks SET
162                   operation = ?2, holder_pid = ?3, holder_start_time = ?4,
163                   holder_host = ?5, acquired_at = ?6
164                 WHERE instance = ?1
165                   AND holder_pid = ?7 AND holder_start_time = ?8 AND holder_host = ?9",
166                &[
167                    instance.into(),
168                    operation.into(),
169                    me.pid.into(),
170                    me.start_time.into(),
171                    host.into(),
172                    Self::now().into(),
173                    dead_pid.into(),
174                    dead_start.into(),
175                    dead_host.into(),
176                ],
177            )?,
178            None => self.execute(
179                "INSERT INTO op_locks
180                   (instance, operation, holder_pid, holder_start_time, holder_host, acquired_at)
181                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
182                 ON CONFLICT(instance) DO NOTHING",
183                &[
184                    instance.into(),
185                    operation.into(),
186                    me.pid.into(),
187                    me.start_time.into(),
188                    host.into(),
189                    Self::now().into(),
190                ],
191            )?,
192        };
193        if won > 0 {
194            Ok(LockClaim {
195                instance: instance.into(),
196                operation: operation.into(),
197                holder: *me,
198                host: host.to_owned(),
199            })
200        } else {
201            Err(StateError::LockHeld {
202                instance: instance.into(),
203                operation: operation.into(),
204                holder_pid: me.pid.get(),
205                acquired_at: Self::now(),
206            })
207        }
208    }
209
210    /// Release a claim. Only the recorded holder's release deletes the
211    /// row, so a stale guard cannot release a successor's lock.
212    pub fn release_lock(&self, claim: &LockClaim) -> Result<(), StateError> {
213        self.execute(
214            "DELETE FROM op_locks
215             WHERE instance = ?1 AND holder_pid = ?2 AND holder_start_time = ?3
216               AND holder_host = ?4",
217            &[
218                claim.instance.as_str().into(),
219                claim.holder.pid.into(),
220                claim.holder.start_time.into(),
221                claim.host.as_str().into(),
222            ],
223        )?;
224        Ok(())
225    }
226
227    /// Whether `instance` is currently mid-operation under a live
228    /// holder — the reaper must never reap such an instance (§6). A
229    /// foreign-host holder is treated as live (its PID is unprobeable
230    /// here) until the lease/staleness machinery resolves it.
231    pub fn lock_holder_alive(&self, instance: &str) -> Result<bool, StateError> {
232        let host = Self::hostname();
233        let existing = self.query_row(
234            "SELECT holder_pid, holder_start_time, holder_host FROM op_locks WHERE instance = ?1",
235            &[instance.into()],
236            |row: &Row| Ok((row.get_u32(0)?, row.get_i64(1)?, row.get_string(2)?)),
237        )?;
238        Ok(existing.is_some_and(|(pid, start_time, holder_host)| {
239            let same_host = holder_host == host;
240            if same_host {
241                ProcessStamp {
242                    pid: Pid::from_os(pid),
243                    start_time: ProcessStartTime::from_os(start_time as u64),
244                }
245                .is_alive()
246            } else {
247                true
248            }
249        }))
250    }
251}