flint_lean/verbs.rs
1//! The one-shot verbs, and which of them holds the publish fence for
2//! its whole run.
3//!
4//! Lifted out of `bin/flint_sync.rs` so the RULE — which verb claims —
5//! is a property the battery can execute rather than a line of dispatch
6//! nobody can reach from a test. `a_checkout_does_not_wait_out_a_\
7//! standing_lease` in `tests.rs` calls `lease_free_then`, the same
8//! function the dispatch calls, rather than a re-implementation of it.
9
10use super::lease;
11use super::{LeanError, Syncer};
12
13/// A comma list, trimmed, empties dropped. Returns `None` when the
14/// variable is unset or holds only separators — `Some(vec![])` would be
15/// an empty scope, and `checkout_scoped` refuses that rather than let it
16/// mean "everything".
17pub fn env_list(name: &str) -> Option<Vec<String>> {
18 let raw = std::env::var(name).ok()?;
19 let v: Vec<String> =
20 raw.split(',').map(|e| e.trim().to_string()).filter(|e| !e.is_empty()).collect();
21 if v.is_empty() {
22 return None;
23 }
24 Some(v)
25}
26
27pub enum Step {
28 Checkout,
29 Barrier,
30 Sync,
31 /// The narrow/widen verb. `None` is the whole tree; an empty
32 /// argument list therefore cannot mean "narrow to nothing".
33 Rescope(Option<Vec<String>>),
34}
35
36impl Step {
37 /// Does this verb hold the publish fence for its WHOLE run?
38 ///
39 /// The epoch cell arbitrates who may INSTALL a manifest, and since
40 /// the lease is held per barrier (design 2026-09-13 §4) a barrier
41 /// claims it INSIDE — after its uploads, for the commit section
42 /// only — so the `barrier` verb needs no outer claim. `checkout`
43 /// and `sync` install nothing: every GET they make carries the
44 /// cited etag as `If-Match`, so a publisher racing them yields a
45 /// handled 412, never a torn file, and holding the fence around
46 /// them only ever serialised readers on one cell (four syncers
47 /// reading disjoint subtrees ran 2.1x faster without it, 0.41x with
48 /// it — measured 2026-09-11).
49 ///
50 /// `rescope` is the only `true`. It reads the bucket and writes
51 /// nothing to it either, but it rewrites the held set and leaves a
52 /// replayable intent behind, so it stays fenced until someone has
53 /// walked that crash matrix with two of them running.
54 pub fn holds_the_fence_throughout(&self) -> bool {
55 match self {
56 Step::Rescope(_) => true,
57 Step::Checkout | Step::Barrier | Step::Sync => false,
58 }
59 }
60}
61
62/// The one door every one-shot verb goes through. The ROUTING lives
63/// here rather than in the binary's argv match so that the battery
64/// exercises the same decision the shipped binary makes.
65pub async fn run_verb(sc: &mut Syncer, step: Step) -> Result<(), LeanError> {
66 // A reader publishes nothing and holds no fence. `rescope` writes
67 // nothing either, but it claims the fence for its whole run, and a
68 // reader's credential cannot.
69 match &step {
70 Step::Barrier => sc.refuse_if_read("barrier")?,
71 Step::Rescope(_) => sc.refuse_if_read("rescope")?,
72 Step::Checkout | Step::Sync => {}
73 }
74 if step.holds_the_fence_throughout() {
75 claim_then(sc, step).await
76 } else {
77 lease_free_then(sc, step).await
78 }
79}
80
81/// Take the fence, do one step, release it.
82pub async fn claim_then(sc: &mut Syncer, step: Step) -> Result<(), LeanError> {
83 lease::verify_claim(sc).await?;
84 lease::warn_if_prefix_is_shared(sc).await;
85 let held = lease::claim(sc).await?;
86 eprintln!("flint-sync: holding epoch {}", held.epoch);
87 let out = run_step(sc, step).await;
88 if let Err(e) = lease::release(sc).await {
89 eprintln!("flint-sync: the publish fence could not be released ({e}); a waiter deposes it");
90 }
91 out
92}
93
94/// A verb that claims nothing up front. Two things here are not the
95/// lease and stay:
96/// * `verify_claim` — the project-id precondition. A refusal, not a
97/// fence: this prefix is another project's and must not be read or
98/// written by us at all.
99/// * `warn_if_prefix_is_shared` — a diagnostic nobody else emits.
100pub async fn lease_free_then(sc: &mut Syncer, step: Step) -> Result<(), LeanError> {
101 lease::verify_claim(sc).await?;
102 lease::warn_if_prefix_is_shared(sc).await;
103 run_step(sc, step).await
104}
105
106/// The verb bodies. Shared by both doors above so that the ONLY
107/// difference between a fenced verb and a lease-free one is the door,
108/// never a second copy of the work.
109pub async fn run_step(sc: &mut Syncer, step: Step) -> Result<(), LeanError> {
110 {
111 match step {
112 Step::Checkout => {
113 let r = sc.checkout_scoped(env_list("FLINT_SYNC_CHECKOUT_SCOPE")).await?;
114 eprintln!(
115 "flint-sync: checkout — {} materialized, {} present, live-tree={}",
116 r.materialized, r.skipped_present, r.resumed_live_tree
117 );
118 if let Some(sc) = &r.scope {
119 // The declined count, not just the scope: a scope
120 // that admits everything reads exactly like no
121 // scope, and "it was configured" is not evidence
122 // that it did anything.
123 eprintln!(
124 "flint-sync: checkout SCOPED to {:?} — {} citations declined",
125 sc, r.out_of_scope
126 );
127 }
128 // BYTES on the same line as the phases, deliberately:
129 // an A/B of the fetch window compares two wall clocks,
130 // and an arm that "wins" by materialising fewer bytes
131 // is the failure mode a timing-only line cannot show.
132 eprintln!(
133 "flint-sync: phase manifest={:.3}s fetch={:.3}s commit={:.3}s bytes={} ranged={}",
134 r.manifest_secs, r.fetch_secs, r.commit_secs, r.bytes, r.ranged
135 );
136 }
137 Step::Rescope(target) => {
138 let r = sc.rescope(target).await?;
139 eprintln!(
140 "flint-sync: rescope to {:?} — uncited {}, unlinked {}, materialised {} \
141 ({} bytes), already held {}",
142 r.target, r.uncited, r.unlinked, r.materialized, r.bytes, r.already_held
143 );
144 // A kept path is the one outcome a caller must not miss:
145 // the scope now says one thing and the held set holds
146 // more, and the reason is an edit only they can resolve.
147 if !r.kept_dirty.is_empty() {
148 eprintln!(
149 "flint-sync: rescope KEPT {} path(s) with unpublished changes: {:?} — \
150 publish them, then rescope again to drop them",
151 r.kept_dirty.len(),
152 r.kept_dirty
153 );
154 }
155 }
156 Step::Barrier => {
157 let r = sc.run_barrier().await?;
158 eprintln!(
159 "flint-sync: barrier seq={:?} up={} del={} parked={} consumed={}{}",
160 r.seq,
161 r.uploaded.len(),
162 r.deleted.len(),
163 r.parked.len(),
164 r.consumed,
165 // Only on a store whose collector had to give way
166 // (`conformance.rs`), so the ordinary line stays
167 // byte-identical for whatever greps it. `del` counts
168 // objects COLLECTED; these left the boundary and
169 // stayed in the bucket, and a barrier that said
170 // nothing about them would read as a tidy delete.
171 if r.leaked.is_empty() {
172 String::new()
173 } else {
174 format!(" leaked={}", r.leaked.len())
175 }
176 );
177 }
178 Step::Sync => {
179 let r = sc.sync().await?;
180 println!("{}", serde_json::to_string_pretty(&r).unwrap());
181 }
182 }
183 Ok(())
184 }
185}