Skip to main content

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}