flint_lean/lease.rs
1//! The subtree lease (plan P4: `tier::epoch` re-scoped to the lean
2//! subtree cell), with lean's own claim loop.
3//!
4//! Deliberately NOT `tier::epoch::claim`: that path (a) runs the
5//! bucket-wide MPU takeover sweep, which a project-scoped proxy denies
6//! (the sweep is an operator-side job in lean — plan §2.4), and (b)
7//! gates self-recognition on the hub's state-directory occupancy lock.
8//! Lean's self-recognition token is the PERSISTED INCARNATION ID: it is
9//! emptyDir-scoped, so only the same pod's restarted container inherits
10//! it — which is exactly the one case where immediate self-supersede is
11//! safe. A replacement pod gets a fresh id and must wait out the quiet
12//! polls; that observation ({last_token, quiet_polls}) persists so a
13//! container restart RESUMES it instead of resetting the clock.
14
15use std::sync::Arc;
16
17use flint_store::{EpochLease, ObjectStore, StoreError};
18
19use super::state::Incarnation;
20use super::{manifest, LeanError, LeanResult, Syncer};
21
22/// Quiet polls required before superseding a foreign holder (the
23/// token must not advance across this many observations).
24pub const QUIET_POLLS: u32 = 6;
25
26pub enum ClaimOutcome {
27 /// Fresh cell or clean-released cell: claimed immediately.
28 Claimed(EpochLease),
29 /// Foreign holder's token advanced (or not enough quiet polls yet):
30 /// call again after the heartbeat interval. The observation is
31 /// persisted.
32 Waiting { quiet_polls: u32 },
33}
34
35/// Say so, loudly, if another product also writes this prefix.
36///
37/// Detection, deliberately NOT enforcement — see the forge twin. A
38/// lean workspace and a forge repository on one prefix arbitrate on
39/// different cells and will never fence each other, so without this
40/// nothing anywhere reports the condition.
41///
42/// Note what is NOT a finding: forge's legible export IS a lean
43/// workspace, published by forge rather than by an agent's syncer, so
44/// a syncer reading an export prefix finds only its own kind of cell.
45/// And an export nested under a repository's prefix puts this cell at
46/// `<prefix>/inner/.flint/lean/epoch`, which is not the key forge
47/// probes — the exact-key probe is what keeps that from reading as a
48/// collision.
49pub async fn warn_if_prefix_is_shared(sc: &Syncer) {
50 // A published mirror does not probe. Two reasons, and the second is
51 // the load-bearing one:
52 //
53 // Forge spawns a barrier per export, so this would be a recurring
54 // read for a condition that cannot change between two barriers of
55 // the same publisher. And forge echoes only the child lines
56 // containing "barrier" (export.rs:389), so a warning raised here
57 // would be read by nobody — a check whose output is discarded is
58 // worse than no check, because it looks like coverage.
59 //
60 // The prefix is still probed, ONCE, by the publisher at startup:
61 // see `warn_if_export_prefix_is_shared` in forge's twin. Moved, not
62 // dropped — an export prefix is a prefix two products both write
63 // legitimately, which makes it likelier than most to have a third
64 // pointed at it.
65 if sc.cfg.sole_writer {
66 return;
67 }
68 let found = flint_store::layout::neighbours(
69 sc.store.as_ref(),
70 &sc.cfg.prefix,
71 flint_store::layout::Writer::LeanWorkspace,
72 )
73 .await
74 .unwrap_or_default();
75 for f in found {
76 eprintln!("flint-sync: {}", f.report());
77 }
78}
79
80/// One claim step. The caller loops on `Waiting` at its poll cadence;
81/// each call performs at most one read + one acquire.
82pub async fn claim_step(sc: &mut Syncer) -> LeanResult<ClaimOutcome> {
83 let store: &Arc<dyn ObjectStore> = &sc.store;
84 let key = sc.cfg.epoch_key();
85 let mut inc = sc.state.load_incarnation()?.unwrap_or_else(|| Incarnation {
86 holder_id: format!("lean-{}", uuid::Uuid::new_v4()),
87 epoch: 0,
88 last_token: None,
89 quiet_polls: 0,
90 });
91
92 let observed = store.epoch_read(&key).await?;
93 match observed {
94 None => match store.epoch_acquire(&key, &inc.holder_id, None).await {
95 Ok(lease) => {
96 inc.epoch = lease.epoch;
97 inc.last_token = None;
98 inc.quiet_polls = 0;
99 sc.state.save_incarnation(&inc)?;
100 sc.lease = Some(lease.clone());
101 Ok(ClaimOutcome::Claimed(lease))
102 }
103 Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {
104 Ok(ClaimOutcome::Waiting { quiet_polls: 0 })
105 }
106 Err(e) => Err(e.into()),
107 },
108 Some(state) => {
109 let ours = state.holder_id == inc.holder_id;
110 let quiet = inc.last_token.as_deref() == Some(state.token.as_str());
111 if ours || state.released || (quiet && inc.quiet_polls + 1 >= QUIET_POLLS) {
112 // Self-recognition (same emptyDir), a clean release, or
113 // a lease judged dead across QUIET_POLLS observations.
114 //
115 // Rotation is needed ONLY for the unreleased-foreign
116 // takeover (a possibly-live straggler mid-barrier). A
117 // released cell is a clean handoff — the holder's final
118 // barrier completed before release — and self-
119 // recognition means the previous container's process
120 // (and any in-flight write of its) died with it.
121 // Rotating on those paths is pure manifest churn: at
122 // 100k+ entries it is a multi-MB GET+PUT per claim, it
123 // double-bumps seq, and it defeats the no-change
124 // barrier's early exit (measured on the 0b rig).
125 let rotate = !ours && !state.released;
126 match store.epoch_acquire(&key, &inc.holder_id, Some(&state)).await {
127 Ok(lease) => {
128 if rotate {
129 manifest::rotate_for_takeover(store.as_ref(), &sc.cfg, lease.epoch)
130 .await?;
131 }
132 inc.epoch = lease.epoch;
133 inc.last_token = None;
134 inc.quiet_polls = 0;
135 sc.state.save_incarnation(&inc)?;
136 sc.lease = Some(lease.clone());
137 Ok(ClaimOutcome::Claimed(lease))
138 }
139 Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {
140 // Lost the supersede race; restart observation.
141 inc.last_token = None;
142 inc.quiet_polls = 0;
143 sc.state.save_incarnation(&inc)?;
144 Ok(ClaimOutcome::Waiting { quiet_polls: 0 })
145 }
146 Err(e) => Err(e.into()),
147 }
148 } else {
149 inc.quiet_polls = if quiet { inc.quiet_polls + 1 } else { 0 };
150 inc.last_token = Some(state.token.clone());
151 let polls = inc.quiet_polls;
152 sc.state.save_incarnation(&inc)?;
153 Ok(ClaimOutcome::Waiting { quiet_polls: polls })
154 }
155 }
156 }
157}
158
159/// What this syncer is OBSERVED to be doing, for the heartbeat cell
160/// (boundary-verbs plan §2.6). Computed from local files only — the
161/// same store-free discipline as `write_gauges`, and for the same
162/// reason: this rides the renewal, so it must not add a request to the
163/// one tick every idle workspace in the fleet pays (leg B8's oracle
164/// counts them).
165fn observed_echo(sc: &Syncer) -> Option<String> {
166 let g = sc.load_gauges().ok()?;
167 // The count comes from the stage itself rather than the gauges
168 // snapshot: it is the exposure number an operator pages on, and
169 // gauges lag by up to a floor.
170 let staged = sc.load_stage().map(|s| s.entries.len() as u64).unwrap_or(0);
171 let (seq, unix) = g.last_boundary.as_ref().map(|b| (b.seq, b.unix)).unwrap_or((0, 0));
172 // The pre-flight verdict lives in the marker the AGENT reads; the
173 // operator has no other way to see it, so it rides the echo.
174 let caps: Option<super::control::Capabilities> =
175 std::fs::read(sc.cfg.control_dir().join(super::control::CAPABILITIES))
176 .ok()
177 .and_then(|b| serde_json::from_slice(&b).ok());
178 serde_json::to_string(&flint_store::LeaseEcho {
179 syncer_version: super::SYNCER_VERSION.to_string(),
180 protocol: super::SENTINEL_PROTOCOL,
181 active_boundary_mode: sc.cfg.boundary_mode.as_str().to_string(),
182 last_cited_seq: seq,
183 last_cited_unix: unix,
184 staged_uncited_count: staged,
185 sentinel_verbs_active: caps.map(|c| !c.verbs.is_empty()).unwrap_or(false),
186 metrics_bound: sc.load_metrics_posture().filter(|m| m.enabled).map(|m| m.bound),
187 })
188 .ok()
189}
190
191/// Renew the held lease; a 412 means deposed — the caller must stop
192/// publishing (self-fence).
193///
194/// The renewal also carries the observed-state echo (§2.6): the
195/// operator's only evidence of what the syncer binary is ACTUALLY
196/// running, on a request that was already being paid for.
197pub async fn renew(sc: &mut Syncer) -> LeanResult<()> {
198 let key = sc.cfg.epoch_key();
199 let lease = sc
200 .lease
201 .clone()
202 .ok_or_else(|| LeanError::State("renew without a lease".into()))?;
203 let echo = observed_echo(sc);
204 match sc.store.epoch_renew(&key, &lease, echo.as_deref()).await {
205 Ok(l) => {
206 sc.lease = Some(l);
207 // The renewal is the only scheduled probe of our own
208 // credentials, so it is also the only place a pause can be
209 // observed to have ENDED. Best-effort: a gauge that failed
210 // to write must not fail a renewal that succeeded.
211 let _ = sc.clear_auth_pause();
212 Ok(())
213 }
214 Err(StoreError::PreconditionFailed(e)) => {
215 // A 412 is not yet a deposal. The renew CAS is If-Match on
216 // OUR token, and only two things move that token: a
217 // successor's acquire, or our own previous renew whose
218 // RESPONSE was lost (the PUT landed, the client saw a
219 // timeout). The second is not a takeover, and treating it as
220 // one made a live holder fence itself — exit 0, and under
221 // the CSI delivery nothing restarted it, so the workspace
222 // went unpublished for the rest of the tenant's life
223 // (audit 2026-09-03, finding 2). One read tells the cases
224 // apart: a cell that still names this holder at this epoch,
225 // unreleased, can only have been written by us, so its token
226 // is ours to adopt. Anything else is the fence.
227 match sc.store.epoch_read(&key).await {
228 Ok(Some(state))
229 if state.holder_id == lease.holder_id
230 && state.epoch == lease.epoch
231 && !state.released =>
232 {
233 eprintln!(
234 "flint-sync: renew 412 on a cell that is still ours (epoch {}): a lost \
235 renew response — adopting its token, not fencing",
236 state.epoch
237 );
238 sc.lease = Some(EpochLease {
239 holder_id: state.holder_id,
240 epoch: state.epoch,
241 token: state.token,
242 });
243 let _ = sc.clear_auth_pause();
244 Ok(())
245 }
246 _ => {
247 sc.lease = None;
248 Err(LeanError::Fenced(format!("deposed at renew: {e}")))
249 }
250 }
251 }
252 // 401/403 is not contention and not a bucket fault; retrying
253 // cannot fix it. We keep serving local files and keep trying —
254 // but the renewals we are now missing are exactly what a
255 // challenger reads as a dead holder, so record when the pause
256 // began while we still can. Nothing we write to the STORE can
257 // carry this: the request that would carry it is the one being
258 // refused (design §6.3).
259 Err(e @ StoreError::Auth(_)) => {
260 let _ = sc.note_auth_pause();
261 Err(e.into())
262 }
263 Err(e) => Err(e.into()),
264 }
265}
266
267/// The claim precondition (audit 2026-09-03, finding 5). With a project
268/// id stamped (`FLINT_SYNC_PROJECT_ID`), refuse to run over a prefix
269/// whose claim cell names ANOTHER project. The operator's refuse-foreign
270/// was advisory on the data plane: a CR the operator had not yet judged
271/// resolved to its spec, and the syncer checked out and republished over
272/// the foreign project's manifest. The cell is durable in the bucket, so
273/// the data plane can read it — one GET before the first claim step.
274/// Absent cell ⇒ a fresh prefix, claimable; unstamped ⇒ no check (the
275/// pre-operator posture, which the drill runs). A foreign claim is
276/// `LeanError::Refused`: the process exits `EXIT_REFUSED`, which the
277/// delivery treats as final (tear down, name the reason) rather than
278/// as one more crash to restart.
279pub async fn verify_claim(sc: &Syncer) -> LeanResult<()> {
280 let Some(mine) = sc.cfg.project_id.as_deref() else { return Ok(()) };
281 let key = sc.cfg.claim_key();
282 match sc.store.get_whole(&key, None).await {
283 Ok((_, body)) => {
284 let doc: serde_json::Value = serde_json::from_slice(&body)
285 .map_err(|e| LeanError::State(format!("claim cell {key} is unparseable: {e}")))?;
286 match doc.get("project_id").and_then(|v| v.as_str()) {
287 Some(p) if p == mine => Ok(()),
288 Some(p) => Err(LeanError::Refused(format!(
289 "prefix {} is claimed by project {p:?}; this workspace is project {mine:?} — \
290 refusing to check out or publish over another project's data (delete the \
291 standing claim explicitly if this reuse is intended)",
292 sc.cfg.prefix
293 ))),
294 None => Err(LeanError::State(format!("claim cell {key} names no project_id"))),
295 }
296 }
297 Err(StoreError::NotFound(_)) => Ok(()),
298 Err(e) => Err(e.into()),
299 }
300}
301
302/// Clean release (the preStop path): a successor supersedes immediately
303/// instead of waiting out the lease.
304pub async fn release(sc: &mut Syncer) -> LeanResult<()> {
305 let key = sc.cfg.epoch_key();
306 if let Some(lease) = sc.lease.take() {
307 match sc.store.epoch_release(&key, &lease).await {
308 Ok(()) => Ok(()),
309 Err(StoreError::PreconditionFailed(_)) => Ok(()), // already deposed
310 Err(e) => Err(e.into()),
311 }
312 } else {
313 Ok(())
314 }
315}