use serde::{Deserialize, Serialize};
use flint_store::{
crc64_nvme, GenerationStamps, ObjectStore, PutCondition, StoreError,
};
use super::{now_unix, LeanConfig, LeanError, LeanResult};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InboxEntry {
pub path: String,
pub etag: String,
pub author: String,
pub added_unix: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crc64_b64: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Window {
pub epoch: u64,
pub deadline_unix: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VerbRequest {
pub requested_unix: u64,
pub requestor: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Removal {
pub path: String,
pub author: String,
pub requested_unix: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub moved_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refused: Option<Refusal>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Refusal {
pub kind: String,
pub message: String,
pub at_unix: u64,
}
pub const REFUSED_REMOVALS_CAP: usize = 100;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InboxDoc {
pub entries: Vec<InboxEntry>,
pub window: Option<Window>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub boundary_request: Option<VerbRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sync_request: Option<VerbRequest>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub removals: Vec<Removal>,
}
impl InboxDoc {
pub fn pending_removal(&self, path: &str) -> Option<&Removal> {
self.removals.iter().find(|r| r.path == path && r.refused.is_none())
}
}
pub struct LoadedInbox {
pub doc: InboxDoc,
pub etag: Option<String>,
}
pub async fn load(store: &dyn ObjectStore, cfg: &LeanConfig) -> LeanResult<LoadedInbox> {
match store.get_whole(&cfg.inbox_key(), None).await {
Ok((meta, bytes)) => {
let doc = serde_json::from_slice(&bytes)
.map_err(|e| LeanError::State(format!("inbox parse: {e}")))?;
Ok(LoadedInbox { doc, etag: Some(meta.etag) })
}
Err(StoreError::NotFound(_)) => Ok(LoadedInbox { doc: InboxDoc::default(), etag: None }),
Err(e) => Err(e.into()),
}
}
pub async fn cas_write(
store: &dyn ObjectStore,
cfg: &LeanConfig,
doc: &InboxDoc,
expected: Option<&str>,
epoch: u64,
) -> LeanResult<String> {
let bytes = serde_json::to_vec_pretty(doc)
.map_err(|e| LeanError::State(format!("inbox: {e}")))?;
let crc = crc64_nvme(&bytes);
let cond = match expected {
Some(etag) => PutCondition::IfMatch(etag.to_string()),
None => PutCondition::IfNoneMatchAny,
};
let stamps = GenerationStamps {
generation: 0,
epoch,
flush_uuid: "inbox".into(),
boundary_source: None,
posix: None,
};
let meta = store.put_whole(&cfg.inbox_key(), bytes.into(), &cond, &stamps, crc).await?;
Ok(meta.etag)
}
pub fn admits_hitl(doc: &InboxDoc) -> bool {
match &doc.window {
None => true,
Some(w) => now_unix() > w.deadline_unix,
}
}
pub async fn gateway_append(
store: &dyn ObjectStore,
cfg: &LeanConfig,
entry: InboxEntry,
) -> LeanResult<()> {
for _ in 0..5 {
let loaded = load(store, cfg).await?;
if !admits_hitl(&loaded.doc) {
return Err(LeanError::State(
"barrier window open — retry after the window deadline".into(),
));
}
let mut doc = loaded.doc;
doc.entries.retain(|e| e.path != entry.path);
doc.entries.push(entry.clone());
match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
Ok(_) => return Ok(()),
Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
Err(e) => return Err(e),
}
}
Err(LeanError::State("inbox append lost 5 CAS races".into()))
}
fn record_removal(doc: &mut InboxDoc, r: Removal) {
doc.removals.retain(|x| x.path != r.path);
doc.removals.push(r);
}
pub async fn gateway_remove(
store: &dyn ObjectStore,
cfg: &LeanConfig,
removals: Vec<Removal>,
) -> LeanResult<()> {
for _ in 0..5 {
let loaded = load(store, cfg).await?;
let mut doc = loaded.doc;
for r in &removals {
record_removal(&mut doc, r.clone());
}
match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
Ok(_) => return Ok(()),
Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
Err(e) => return Err(e),
}
}
Err(LeanError::State("inbox removal lost 5 CAS races".into()))
}
pub async fn gateway_rename(
store: &dyn ObjectStore,
cfg: &LeanConfig,
entries: Vec<InboxEntry>,
removals: Vec<Removal>,
) -> LeanResult<()> {
for _ in 0..5 {
let loaded = load(store, cfg).await?;
if !admits_hitl(&loaded.doc) {
return Err(LeanError::State(
"barrier window open — retry after the window deadline".into(),
));
}
let mut doc = loaded.doc;
for e in &entries {
doc.entries.retain(|x| x.path != e.path);
doc.entries.push(e.clone());
}
for r in &removals {
record_removal(&mut doc, r.clone());
}
match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
Ok(_) => return Ok(()),
Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
Err(e) => return Err(e),
}
}
Err(LeanError::State("inbox rename lost 5 CAS races".into()))
}
pub async fn withdraw_removal(
store: &dyn ObjectStore,
cfg: &LeanConfig,
path: &str,
) -> LeanResult<bool> {
for _ in 0..5 {
let loaded = load(store, cfg).await?;
let mut doc = loaded.doc;
let before = doc.removals.len();
doc.removals.retain(|r| r.path != path);
if doc.removals.len() == before {
return Ok(false);
}
match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
Ok(_) => return Ok(true),
Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
Err(e) => return Err(e),
}
}
Err(LeanError::State("inbox withdraw lost 5 CAS races".into()))
}
pub async fn settle_removals(
store: &dyn ObjectStore,
cfg: &LeanConfig,
epoch: u64,
applied: &[Removal],
refused: &[Removal],
) -> LeanResult<()> {
if applied.is_empty() && refused.is_empty() {
return Ok(());
}
let same = |a: &Removal, b: &Removal| a.path == b.path && a.requested_unix == b.requested_unix;
for _ in 0..5 {
let loaded = load(store, cfg).await?;
let mut doc = loaded.doc.clone();
let mut changed = false;
doc.removals.retain(|r| {
let drop = applied.iter().any(|a| same(a, r));
changed |= drop;
!drop
});
for r in doc.removals.iter_mut() {
if r.refused.is_none() {
if let Some(f) = refused.iter().find(|f| same(f, r)) {
r.refused = f.refused.clone();
changed = true;
}
}
}
loop {
let refused_now = doc.removals.iter().filter(|r| r.refused.is_some()).count();
if refused_now <= REFUSED_REMOVALS_CAP {
break;
}
let oldest = doc
.removals
.iter()
.enumerate()
.filter(|(_, r)| r.refused.is_some())
.min_by_key(|(_, r)| r.refused.as_ref().map(|f| f.at_unix).unwrap_or(0))
.map(|(i, _)| i);
match oldest {
Some(i) => {
doc.removals.remove(i);
changed = true;
}
None => break,
}
}
if !changed {
return Ok(());
}
match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
Ok(_) => return Ok(()),
Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
Err(e) => return Err(e),
}
}
Err(LeanError::State("inbox removal settle lost 5 CAS races".into()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestedVerb {
Boundary,
Sync,
}
pub async fn gateway_request(
store: &dyn ObjectStore,
cfg: &LeanConfig,
verb: RequestedVerb,
requestor: &str,
) -> LeanResult<VerbRequest> {
let req = VerbRequest {
requested_unix: now_unix(),
requestor: requestor.chars().take(128).collect(),
};
for _ in 0..5 {
let loaded = load(store, cfg).await?;
let mut doc = loaded.doc;
match verb {
RequestedVerb::Boundary => doc.boundary_request = Some(req.clone()),
RequestedVerb::Sync => doc.sync_request = Some(req.clone()),
}
match cas_write(store, cfg, &doc, loaded.etag.as_deref(), 0).await {
Ok(_) => return Ok(req),
Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
Err(e) => return Err(e),
}
}
Err(LeanError::State("inbox verb request lost 5 CAS races".into()))
}
pub async fn open_window(
store: &dyn ObjectStore,
cfg: &LeanConfig,
epoch: u64,
deadline_unix: u64,
) -> LeanResult<LoadedInbox> {
for _ in 0..5 {
let loaded = load(store, cfg).await?;
if let Some(w) = &loaded.doc.window {
let expired = now_unix() > w.deadline_unix;
if w.epoch > epoch {
return Err(LeanError::Fenced(format!(
"window held by higher epoch {} (ours {})",
w.epoch, epoch
)));
}
if w.epoch == epoch && !expired {
return Ok(loaded);
}
}
let mut doc = loaded.doc.clone();
doc.window = Some(Window { epoch, deadline_unix });
match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
Ok(etag) => {
return Ok(LoadedInbox { doc, etag: Some(etag) });
}
Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
Err(e) => return Err(e),
}
}
Err(LeanError::State("window open lost 5 CAS races".into()))
}
pub async fn drop_entries(
store: &dyn ObjectStore,
cfg: &LeanConfig,
epoch: u64,
consumed: &[InboxEntry],
) -> LeanResult<()> {
for _ in 0..5 {
let loaded = load(store, cfg).await?;
let mut doc = loaded.doc.clone();
let before = doc.entries.len();
doc.entries.retain(|e| !consumed.contains(e));
if doc.entries.len() == before {
return Ok(());
}
match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
Ok(_) => return Ok(()),
Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
Err(e) => return Err(e),
}
}
Err(LeanError::State("inbox drop lost 5 CAS races".into()))
}
pub const UNTRACKED_GRACE_SECS: u64 = 600;
pub async fn hitl_may_overwrite(
store: &dyn ObjectStore,
cfg: &LeanConfig,
path: &str,
current: &str,
last_modified_unix: Option<u64>,
now: u64,
) -> LeanResult<bool> {
let same = |a: &str, b: &str| a.trim_matches('"') == b.trim_matches('"');
if let Some(m) = super::manifest::load(store, cfg).await? {
if m.manifest.entries.get(path).map(|e| same(&e.etag, current)).unwrap_or(false) {
return Ok(true);
}
}
let ib = load(store, cfg).await?;
if ib.doc.entries.iter().any(|e| e.path == path && same(&e.etag, current)) {
return Ok(true);
}
Ok(last_modified_unix.map(|t| now.saturating_sub(t) > UNTRACKED_GRACE_SECS).unwrap_or(false))
}
pub async fn clear_window(
store: &dyn ObjectStore,
cfg: &LeanConfig,
epoch: u64,
queued: &[InboxEntry],
) -> LeanResult<()> {
clear_window_settling(store, cfg, epoch, queued, &[], &[]).await
}
pub async fn clear_window_settling(
store: &dyn ObjectStore,
cfg: &LeanConfig,
epoch: u64,
queued: &[InboxEntry],
consumed: &[InboxEntry],
applied_removals: &[Removal],
) -> LeanResult<()> {
for _ in 0..5 {
let loaded = load(store, cfg).await?;
let mut doc = loaded.doc.clone();
if let Some(w) = &doc.window {
if w.epoch > epoch {
return Err(LeanError::Fenced(format!(
"window rotated to higher epoch {} (ours {})",
w.epoch, epoch
)));
}
}
doc.window = None;
doc.entries.retain(|e| !consumed.contains(e));
for q in queued {
if !doc.entries.iter().any(|e| e.path == q.path && e.etag == q.etag) {
doc.entries.push(q.clone());
}
}
doc.removals.retain(|r| {
!applied_removals
.iter()
.any(|a| a.path == r.path && a.requested_unix == r.requested_unix)
});
match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
Ok(_) => return Ok(()),
Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
Err(e) => return Err(e),
}
}
Err(LeanError::State("window clear lost 5 CAS races".into()))
}