1use 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 pub etag: String,
28 pub author: String,
30 pub added_unix: u64,
31 #[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub boundary_request: Option<VerbRequest>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub sync_request: Option<VerbRequest>,
81}
82
83pub struct LoadedInbox {
84 pub doc: InboxDoc,
85 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
126pub fn admits_hitl(doc: &InboxDoc) -> bool {
129 match &doc.window {
130 None => true,
131 Some(w) => now_unix() > w.deadline_unix,
132 }
133}
134
135pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum RequestedVerb {
167 Boundary,
168 Sync,
169}
170
171pub 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 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
209pub 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 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
247pub 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
272pub 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}