Skip to main content

flint_lean/
lease.rs

1//! The subtree lease, held for ONE BARRIER's commit section (design
2//! 2026-09-13, `docs/plans/flint-lean-writer-lease-and-gated-assessment.md`
3//! §4), with a FIFO ticket so W writers wait at most W barriers.
4//!
5//! What the cell arbitrates is who may INSTALL a manifest. Nothing
6//! else: checkout, sync, status and the uploads of a barrier hold
7//! nothing, because every one of those is guarded by the object's own
8//! etag (`If-Match`) and installs no citation. Between barriers nobody
9//! holds the cell, so a second writer on the same workspace is Ready in
10//! checkout time and publishes every floor — the life-long lease this
11//! replaces made it wait the first writer's LIFETIME in
12//! ContainerCreating (`verbs.rs` used to call it "a DEADLOCK dressed as
13//! mutual exclusion", and had already taken checkout out of it).
14//!
15//! Deliberately NOT `tier::epoch::claim`: that path (a) runs the
16//! bucket-wide MPU takeover sweep, which a project-scoped proxy denies
17//! (the sweep is an operator-side job in lean — plan §2.4), and (b)
18//! gates self-recognition on the hub's state-directory occupancy lock.
19//! Lean's self-recognition token is the PERSISTED INCARNATION ID: it is
20//! emptyDir-scoped, so only the same pod's restarted container inherits
21//! it — which is exactly the one case where immediate self-supersede is
22//! safe. A replacement pod gets a fresh id and must wait out the quiet
23//! polls; that observation ({last_token, quiet_polls}) persists so a
24//! container restart RESUMES it instead of resetting the clock.
25//!
26//! The claim step is one read plus at most one CAS. Its verdicts:
27//!
28//! - a FRESH cell: acquire.
29//! - a cell that names THIS incarnation at THIS epoch: our own acquire
30//!   whose response was lost — adopt it.
31//! - a RELEASED cell reserved for nobody, or for us: acquire (a clean
32//!   handoff; no manifest rotation).
33//! - a RELEASED cell reserved for someone else: queue up once, and wait
34//!   — unless the reservation has stood unclaimed across
35//!   `HANDOFF_QUIET_POLLS` spaced observations (the reserved holder
36//!   died), in which case take it.
37//! - a cell HELD by someone else (or by us at an epoch we never
38//!   recorded: an orphaned acquire): queue up once, and wait — unless it
39//!   has stood still across `QUIET_POLLS` spaced observations (the
40//!   holder died inside its commit section), in which case DEPOSE it and
41//!   rotate the manifest, exactly the straggler fence there always was.
42//!
43//! An observation is "spaced" when at least `QUIET_SPACING_SECS` passed
44//! since the previous counted one. The wait loop polls faster than
45//! that (`CLAIM_POLL_SECS`), so a release is seen within a second, but
46//! deadness is still judged over a minute of a token that does not move.
47//! A waiter's own enqueue moves the token; the holder's next renew or
48//! handoff 412s, re-reads, and adopts the longer queue (`renew`,
49//! `release`).
50
51use std::sync::Arc;
52use std::time::{Duration, Instant};
53
54use flint_store::{EpochLease, EpochState, ObjectStore, StoreError};
55
56use super::state::Incarnation;
57use super::{manifest, LeanError, LeanResult, Syncer};
58
59/// Spaced quiet observations before a HELD cell's holder is judged dead
60/// and deposed (6 x 10 s: the takeover threshold the drills were run at).
61pub const QUIET_POLLS: u32 = 6;
62/// Spaced quiet observations before a RELEASED cell's reservation is
63/// judged abandoned (the reserved waiter died before claiming).
64pub const HANDOFF_QUIET_POLLS: u32 = 2;
65/// Minimum spacing between two observations that COUNT toward either
66/// threshold. Polls in between only look for a release or a handoff.
67pub const QUIET_SPACING_SECS: u64 = 10;
68/// The wait loop's poll cadence: one GET of a few-hundred-byte cell.
69pub const CLAIM_POLL_SECS: u64 = 1;
70/// Longest a barrier waits for the cell before failing (and being
71/// retried at the next floor). Comfortably past the deposal threshold
72/// plus the spacing it needs, so a dead holder is always deposed within
73/// one wait; a LIVE holder that never releases is a bug, and the
74/// deadline is what keeps it from being a hang.
75pub const CLAIM_DEADLINE_SECS: u64 = 150;
76
77pub enum ClaimOutcome {
78    /// Fresh, released-for-us, deposed, or adopted: held.
79    Claimed(EpochLease),
80    /// Not ours yet. `quiet_polls` is the spaced-observation count
81    /// against the current verdict's threshold; `behind` names the
82    /// holder or the reserved waiter we are waiting on.
83    Waiting { quiet_polls: u32, behind: Option<String> },
84}
85
86/// Say so, loudly, if another product also writes this prefix.
87///
88/// Detection, deliberately NOT enforcement — see the forge twin. A
89/// lean workspace and a forge repository on one prefix arbitrate on
90/// different cells and will never fence each other, so without this
91/// nothing anywhere reports the condition.
92///
93/// Note what is NOT a finding: forge's legible export IS a lean
94/// workspace, published by forge rather than by an agent's syncer, so
95/// a syncer reading an export prefix finds only its own kind of cell.
96/// And an export nested under a repository's prefix puts this cell at
97/// `<prefix>/inner/.flint/lean/epoch`, which is not the key forge
98/// probes — the exact-key probe is what keeps that from reading as a
99/// collision.
100pub async fn warn_if_prefix_is_shared(sc: &Syncer) {
101    // A published mirror does not probe. Two reasons, and the second is
102    // the load-bearing one:
103    //
104    // Forge spawns a barrier per export, so this would be a recurring
105    // read for a condition that cannot change between two barriers of
106    // the same publisher. And forge echoes only the child lines
107    // containing "barrier" (export.rs:389), so a warning raised here
108    // would be read by nobody — a check whose output is discarded is
109    // worse than no check, because it looks like coverage.
110    //
111    // The prefix is still probed, ONCE, by the publisher at startup:
112    // see `warn_if_export_prefix_is_shared` in forge's twin. Moved, not
113    // dropped — an export prefix is a prefix two products both write
114    // legitimately, which makes it likelier than most to have a third
115    // pointed at it.
116    if sc.cfg.sole_writer {
117        return;
118    }
119    let found = flint_store::layout::neighbours(
120        sc.store.as_ref(),
121        &sc.cfg.prefix,
122        flint_store::layout::Writer::LeanWorkspace,
123    )
124    .await
125    .unwrap_or_default();
126    for f in found {
127        eprintln!("flint-sync: {}", f.report());
128    }
129}
130
131/// This pod's identity, minted once and persisted in the state
132/// directory (emptyDir-scoped: a restarted container inherits it, a
133/// replacement pod does not).
134pub fn incarnation(sc: &Syncer) -> LeanResult<Incarnation> {
135    match sc.state.load_incarnation()? {
136        Some(i) => Ok(i),
137        None => {
138            let i = Incarnation {
139                holder_id: format!("lean-{}", uuid::Uuid::new_v4()),
140                epoch: 0,
141                last_token: None,
142                quiet_polls: 0,
143            };
144            sc.state.save_incarnation(&i)?;
145            Ok(i)
146        }
147    }
148}
149
150/// One claim step. The caller loops on `Waiting` at its poll cadence;
151/// each call performs one read and at most one CAS (an acquire, or an
152/// enqueue). `count` says whether this observation is spaced far
153/// enough from the last counted one to advance the quiet thresholds —
154/// the loop decides that from its own clock, so the judgement of
155/// deadness stays tied to wall time however fast the loop polls.
156pub async fn claim_step(sc: &mut Syncer, count: bool) -> LeanResult<ClaimOutcome> {
157    let store: &Arc<dyn ObjectStore> = &sc.store;
158    let key = sc.cfg.epoch_key();
159    let mut inc = incarnation(sc)?;
160
161    let observed = store.epoch_read(&key).await?;
162    let Some(state) = observed else {
163        return match store.epoch_acquire(&key, &inc.holder_id, None).await {
164            Ok(lease) => {
165                inc.epoch = lease.epoch;
166                inc.last_token = None;
167                inc.quiet_polls = 0;
168                sc.state.save_incarnation(&inc)?;
169                sc.lease = Some(lease.clone());
170                sc.trace("claim", serde_json::json!({"verdict": "claimed", "how": "fresh", "epoch": lease.epoch}));
171                Ok(ClaimOutcome::Claimed(lease))
172            }
173            Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {
174                Ok(ClaimOutcome::Waiting { quiet_polls: 0, behind: None })
175            }
176            Err(e) => Err(e.into()),
177        };
178    };
179
180    // Self-recognition needs the EPOCH too (review 2026-09-12, lease-3 /
181    // audit #7): a cell naming this holder at an epoch this incarnation
182    // never recorded is our own acquire whose response was lost, or
183    // whose rotation failed after it landed — the straggler it deposed
184    // may still be mid-barrier, so that path takes the takeover
185    // rotation like any other.
186    let same_holder = state.holder_id == inc.holder_id;
187    let ours = same_holder && state.epoch == inc.epoch && !state.released;
188    let quiet = inc.last_token.as_deref() == Some(state.token.as_str());
189    let counted_quiet = if count {
190        if quiet {
191            inc.quiet_polls + 1
192        } else {
193            0
194        }
195    } else {
196        inc.quiet_polls
197    };
198
199    if ours {
200        // Our own acquire whose response was lost, or a cell a previous
201        // container of this pod is still named on at the epoch we
202        // recorded: adopt it IN PLACE — token and queue as they stand,
203        // no write. (Re-acquiring would move the epoch for nothing and
204        // make every reader of the cell count a barrier that never ran.)
205        let lease = EpochLease {
206            holder_id: state.holder_id,
207            epoch: state.epoch,
208            token: state.token,
209            waiters: state.waiters,
210        };
211        inc.last_token = None;
212        inc.quiet_polls = 0;
213        sc.state.save_incarnation(&inc)?;
214        sc.lease = Some(lease.clone());
215        sc.trace("claim", serde_json::json!({"verdict": "claimed", "how": "adopted-own", "epoch": lease.epoch}));
216        return Ok(ClaimOutcome::Claimed(lease));
217    }
218
219    // The verdict. `take` = acquire now; `rotate` = the acquire is a
220    // deposal of a possibly-live straggler and must fence it.
221    let (take, rotate, behind) = if same_holder && !state.released {
222        // Orphaned own: our acquire landed and its response was lost, or
223        // its rotation failed after it landed. The straggler it deposed
224        // may still be mid-commit, so this takes the rotation too.
225        (true, true, None)
226    } else if state.released {
227        match state.handoff.as_deref() {
228            None => (true, false, None),
229            Some(h) if h == inc.holder_id => (true, false, None),
230            Some(h) => (counted_quiet >= HANDOFF_QUIET_POLLS, false, Some(h.to_string())),
231        }
232    } else {
233        // Held by someone else, or by us at an epoch we never recorded.
234        (counted_quiet >= QUIET_POLLS, true, Some(state.holder_id.clone()))
235    };
236
237    if take {
238        // Rotation is needed ONLY for the unreleased-foreign takeover (a
239        // possibly-live straggler mid-commit). A released cell is a
240        // clean handoff — the holder's barrier completed before its
241        // release — and self-recognition means the previous container's
242        // process (and any in-flight write of its) died with it.
243        // Rotating on those paths is pure manifest churn: at 100k+
244        // entries it is a multi-MB GET+PUT per claim, it double-bumps
245        // seq, and it defeats the no-change barrier's early exit
246        // (measured on the 0b rig). Under the per-barrier lease that
247        // would be every boundary.
248        let prior = serde_json::json!({"holder": state.holder_id, "epoch": state.epoch, "released": state.released,
249            "handoff": state.handoff, "waiters": state.waiters});
250        let how = if rotate && same_holder {
251            "orphaned-own"
252        } else if rotate {
253            "deposed"
254        } else if state.handoff.as_deref().map(|h| h != inc.holder_id).unwrap_or(false) {
255            "skipped-handoff"
256        } else {
257            "released"
258        };
259        return match store.epoch_acquire(&key, &inc.holder_id, Some(&state)).await {
260            Ok(lease) => {
261                if rotate {
262                    manifest::rotate_for_takeover(store.as_ref(), &sc.cfg, lease.epoch).await?;
263                }
264                inc.epoch = lease.epoch;
265                inc.last_token = None;
266                inc.quiet_polls = 0;
267                sc.state.save_incarnation(&inc)?;
268                sc.lease = Some(lease.clone());
269                sc.trace("claim", serde_json::json!({"verdict": "claimed", "how": how, "epoch": lease.epoch, "prior": prior}));
270                Ok(ClaimOutcome::Claimed(lease))
271            }
272            Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {
273                // Lost the race (a rival acquire, or an enqueue moved the
274                // token); restart the observation.
275                inc.last_token = None;
276                inc.quiet_polls = 0;
277                sc.state.save_incarnation(&inc)?;
278                Ok(ClaimOutcome::Waiting { quiet_polls: 0, behind })
279            }
280            Err(e) => Err(e.into()),
281        };
282    }
283
284    // Not ours yet: make sure we are in the queue, ONCE. The enqueue
285    // moves the token, so the observation restarts from the token we
286    // wrote — our own append must not read as the holder's renewal,
287    // and it must not reset a count that a rival's append did not.
288    let token = if state.waiters.iter().any(|w| w == &inc.holder_id) {
289        state.token.clone()
290    } else {
291        match store.epoch_enqueue(&key, &state, &inc.holder_id).await {
292            Ok(after) => after.token,
293            Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {
294                // Somebody else moved the cell first; observe again.
295                state.token.clone()
296            }
297            Err(e) => return Err(e.into()),
298        }
299    };
300    if count {
301        inc.quiet_polls = counted_quiet;
302        inc.last_token = Some(token);
303        sc.state.save_incarnation(&inc)?;
304    }
305    Ok(ClaimOutcome::Waiting { quiet_polls: counted_quiet, behind })
306}
307
308/// The wait loop: claim the cell for a commit section, or give up
309/// after `CLAIM_DEADLINE_SECS` so a holder that never releases turns
310/// into a failed (and retried) barrier rather than a hang.
311pub async fn claim(sc: &mut Syncer) -> LeanResult<EpochLease> {
312    let started = Instant::now();
313    let mut last_counted: Option<Instant> = None;
314    let mut reported: Option<String> = None;
315    let spacing = Duration::from_secs(sc.cfg.claim_quiet_spacing_secs);
316    let deadline = Duration::from_secs(sc.cfg.claim_deadline_secs);
317    let poll = Duration::from_secs(sc.cfg.claim_poll_secs);
318    loop {
319        let count = last_counted.map(|t| t.elapsed() >= spacing).unwrap_or(true);
320        match claim_step(sc, count).await? {
321            ClaimOutcome::Claimed(lease) => return Ok(lease),
322            ClaimOutcome::Waiting { quiet_polls, behind } => {
323                if count {
324                    last_counted = Some(Instant::now());
325                    sc.trace("claim", serde_json::json!({"verdict": "waiting", "behind": behind, "quiet_polls": quiet_polls,
326                        "waited_ms": started.elapsed().as_millis() as u64}));
327                    // Once per spaced observation, not once per second.
328                    let line = format!(
329                        "flint-sync: waiting for the publish fence behind {} (quiet {quiet_polls})",
330                        behind.as_deref().unwrap_or("a rival claim")
331                    );
332                    if reported.as_deref() != Some(line.as_str()) {
333                        eprintln!("{line}");
334                        reported = Some(line);
335                    }
336                }
337                if started.elapsed() >= deadline {
338                    sc.trace("claim", serde_json::json!({"verdict": "deadline", "behind": behind,
339                        "waited_ms": started.elapsed().as_millis() as u64}));
340                    return Err(LeanError::State(format!(
341                        "could not acquire the publish fence within {}s \
342                         (behind {}); this barrier is abandoned and retried at the next floor — \
343                         its uploads are durable and the next barrier adopts them",
344                        deadline.as_secs(),
345                        behind.as_deref().unwrap_or("a rival claim")
346                    )));
347                }
348                tokio::time::sleep(poll).await;
349            }
350        }
351    }
352}
353
354/// What this syncer is OBSERVED to be doing, for the cell's echo
355/// (boundary-verbs plan §2.6). Computed from local files only — the
356/// same store-free discipline as `write_gauges`, and for the same
357/// reason: this rides writes that already happen, so it must not add a
358/// request to the one tick every idle workspace in the fleet pays (leg
359/// B8's oracle counts them).
360fn observed_echo(sc: &Syncer) -> Option<String> {
361    let g = sc.load_gauges().ok()?;
362    let (seq, unix) = g.last_boundary.as_ref().map(|b| (b.seq, b.unix)).unwrap_or((0, 0));
363    // The pre-flight verdict lives in the marker the AGENT reads; the
364    // operator has no other way to see it, so it rides the echo.
365    let caps: Option<super::control::Capabilities> =
366        std::fs::read(sc.cfg.control_dir().join(super::control::CAPABILITIES))
367            .ok()
368            .and_then(|b| serde_json::from_slice(&b).ok());
369    serde_json::to_string(&flint_store::LeaseEcho {
370        syncer_version: super::SYNCER_VERSION.to_string(),
371        protocol: super::SENTINEL_PROTOCOL,
372        last_cited_seq: seq,
373        last_cited_unix: unix,
374        sentinel_verbs_active: caps.map(|c| !c.verbs.is_empty()).unwrap_or(false),
375        metrics_bound: sc.load_metrics_posture().filter(|m| m.enabled).map(|m| m.bound),
376    })
377    .ok()
378}
379
380/// Re-read the cell after a 412 and, if it still names this holder at
381/// this epoch unreleased, hand back its current token and queue: the
382/// only things that move OUR cell's token are our own writes whose
383/// response was lost and the waiters' enqueues, and neither is a
384/// takeover. Anything else is the fence.
385async fn still_ours(sc: &Syncer, key: &str, lease: &EpochLease) -> Option<EpochLease> {
386    match sc.store.epoch_read(key).await {
387        Ok(Some(state))
388            if state.holder_id == lease.holder_id && state.epoch == lease.epoch && !state.released =>
389        {
390            Some(EpochLease {
391                holder_id: state.holder_id,
392                epoch: state.epoch,
393                token: state.token,
394                waiters: state.waiters,
395            })
396        }
397        _ => None,
398    }
399}
400
401/// Renew the held lease inside a commit section; a 412 that is not our
402/// own moved token means deposed — the caller must abandon the barrier
403/// (self-fence).
404///
405/// The renewal also carries the observed-state echo (§2.6).
406pub async fn renew(sc: &mut Syncer) -> LeanResult<()> {
407    let key = sc.cfg.epoch_key();
408    let lease = sc
409        .lease
410        .clone()
411        .ok_or_else(|| LeanError::State("renew without a lease".into()))?;
412    let echo = observed_echo(sc);
413    match sc.store.epoch_renew(&key, &lease, echo.as_deref()).await {
414        Ok(l) => {
415            sc.lease = Some(l);
416            // The renewal is a probe of our own credentials, so it is
417            // also a place a pause can be observed to have ENDED.
418            // Best-effort: a gauge that failed to write must not fail a
419            // renewal that succeeded.
420            let _ = sc.clear_auth_pause();
421            Ok(())
422        }
423        Err(StoreError::PreconditionFailed(e)) => {
424            // A 412 is not yet a deposal. The renew CAS is If-Match on
425            // OUR token, and three things move that token: a
426            // successor's acquire, a waiter's enqueue, or our own
427            // previous write whose RESPONSE was lost. Only the first is
428            // a takeover, and treating the others as one made a live
429            // holder fence itself (audit 2026-09-03, finding 2). One
430            // read tells the cases apart.
431            match still_ours(sc, &key, &lease).await {
432                Some(adopted) => {
433                    eprintln!(
434                        "flint-sync: renew 412 on a cell that is still ours (epoch {}, {} queued): \
435                         adopting its token, not fencing",
436                        adopted.epoch,
437                        adopted.waiters.len()
438                    );
439                    // Review 2026-09-12, lease-2: the adoption alone
440                    // writes nothing, so the token would stand still
441                    // for a whole takeover threshold and a waiter could
442                    // count a live holder dead. One renew moves it; if
443                    // that write fails the adopted token stands and the
444                    // next tick tries again.
445                    sc.lease = Some(
446                        sc.store
447                            .epoch_renew(&key, &adopted, echo.as_deref())
448                            .await
449                            .unwrap_or(adopted),
450                    );
451                    let _ = sc.clear_auth_pause();
452                    Ok(())
453                }
454                None => {
455                    sc.lease = None;
456                    Err(LeanError::Fenced(format!("deposed at renew: {e}")))
457                }
458            }
459        }
460        // 401/403 is not contention and not a bucket fault; retrying
461        // cannot fix it. Record when the pause began while we still can:
462        // nothing we write to the STORE can carry this, because the
463        // request that would carry it is the one being refused (design
464        // §6.3).
465        Err(e @ StoreError::Auth(_)) => {
466            let _ = sc.note_auth_pause();
467            Err(e.into())
468        }
469        Err(e) => Err(e.into()),
470    }
471}
472
473/// The claim precondition (audit 2026-09-03, finding 5). With a project
474/// id stamped (`FLINT_SYNC_PROJECT_ID`), refuse to run over a prefix
475/// whose claim cell names ANOTHER project. The operator's refuse-foreign
476/// was advisory on the data plane: a CR the operator had not yet judged
477/// resolved to its spec, and the syncer checked out and republished over
478/// the foreign project's manifest. The cell is durable in the bucket, so
479/// the data plane can read it — one GET before the first claim step.
480/// Absent cell ⇒ a fresh prefix, claimable; unstamped ⇒ no check (the
481/// pre-operator posture, which the drill runs). A foreign claim is
482/// `LeanError::Refused`: the process exits `EXIT_REFUSED`, which the
483/// delivery treats as final (tear down, name the reason) rather than
484/// as one more crash to restart.
485pub async fn verify_claim(sc: &Syncer) -> LeanResult<()> {
486    let Some(mine) = sc.cfg.project_id.as_deref() else { return Ok(()) };
487    let key = sc.cfg.claim_key();
488    match sc.store.get_whole(&key, None).await {
489        Ok((_, body)) => {
490            let doc: serde_json::Value = serde_json::from_slice(&body)
491                .map_err(|e| LeanError::State(format!("claim cell {key} is unparseable: {e}")))?;
492            match doc.get("project_id").and_then(|v| v.as_str()) {
493                Some(p) if p == mine => Ok(()),
494                Some(p) => Err(LeanError::Refused(format!(
495                    "prefix {} is claimed by project {p:?}; this workspace is project {mine:?} — \
496                     refusing to check out or publish over another project's data (delete the \
497                     standing claim explicitly if this reuse is intended)",
498                    sc.cfg.prefix
499                ))),
500                None => Err(LeanError::State(format!("claim cell {key} names no project_id"))),
501            }
502        }
503        Err(StoreError::NotFound(_)) => Ok(()),
504        Err(e) => Err(e.into()),
505    }
506}
507
508/// Release at the end of a commit section: the cell is handed to the
509/// queue head and keeps this barrier's echo. A 412 is re-read once —
510/// a waiter's enqueue moved the token — and retried with the adopted
511/// token; a cell that is no longer ours is nobody's to release.
512pub async fn release(sc: &mut Syncer) -> LeanResult<()> {
513    let key = sc.cfg.epoch_key();
514    let Some(lease) = sc.lease.take() else { return Ok(()) };
515    let echo = observed_echo(sc);
516    sc.trace("release", serde_json::json!({"epoch": lease.epoch, "waiters_at_claim": lease.waiters}));
517    match sc.store.epoch_handoff(&key, &lease, echo.as_deref()).await {
518        Ok(()) => Ok(()),
519        Err(StoreError::PreconditionFailed(_)) => match still_ours(sc, &key, &lease).await {
520            Some(adopted) => match sc.store.epoch_handoff(&key, &adopted, echo.as_deref()).await {
521                Ok(()) => Ok(()),
522                Err(StoreError::PreconditionFailed(_)) => Ok(()), // deposed meanwhile
523                Err(e) => Err(e.into()),
524            },
525            None => Ok(()), // already deposed: the cell is the successor's
526        },
527        Err(e) => Err(e.into()),
528    }
529}
530
531/// A restarted container that finds the cell HELD by its own
532/// incarnation releases it: it holds nothing in memory, the intent
533/// journal replays whatever the previous container left, and a cell
534/// left held would cost every other writer a 60 s deposal wait.
535pub async fn release_stale_own(sc: &mut Syncer) -> LeanResult<()> {
536    let key = sc.cfg.epoch_key();
537    let inc = incarnation(sc)?;
538    if let Some(state) = sc.store.epoch_read(&key).await? {
539        if state.holder_id == inc.holder_id && !state.released {
540            eprintln!(
541                "flint-sync: the publish fence was left held by a previous container of this pod \
542                 (epoch {}); releasing it",
543                state.epoch
544            );
545            sc.lease = Some(EpochLease {
546                holder_id: state.holder_id,
547                epoch: state.epoch,
548                token: state.token,
549                waiters: state.waiters,
550            });
551            return release(sc).await;
552        }
553    }
554    Ok(())
555}
556
557// An observed cell, re-exported for the callers that fence on one.
558pub type Observed = EpochState;