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, Serialize, Deserialize, PartialEq, Eq)]
71pub struct Removal {
72 pub path: String,
73 pub author: String,
74 pub requested_unix: u64,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub moved_to: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub refused: Option<Refusal>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct Refusal {
92 pub kind: String,
95 pub message: String,
96 pub at_unix: u64,
97}
98
99pub const REFUSED_REMOVALS_CAP: usize = 100;
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct InboxDoc {
104 pub entries: Vec<InboxEntry>,
105 pub window: Option<Window>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub boundary_request: Option<VerbRequest>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub sync_request: Option<VerbRequest>,
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub removals: Vec<Removal>,
129}
130
131impl InboxDoc {
132 pub fn pending_removal(&self, path: &str) -> Option<&Removal> {
138 self.removals.iter().find(|r| r.path == path && r.refused.is_none())
139 }
140}
141
142pub struct LoadedInbox {
143 pub doc: InboxDoc,
144 pub etag: Option<String>,
146}
147
148pub async fn load(store: &dyn ObjectStore, cfg: &LeanConfig) -> LeanResult<LoadedInbox> {
149 match store.get_whole(&cfg.inbox_key(), None).await {
150 Ok((meta, bytes)) => {
151 let doc = serde_json::from_slice(&bytes)
152 .map_err(|e| LeanError::State(format!("inbox parse: {e}")))?;
153 Ok(LoadedInbox { doc, etag: Some(meta.etag) })
154 }
155 Err(StoreError::NotFound(_)) => Ok(LoadedInbox { doc: InboxDoc::default(), etag: None }),
156 Err(e) => Err(e.into()),
157 }
158}
159
160pub async fn cas_write(
161 store: &dyn ObjectStore,
162 cfg: &LeanConfig,
163 doc: &InboxDoc,
164 expected: Option<&str>,
165 epoch: u64,
166) -> LeanResult<String> {
167 let bytes = serde_json::to_vec_pretty(doc)
168 .map_err(|e| LeanError::State(format!("inbox: {e}")))?;
169 let crc = crc64_nvme(&bytes);
170 let cond = match expected {
171 Some(etag) => PutCondition::IfMatch(etag.to_string()),
172 None => PutCondition::IfNoneMatchAny,
173 };
174 let stamps = GenerationStamps {
175 generation: 0,
176 epoch,
177 flush_uuid: "inbox".into(),
178 boundary_source: None,
179 posix: None,
180 };
181 let meta = store.put_whole(&cfg.inbox_key(), bytes.into(), &cond, &stamps, crc).await?;
182 Ok(meta.etag)
183}
184
185pub fn admits_hitl(doc: &InboxDoc) -> bool {
188 match &doc.window {
189 None => true,
190 Some(w) => now_unix() > w.deadline_unix,
191 }
192}
193
194pub async fn gateway_append(
199 store: &dyn ObjectStore,
200 cfg: &LeanConfig,
201 entry: InboxEntry,
202) -> LeanResult<()> {
203 for _ in 0..5 {
204 let loaded = load(store, cfg).await?;
205 if !admits_hitl(&loaded.doc) {
206 return Err(LeanError::State(
207 "barrier window open — retry after the window deadline".into(),
208 ));
209 }
210 let mut doc = loaded.doc;
211 doc.entries.retain(|e| e.path != entry.path);
213 doc.entries.push(entry.clone());
214 match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
215 Ok(_) => return Ok(()),
216 Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
217 Err(e) => return Err(e),
218 }
219 }
220 Err(LeanError::State("inbox append lost 5 CAS races".into()))
221}
222
223fn record_removal(doc: &mut InboxDoc, r: Removal) {
227 doc.removals.retain(|x| x.path != r.path);
228 doc.removals.push(r);
229}
230
231pub async fn gateway_remove(
241 store: &dyn ObjectStore,
242 cfg: &LeanConfig,
243 removals: Vec<Removal>,
244) -> LeanResult<()> {
245 for _ in 0..5 {
246 let loaded = load(store, cfg).await?;
247 let mut doc = loaded.doc;
248 for r in &removals {
249 record_removal(&mut doc, r.clone());
250 }
251 match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
252 Ok(_) => return Ok(()),
253 Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
254 Err(e) => return Err(e),
255 }
256 }
257 Err(LeanError::State("inbox removal lost 5 CAS races".into()))
258}
259
260pub async fn gateway_rename(
266 store: &dyn ObjectStore,
267 cfg: &LeanConfig,
268 entries: Vec<InboxEntry>,
269 removals: Vec<Removal>,
270) -> LeanResult<()> {
271 for _ in 0..5 {
272 let loaded = load(store, cfg).await?;
273 if !admits_hitl(&loaded.doc) {
274 return Err(LeanError::State(
275 "barrier window open — retry after the window deadline".into(),
276 ));
277 }
278 let mut doc = loaded.doc;
279 for e in &entries {
280 doc.entries.retain(|x| x.path != e.path);
281 doc.entries.push(e.clone());
282 }
283 for r in &removals {
284 record_removal(&mut doc, r.clone());
285 }
286 match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
287 Ok(_) => return Ok(()),
288 Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
289 Err(e) => return Err(e),
290 }
291 }
292 Err(LeanError::State("inbox rename lost 5 CAS races".into()))
293}
294
295pub async fn withdraw_removal(
300 store: &dyn ObjectStore,
301 cfg: &LeanConfig,
302 path: &str,
303) -> LeanResult<bool> {
304 for _ in 0..5 {
305 let loaded = load(store, cfg).await?;
306 let mut doc = loaded.doc;
307 let before = doc.removals.len();
308 doc.removals.retain(|r| r.path != path);
309 if doc.removals.len() == before {
310 return Ok(false);
311 }
312 match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
313 Ok(_) => return Ok(true),
314 Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
315 Err(e) => return Err(e),
316 }
317 }
318 Err(LeanError::State("inbox withdraw lost 5 CAS races".into()))
319}
320
321pub async fn settle_removals(
328 store: &dyn ObjectStore,
329 cfg: &LeanConfig,
330 epoch: u64,
331 applied: &[Removal],
332 refused: &[Removal],
333) -> LeanResult<()> {
334 if applied.is_empty() && refused.is_empty() {
335 return Ok(());
336 }
337 let same = |a: &Removal, b: &Removal| a.path == b.path && a.requested_unix == b.requested_unix;
338 for _ in 0..5 {
339 let loaded = load(store, cfg).await?;
340 let mut doc = loaded.doc.clone();
341 let mut changed = false;
342 doc.removals.retain(|r| {
343 let drop = applied.iter().any(|a| same(a, r));
344 changed |= drop;
345 !drop
346 });
347 for r in doc.removals.iter_mut() {
348 if r.refused.is_none() {
349 if let Some(f) = refused.iter().find(|f| same(f, r)) {
350 r.refused = f.refused.clone();
351 changed = true;
352 }
353 }
354 }
355 loop {
356 let refused_now = doc.removals.iter().filter(|r| r.refused.is_some()).count();
357 if refused_now <= REFUSED_REMOVALS_CAP {
358 break;
359 }
360 let oldest = doc
361 .removals
362 .iter()
363 .enumerate()
364 .filter(|(_, r)| r.refused.is_some())
365 .min_by_key(|(_, r)| r.refused.as_ref().map(|f| f.at_unix).unwrap_or(0))
366 .map(|(i, _)| i);
367 match oldest {
368 Some(i) => {
369 doc.removals.remove(i);
370 changed = true;
371 }
372 None => break,
373 }
374 }
375 if !changed {
376 return Ok(());
377 }
378 match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
379 Ok(_) => return Ok(()),
380 Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
381 Err(e) => return Err(e),
382 }
383 }
384 Err(LeanError::State("inbox removal settle lost 5 CAS races".into()))
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum RequestedVerb {
390 Boundary,
391 Sync,
392}
393
394pub async fn gateway_request(
403 store: &dyn ObjectStore,
404 cfg: &LeanConfig,
405 verb: RequestedVerb,
406 requestor: &str,
407) -> LeanResult<VerbRequest> {
408 let req = VerbRequest {
409 requested_unix: now_unix(),
410 requestor: requestor.chars().take(128).collect(),
411 };
412 for _ in 0..5 {
413 let loaded = load(store, cfg).await?;
414 let mut doc = loaded.doc;
415 match verb {
420 RequestedVerb::Boundary => doc.boundary_request = Some(req.clone()),
421 RequestedVerb::Sync => doc.sync_request = Some(req.clone()),
422 }
423 match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
424 Ok(_) => return Ok(req),
425 Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
426 Err(e) => return Err(e),
427 }
428 }
429 Err(LeanError::State("inbox verb request lost 5 CAS races".into()))
430}
431
432pub async fn open_window(
436 store: &dyn ObjectStore,
437 cfg: &LeanConfig,
438 epoch: u64,
439 deadline_unix: u64,
440) -> LeanResult<LoadedInbox> {
441 for _ in 0..5 {
442 let loaded = load(store, cfg).await?;
443 if let Some(w) = &loaded.doc.window {
444 let expired = now_unix() > w.deadline_unix;
445 if w.epoch > epoch {
446 return Err(LeanError::Fenced(format!(
447 "window held by higher epoch {} (ours {})",
448 w.epoch, epoch
449 )));
450 }
451 if w.epoch == epoch && !expired {
452 return Ok(loaded);
455 }
456 }
457 let mut doc = loaded.doc.clone();
458 doc.window = Some(Window { epoch, deadline_unix });
459 match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
460 Ok(etag) => {
461 return Ok(LoadedInbox { doc, etag: Some(etag) });
462 }
463 Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
464 Err(e) => return Err(e),
465 }
466 }
467 Err(LeanError::State("window open lost 5 CAS races".into()))
468}
469
470pub async fn drop_entries(
473 store: &dyn ObjectStore,
474 cfg: &LeanConfig,
475 epoch: u64,
476 consumed: &[InboxEntry],
477) -> LeanResult<()> {
478 for _ in 0..5 {
479 let loaded = load(store, cfg).await?;
480 let mut doc = loaded.doc.clone();
481 let before = doc.entries.len();
482 doc.entries.retain(|e| !consumed.contains(e));
483 if doc.entries.len() == before {
484 return Ok(());
485 }
486 match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
487 Ok(_) => return Ok(()),
488 Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
489 Err(e) => return Err(e),
490 }
491 }
492 Err(LeanError::State("inbox drop lost 5 CAS races".into()))
493}
494
495pub async fn clear_window(
500 store: &dyn ObjectStore,
501 cfg: &LeanConfig,
502 epoch: u64,
503 queued: &[InboxEntry],
504) -> LeanResult<()> {
505 clear_window_settling(store, cfg, epoch, queued, &[], &[]).await
506}
507
508pub async fn clear_window_settling(
524 store: &dyn ObjectStore,
525 cfg: &LeanConfig,
526 epoch: u64,
527 queued: &[InboxEntry],
528 consumed: &[InboxEntry],
529 applied_removals: &[Removal],
530) -> LeanResult<()> {
531 for _ in 0..5 {
532 let loaded = load(store, cfg).await?;
533 let mut doc = loaded.doc.clone();
534 if let Some(w) = &doc.window {
535 if w.epoch > epoch {
536 return Err(LeanError::Fenced(format!(
537 "window rotated to higher epoch {} (ours {})",
538 w.epoch, epoch
539 )));
540 }
541 }
542 doc.window = None;
543 doc.entries.retain(|e| !consumed.contains(e));
544 for q in queued {
545 if !doc.entries.iter().any(|e| e.path == q.path && e.etag == q.etag) {
546 doc.entries.push(q.clone());
547 }
548 }
549 doc.removals.retain(|r| {
550 !applied_removals
551 .iter()
552 .any(|a| a.path == r.path && a.requested_unix == r.requested_unix)
553 });
554 match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
555 Ok(_) => return Ok(()),
556 Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
557 Err(e) => return Err(e),
558 }
559 }
560 Err(LeanError::State("window clear lost 5 CAS races".into()))
561}