Skip to main content

flint_lean/
inbox.rs

1//! The inbox/window cell (plan §2.2): ONE CAS document that is both the
2//! HITL inbox and the barrier-window token.
3//!
4//! - The gateway appends an entry per UI write (object first, then the
5//!   inbox CAS) — never a direct manifest edit.
6//! - The syncer CAS-marks the window open (with a deadline + its
7//!   epoch) at barrier intent time and clears it after the manifest
8//!   CAS; every gateway replica checks the cell before admitting a UI
9//!   write, which closes the stateless-two-replica race the review
10//!   proved.
11//! - A dead syncer cannot wedge HITL forever: the window carries a
12//!   deadline, and a successor epoch may override a stale window.
13
14use serde::{Deserialize, Serialize};
15
16use flint_store::{
17    crc64_nvme, GenerationStamps, ObjectStore, PutCondition, StoreError,
18};
19
20use super::{now_unix, LeanConfig, LeanError, LeanResult};
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct InboxEntry {
24    pub path: String,
25    /// The object ETag the write produced — what consume fetches
26    /// If-Match (a superseded entry is dropped, not an error).
27    pub etag: String,
28    /// Who wrote it (user identity from the gateway; audit surface).
29    pub author: String,
30    pub added_unix: u64,
31    /// CRC-64/NVME (wire form) of the bytes the write produced, computed
32    /// by the writer over what it sent — the gateway over the request
33    /// body, a merge-preserved entry from the manifest it came from.
34    /// Consume verifies the fetched bytes against it before they are
35    /// written, which is the only verification a backend that attests
36    /// no checksum (Ozone) gets. `None` when the writer had no bytes in
37    /// hand (a draft promote is a server-side copy).
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub crc64_b64: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct Window {
44    pub epoch: u64,
45    pub deadline_unix: u64,
46}
47
48/// A verb asked for through the gateway door (§2.5, D14). Idempotent
49/// STATE, not a queue: repeated sets before the syncer acts collapse
50/// to the newest, which is why neither field needs a rate limit, an
51/// exactly-once protocol, or a clearing CAS of its own.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct VerbRequest {
54    pub requested_unix: u64,
55    pub requestor: String,
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct InboxDoc {
60    pub entries: Vec<InboxEntry>,
61    pub window: Option<Window>,
62    /// "Please publish" from outside the pod (§2.5). Deliberately a
63    /// FIELD and not a fake no-object `InboxEntry`: `consume_inbox`
64    /// HEADs `file_key(path)` for every entry, so an entry naming no
65    /// object lands in the NotFound arm as a spurious
66    /// `consume-object-missing` conflict — and special-casing the
67    /// single most safety-critical function in the crate to avoid that
68    /// is worse than either.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub boundary_request: Option<VerbRequest>,
71    /// "Please pull" from outside the pod — CARRIED, never performed
72    /// (D14). A boundary publishes what is already on disk and touches
73    /// no local file; `sync` re-derives the tree against the current
74    /// remote manifest and DELETES local files for remotely-deleted
75    /// paths. Performing that on a remote's say-so would upgrade what a
76    /// leaked gateway bearer can do from "publish, plus hand over these
77    /// N named objects" to "rewrite and delete across a running agent's
78    /// tree, at my timing, under a scope I choose".
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub sync_request: Option<VerbRequest>,
81}
82
83pub struct LoadedInbox {
84    pub doc: InboxDoc,
85    /// None ⇒ the cell does not exist yet (first CAS is If-None-Match:*).
86    pub etag: Option<String>,
87}
88
89pub async fn load(store: &dyn ObjectStore, cfg: &LeanConfig) -> LeanResult<LoadedInbox> {
90    match store.get_whole(&cfg.inbox_key(), None).await {
91        Ok((meta, bytes)) => {
92            let doc = serde_json::from_slice(&bytes)
93                .map_err(|e| LeanError::State(format!("inbox parse: {e}")))?;
94            Ok(LoadedInbox { doc, etag: Some(meta.etag) })
95        }
96        Err(StoreError::NotFound(_)) => Ok(LoadedInbox { doc: InboxDoc::default(), etag: None }),
97        Err(e) => Err(e.into()),
98    }
99}
100
101pub async fn cas_write(
102    store: &dyn ObjectStore,
103    cfg: &LeanConfig,
104    doc: &InboxDoc,
105    expected: Option<&str>,
106    epoch: u64,
107) -> LeanResult<String> {
108    let bytes = serde_json::to_vec_pretty(doc)
109        .map_err(|e| LeanError::State(format!("inbox: {e}")))?;
110    let crc = crc64_nvme(&bytes);
111    let cond = match expected {
112        Some(etag) => PutCondition::IfMatch(etag.to_string()),
113        None => PutCondition::IfNoneMatchAny,
114    };
115    let stamps = GenerationStamps {
116        generation: 0,
117        epoch,
118        flush_uuid: "inbox".into(),
119        boundary_source: None,
120        posix: None,
121    };
122    let meta = store.put_whole(&cfg.inbox_key(), bytes.into(), &cond, &stamps, crc).await?;
123    Ok(meta.etag)
124}
125
126/// Whether a gateway may admit a UI write right now. A window past its
127/// deadline does not block (the dead-syncer unwedge).
128pub fn admits_hitl(doc: &InboxDoc) -> bool {
129    match &doc.window {
130        None => true,
131        Some(w) => now_unix() > w.deadline_unix,
132    }
133}
134
135/// The GATEWAY side: land a UI write. The object PUT must already have
136/// happened (object first, inbox second — a crash between leaves an
137/// orphan object, never a tracked-but-absent entry). Refuses while a
138/// live barrier window is open. CAS-retries the append.
139pub async fn gateway_append(
140    store: &dyn ObjectStore,
141    cfg: &LeanConfig,
142    entry: InboxEntry,
143) -> LeanResult<()> {
144    for _ in 0..5 {
145        let loaded = load(store, cfg).await?;
146        if !admits_hitl(&loaded.doc) {
147            return Err(LeanError::State(
148                "barrier window open — retry after the window deadline".into(),
149            ));
150        }
151        let mut doc = loaded.doc;
152        // A newer write to the same path supersedes the queued one.
153        doc.entries.retain(|e| e.path != entry.path);
154        doc.entries.push(entry.clone());
155        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
156            Ok(_) => return Ok(()),
157            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
158            Err(e) => return Err(e),
159        }
160    }
161    Err(LeanError::State("inbox append lost 5 CAS races".into()))
162}
163
164/// Which verb a gateway request is asking for.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum RequestedVerb {
167    Boundary,
168    Sync,
169}
170
171/// The GATEWAY side of §2.5: set one of the two request fields under
172/// the same CAS discipline every other inbox write uses.
173///
174/// Deliberately NOT window-gated. `admits_hitl` exists because a HITL
175/// object write races the barrier that is about to publish the tree; a
176/// verb request touches no object and no path — refusing it during a
177/// window would make "please publish" fail precisely while a publish is
178/// in flight, which is the least useful moment to say no.
179pub async fn gateway_request(
180    store: &dyn ObjectStore,
181    cfg: &LeanConfig,
182    verb: RequestedVerb,
183    requestor: &str,
184) -> LeanResult<VerbRequest> {
185    let req = VerbRequest {
186        requested_unix: now_unix(),
187        requestor: requestor.chars().take(128).collect(),
188    };
189    for _ in 0..5 {
190        let loaded = load(store, cfg).await?;
191        let mut doc = loaded.doc;
192        // Newest wins: the field is state, so a burst collapses instead
193        // of queueing. This is what makes a rate limit unnecessary on
194        // the transport (the HONOR is still min-interval'd and budgeted
195        // like any other sentinel).
196        match verb {
197            RequestedVerb::Boundary => doc.boundary_request = Some(req.clone()),
198            RequestedVerb::Sync => doc.sync_request = Some(req.clone()),
199        }
200        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
201            Ok(_) => return Ok(req),
202            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
203            Err(e) => return Err(e),
204        }
205    }
206    Err(LeanError::State("inbox verb request lost 5 CAS races".into()))
207}
208
209/// The SYNCER side: open the barrier window (the intent). Succeeds
210/// over a closed cell, an expired window, or a LOWER epoch's stale
211/// window; refuses a live window at our own or a higher epoch.
212pub async fn open_window(
213    store: &dyn ObjectStore,
214    cfg: &LeanConfig,
215    epoch: u64,
216    deadline_unix: u64,
217) -> LeanResult<LoadedInbox> {
218    for _ in 0..5 {
219        let loaded = load(store, cfg).await?;
220        if let Some(w) = &loaded.doc.window {
221            let expired = now_unix() > w.deadline_unix;
222            if w.epoch > epoch {
223                return Err(LeanError::Fenced(format!(
224                    "window held by higher epoch {} (ours {})",
225                    w.epoch, epoch
226                )));
227            }
228            if w.epoch == epoch && !expired {
229                // Our own live window (a crashed earlier attempt inside
230                // the deadline): adopt it.
231                return Ok(loaded);
232            }
233        }
234        let mut doc = loaded.doc.clone();
235        doc.window = Some(Window { epoch, deadline_unix });
236        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
237            Ok(etag) => {
238                return Ok(LoadedInbox { doc, etag: Some(etag) });
239            }
240            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
241            Err(e) => return Err(e),
242        }
243    }
244    Err(LeanError::State("window open lost 5 CAS races".into()))
245}
246
247/// Drop integrated entries (after they are durably in the baseline).
248/// Entries that arrived after the consume are preserved.
249pub async fn drop_entries(
250    store: &dyn ObjectStore,
251    cfg: &LeanConfig,
252    epoch: u64,
253    consumed: &[InboxEntry],
254) -> LeanResult<()> {
255    for _ in 0..5 {
256        let loaded = load(store, cfg).await?;
257        let mut doc = loaded.doc.clone();
258        let before = doc.entries.len();
259        doc.entries.retain(|e| !consumed.contains(e));
260        if doc.entries.len() == before {
261            return Ok(());
262        }
263        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
264            Ok(_) => return Ok(()),
265            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
266            Err(e) => return Err(e),
267        }
268    }
269    Err(LeanError::State("inbox drop lost 5 CAS races".into()))
270}
271
272/// Clear the window (after the manifest CAS) and, in the same CAS,
273/// queue `queued` entries (the merge-preserved foreign entries handed
274/// to the next consume). Entries that arrived mid-barrier are
275/// preserved.
276pub async fn clear_window(
277    store: &dyn ObjectStore,
278    cfg: &LeanConfig,
279    epoch: u64,
280    queued: &[InboxEntry],
281) -> LeanResult<()> {
282    for _ in 0..5 {
283        let loaded = load(store, cfg).await?;
284        let mut doc = loaded.doc.clone();
285        if let Some(w) = &doc.window {
286            if w.epoch > epoch {
287                return Err(LeanError::Fenced(format!(
288                    "window rotated to higher epoch {} (ours {})",
289                    w.epoch, epoch
290                )));
291            }
292        }
293        doc.window = None;
294        for q in queued {
295            if !doc.entries.iter().any(|e| e.path == q.path && e.etag == q.etag) {
296                doc.entries.push(q.clone());
297            }
298        }
299        match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
300            Ok(_) => return Ok(()),
301            Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
302            Err(e) => return Err(e),
303        }
304    }
305    Err(LeanError::State("window clear lost 5 CAS races".into()))
306}