Skip to main content

flint_lean/
verbs.rs

1//! The one-shot verbs, and which of them takes the publish fence.
2//!
3//! Lifted out of `bin/flint_sync.rs` so the RULE — which verb claims —
4//! is a property the battery can execute rather than a line of dispatch
5//! nobody can reach from a test. `a_checkout_does_not_wait_out_a_\
6//! standing_lease` in `tests.rs` calls `read_only_then`, the same
7//! function the dispatch calls, rather than a re-implementation of it.
8
9use std::time::Duration;
10
11use super::lease::{self, ClaimOutcome};
12use super::{LeanError, Syncer};
13
14/// A comma list, trimmed, empties dropped. Returns `None` when the
15/// variable is unset or holds only separators — `Some(vec![])` would be
16/// an empty scope, and `checkout_scoped` refuses that rather than let it
17/// mean "everything".
18pub fn env_list(name: &str) -> Option<Vec<String>> {
19    let raw = std::env::var(name).ok()?;
20    let v: Vec<String> =
21        raw.split(',').map(|e| e.trim().to_string()).filter(|e| !e.is_empty()).collect();
22    if v.is_empty() {
23        return None;
24    }
25    Some(v)
26}
27
28pub enum Step {
29    Checkout,
30    Barrier,
31    Sync,
32    RecoverStaged,
33    /// The narrow/widen verb. `None` is the whole tree; an empty
34    /// argument list therefore cannot mean "narrow to nothing".
35    Rescope(Option<Vec<String>>),
36}
37
38impl Step {
39    /// Does this verb need the publish fence?
40    ///
41    /// The epoch cell arbitrates who may INSTALL a manifest. A verb
42    /// that installs none does not belong in that queue, and putting it
43    /// there does not make it safer — see `read_only_then` for what it
44    /// actually cost.
45    ///
46    /// `checkout` is the only `false` today. `rescope` reads the bucket
47    /// and writes nothing to it either, but it rewrites the held set
48    /// and leaves a replayable intent behind, so it stays fenced until
49    /// someone has walked that crash matrix with two of them running;
50    /// `sync`, `barrier` and `recover-staged` all publish.
51    pub fn installs_nothing_in_the_bucket(&self) -> bool {
52        match self {
53            Step::Checkout => true,
54            Step::Barrier | Step::Sync | Step::RecoverStaged | Step::Rescope(_) => false,
55        }
56    }
57}
58
59/// The one door every one-shot verb goes through. The ROUTING lives
60/// here rather than in the binary's argv match so that the battery
61/// exercises the same decision the shipped binary makes.
62pub async fn run_verb(sc: &mut Syncer, step: Step) -> Result<(), LeanError> {
63    if step.installs_nothing_in_the_bucket() {
64        read_only_then(sc, step).await
65    } else {
66        claim_then(sc, step).await
67    }
68}
69
70pub async fn claim(sc: &mut Syncer) -> Result<(), LeanError> {
71    // Before the first claim step: is this prefix ours to claim at all?
72    lease::verify_claim(sc).await?;
73    lease::warn_if_prefix_is_shared(sc).await;
74    let mut answered_owed = false;
75    loop {
76        match lease::claim_step(sc).await? {
77            ClaimOutcome::Claimed(lease) => {
78                eprintln!("flint-sync: holding epoch {}", lease.epoch);
79                return Ok(());
80            }
81            ClaimOutcome::Waiting { quiet_polls } => {
82                if !answered_owed {
83                    answered_owed = true;
84                    match sc.refuse_what_this_incarnation_can_never_honor().await {
85                        Ok(true) => eprintln!(
86                            "flint-sync: a foreign holder stands and this incarnation owes an \
87                             ack it can never honor — refused-fenced written, marker fenced"
88                        ),
89                        Ok(false) => {}
90                        Err(e) => {
91                            // Never let this block the claim: a fresh
92                            // pod must still take over.
93                            answered_owed = false;
94                            eprintln!("flint-sync: could not settle owed acks while waiting: {e}");
95                        }
96                    }
97                }
98                eprintln!("flint-sync: waiting on the standing lease (quiet {quiet_polls}/6)");
99                tokio::time::sleep(Duration::from_secs(10)).await;
100            }
101        }
102    }
103}
104
105/// Take the writer's lease, do one step, release it.
106///
107/// Every verb that can change the BUCKET comes through here. `checkout`
108/// deliberately does not — see `read_only_then`.
109pub async fn claim_then(sc: &mut Syncer, step: Step) -> Result<(), LeanError> {
110    claim(sc).await?;
111    let out = run_step(sc, step).await;
112    let _ = lease::release(sc).await;
113    out
114}
115
116/// A verb that reads the bucket and writes only this pod's own tree.
117///
118/// It does NOT claim the epoch, and that is the point. The epoch cell
119/// is a PUBLISH fence: it decides which of several syncers may install
120/// a manifest. A checkout installs nothing — it GETs the manifest and
121/// GETs the objects it cites, and every one of those GETs already
122/// carries the cited etag as `If-Match`, so a publisher racing a reader
123/// yields a 412 (handled, loudly, in `checkout.rs`) and never a torn
124/// file.
125///
126/// What claiming actually bought was a DEADLOCK dressed as mutual
127/// exclusion. `claim_step` supersedes a foreign holder only after six
128/// observations in which its token did not advance — and a LIVE holder
129/// renews, so its token always advances. A checkout that met a running
130/// publisher therefore waited forever, and a publisher that met a
131/// checkout waited for it (forge's export already carries a timeout for
132/// exactly this: `export.rs`, "waits for a foreign lease forever").
133/// Measured 2026-09-11: four syncers reading disjoint subtrees of one
134/// prefix ran 2.1x faster with the claim skipped, and 0.41x — SLOWER
135/// than one syncer — with it, because they serialised on the cell.
136///
137/// Two things here are not the lease and stay:
138///   * `verify_claim` — the project-id precondition. A refusal, not a
139///     fence: this prefix is another project's and must not be read or
140///     written by us at all.
141///   * `warn_if_prefix_is_shared` — a diagnostic nobody else emits.
142///
143/// `status` and `ctl` have taken no lease since they shipped, for the
144/// same reason stated the same way; this makes `checkout` the third.
145pub async fn read_only_then(sc: &mut Syncer, step: Step) -> Result<(), LeanError> {
146    lease::verify_claim(sc).await?;
147    lease::warn_if_prefix_is_shared(sc).await;
148    run_step(sc, step).await
149}
150
151/// The verb bodies. Shared by both doors above so that the ONLY
152/// difference between a fenced verb and a lease-free one is the door,
153/// never a second copy of the work.
154pub async fn run_step(sc: &mut Syncer, step: Step) -> Result<(), LeanError> {
155    {
156        match step {
157            Step::Checkout => {
158                let r = sc.checkout_scoped(env_list("FLINT_SYNC_CHECKOUT_SCOPE")).await?;
159                eprintln!(
160                    "flint-sync: checkout — {} materialized, {} present, live-tree={}",
161                    r.materialized, r.skipped_present, r.resumed_live_tree
162                );
163                if let Some(sc) = &r.scope {
164                    // The declined count, not just the scope: a scope
165                    // that admits everything reads exactly like no
166                    // scope, and "it was configured" is not evidence
167                    // that it did anything.
168                    eprintln!(
169                        "flint-sync: checkout SCOPED to {:?} — {} citations declined",
170                        sc, r.out_of_scope
171                    );
172                }
173                // BYTES on the same line as the phases, deliberately:
174                // an A/B of the fetch window compares two wall clocks,
175                // and an arm that "wins" by materialising fewer bytes
176                // is the failure mode a timing-only line cannot show.
177                eprintln!(
178                    "flint-sync: phase manifest={:.3}s fetch={:.3}s commit={:.3}s bytes={} ranged={}",
179                    r.manifest_secs, r.fetch_secs, r.commit_secs, r.bytes, r.ranged
180                );
181            }
182            Step::Rescope(target) => {
183                let r = sc.rescope(target).await?;
184                eprintln!(
185                    "flint-sync: rescope to {:?} — uncited {}, unlinked {}, materialised {} \
186                     ({} bytes), already held {}",
187                    r.target, r.uncited, r.unlinked, r.materialized, r.bytes, r.already_held
188                );
189                // A kept path is the one outcome a caller must not miss:
190                // the scope now says one thing and the held set holds
191                // more, and the reason is an edit only they can resolve.
192                if !r.kept_dirty.is_empty() {
193                    eprintln!(
194                        "flint-sync: rescope KEPT {} path(s) with unpublished changes: {:?} — \
195                         publish them, then rescope again to drop them",
196                        r.kept_dirty.len(),
197                        r.kept_dirty
198                    );
199                }
200            }
201            Step::Barrier => {
202                let r = sc.run_barrier().await?;
203                eprintln!(
204                    "flint-sync: barrier seq={:?} up={} del={} parked={} consumed={}",
205                    r.seq,
206                    r.uploaded.len(),
207                    r.deleted.len(),
208                    r.parked.len(),
209                    r.consumed
210                );
211            }
212            Step::Sync => {
213                let r = sc.sync().await?;
214                println!("{}", serde_json::to_string_pretty(&r).unwrap());
215            }
216            Step::RecoverStaged => {
217                let r = sc.recover_staged().await?;
218                eprintln!(
219                    "flint-sync: recover-staged seq={:?} recited={} dangling={} unrecoverable={}",
220                    r.seq,
221                    r.recited.len(),
222                    r.dangling.len(),
223                    r.unrecoverable.len()
224                );
225                for p in &r.recited {
226                    eprintln!("flint-sync:   recited {p}");
227                }
228                // Named loudly: no verb can fix these — the retention
229                // backstop reaped the cited version and no newer
230                // generation survives.
231                for p in &r.unrecoverable {
232                    eprintln!("flint-sync:   UNRECOVERABLE {p}");
233                }
234                if !r.unrecoverable.is_empty() {
235                    return Err(LeanError::State(format!(
236                        "{} path(s) have no surviving version to cite",
237                        r.unrecoverable.len()
238                    )));
239                }
240            }
241        }
242        Ok(())
243    }
244}