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}