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/// A DECLARED removal (delete/rename design §3): a delete asked for from
59/// OUTSIDE the pod — a UI, a backend embedding the gateway crate. The
60/// caller cannot touch the tree and must never delete the object
61/// itself (§9: a cited object deleted from outside wedges every
62/// checkout), so it records INTENT here and the syncer performs it at
63/// its next barrier: unlink, then cite out, then GC — one manifest
64/// generation, and for a rename the same generation that cites the
65/// destination.
66///
67/// A FIELD of the cell and not a tombstone `InboxEntry`, for the reason
68/// `boundary_request` gives above: `consume_inbox` HEADs every entry's
69/// object and is not taught a second shape.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71pub struct Removal {
72 pub path: String,
73 pub author: String,
74 pub requested_unix: u64,
75 /// For a rename: where the bytes went. Audit trail and conflict
76 /// message; never load-bearing for correctness.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub moved_to: Option<String>,
79 /// Filled in by the syncer when it REFUSED to perform this removal
80 /// — the path had unpublished local edits, was not a file, or
81 /// could not be contained. A refused removal is never retried: it
82 /// stays here, so the human who asked can read why from any
83 /// replica, until a newer removal of the path supersedes it or the
84 /// caller withdraws it. Bounded by `REFUSED_REMOVALS_CAP`.
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub refused: Option<Refusal>,
87}
88
89/// Why the syncer did not perform a removal.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct Refusal {
92 /// `removal-refused-dirty` | `removal-refused-not-a-file` |
93 /// `removal-refused-containment`.
94 pub kind: String,
95 pub message: String,
96 pub at_unix: u64,
97}
98
99/// Refused removals kept per cell before the oldest is evicted.
100pub 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 /// "Please publish" from outside the pod (§2.5). Deliberately a
107 /// FIELD and not a fake no-object `InboxEntry`: `consume_inbox`
108 /// HEADs `file_key(path)` for every entry, so an entry naming no
109 /// object lands in the NotFound arm as a spurious
110 /// `consume-object-missing` conflict — and special-casing the
111 /// single most safety-critical function in the crate to avoid that
112 /// is worse than either.
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub boundary_request: Option<VerbRequest>,
115 /// "Please pull" from outside the pod — CARRIED, never performed
116 /// (D14). A boundary publishes what is already on disk and touches
117 /// no local file; `sync` re-derives the tree against the current
118 /// remote manifest and DELETES local files for remotely-deleted
119 /// paths. Performing that on a remote's say-so would upgrade what a
120 /// leaked gateway bearer can do from "publish, plus hand over these
121 /// N named objects" to "rewrite and delete across a running agent's
122 /// tree, at my timing, under a scope I choose".
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub sync_request: Option<VerbRequest>,
125 /// DECLARED removals, pending or refused (see `Removal`). At most
126 /// one per path: a newer removal of a path supersedes an older one.
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub removals: Vec<Removal>,
129}
130
131impl InboxDoc {
132 /// A removal of `path` that is recorded and not yet performed nor
133 /// refused — what a listing built over the manifest and the inbox
134 /// subtracts, so a deleted or renamed-away file leaves the UI the
135 /// moment the removal is recorded rather than when the syncer gets
136 /// to it.
137 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 /// None ⇒ the cell does not exist yet (first CAS is If-None-Match:*).
145 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
185/// Whether a gateway may admit a UI write right now. A window past its
186/// deadline does not block (the dead-syncer unwedge).
187pub fn admits_hitl(doc: &InboxDoc) -> bool {
188 match &doc.window {
189 None => true,
190 Some(w) => now_unix() > w.deadline_unix,
191 }
192}
193
194/// The GATEWAY side: land a UI write. The object PUT must already have
195/// happened (object first, inbox second — a crash between leaves an
196/// orphan object, never a tracked-but-absent entry). Refuses while a
197/// live barrier window is open. CAS-retries the append.
198pub 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 // A newer write to the same path supersedes the queued one.
212 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
223/// One removal per path: a newer one supersedes an older one, pending
224/// or refused — the older is that caller's own earlier intent, which
225/// the newer replaces (the rule `gateway_append` applies to entries).
226fn record_removal(doc: &mut InboxDoc, r: Removal) {
227 doc.removals.retain(|x| x.path != r.path);
228 doc.removals.push(r);
229}
230
231/// The GATEWAY side: record DECLARED removals (delete/rename design
232/// §3), all in ONE CAS. The caller must have verified the paths exist
233/// (cited or tracked) and must never delete the objects itself.
234///
235/// NOT window-gated, deliberately (§12): a write races the barrier that
236/// is about to publish the tree, but a removal touches no object and no
237/// path at the moment it is recorded — the same argument that left
238/// `gateway_request` ungated. A removal recorded while a window is open
239/// is simply performed by the NEXT barrier.
240pub 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
260/// The GATEWAY side of a RENAME (§6): the destination entries and the
261/// source removals land in ONE CAS, so the cell never holds half a
262/// rename. The destination objects must already have been copied
263/// (create first, removal second — §5). Window-gated like
264/// `gateway_append`, because it carries entries.
265pub 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
295/// The GATEWAY side: take back a removal of `path`, pending or refused.
296/// `Ok(false)` when there was none. Best effort against a barrier that
297/// is already performing it: the cell says whether the record was
298/// still there, the listing says what happened.
299pub 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
321/// The SYNCER side: what became of the removals a pass looked at.
322/// `applied` are dropped from the cell; `refused` are annotated in
323/// place (their `refused` field is the answer), never retried, and
324/// capped at `REFUSED_REMOVALS_CAP` with the oldest evicted. A removal
325/// that was superseded meanwhile (same path, newer `requested_unix`)
326/// is left alone — the newer intent gets its own pass.
327pub 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/// Which verb a gateway request is asking for.
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum RequestedVerb {
390 Boundary,
391 Sync,
392}
393
394/// The GATEWAY side of §2.5: set one of the two request fields under
395/// the same CAS discipline every other inbox write uses.
396///
397/// Deliberately NOT window-gated. `admits_hitl` exists because a HITL
398/// object write races the barrier that is about to publish the tree; a
399/// verb request touches no object and no path — refusing it during a
400/// window would make "please publish" fail precisely while a publish is
401/// in flight, which is the least useful moment to say no.
402pub 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 // Newest wins: the field is state, so a burst collapses instead
416 // of queueing. This is what makes a rate limit unnecessary on
417 // the transport (the HONOR is still min-interval'd and budgeted
418 // like any other sentinel).
419 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
432/// The SYNCER side: open the barrier window (the intent). Succeeds
433/// over a closed cell, an expired window, or a LOWER epoch's stale
434/// window; refuses a live window at our own or a higher epoch.
435pub 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 // Our own live window (a crashed earlier attempt inside
453 // the deadline): adopt it.
454 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
470/// Drop integrated entries (after they are durably in the baseline).
471/// Entries that arrived after the consume are preserved.
472pub 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
495/// How long an object the workspace does not track may sit at a key
496/// before a HITL write may overwrite it anyway, seconds. Past a claim
497/// deadline (150 s) and the next floor's retry, which adopts an abandoned
498/// barrier's uploads, with room to spare.
499pub const UNTRACKED_GRACE_SECS: u64 = 600;
500
501/// May a HITL write — the gateway, for a UI — overwrite the object now at
502/// `path` (etag `current`, last written `last_modified_unix`)?
503///
504/// Only a version the workspace TRACKS: the one the manifest cites, or
505/// one an inbox entry names. Any other object at the key is a writer's
506/// upload that its commit has not cited yet — uploads hold no lease, so
507/// there is no window to hold the gateway off. Overwriting one lost two
508/// writes at once (the model's `Inv_HITLDurable` in the two-writer
509/// sentinel world): the uploading writer's commit re-cites its own
510/// generation over the UI's write after another writer consumed and
511/// cited it, so the manifest names bytes that are gone and the UI's
512/// acked write is uncited, untracked and preserved nowhere. Refused, the
513/// UI retries in a moment and overwrites the version that commit cites,
514/// which every writer's consume already handles.
515///
516/// An untracked object is fair game once it has sat untracked past
517/// `UNTRACKED_GRACE_SECS` — a crashed writer's orphan upload, which no
518/// commit will cite. There used to be a second escape, "no writer has a
519/// live heartbeat"; the heartbeat is gone. Neither escape carries safety
520/// any more: the uploading writer's commit re-reads every citation it
521/// adds and withholds one whose object moved (the model's
522/// `LeanBarrierLeaseHitlOverAnyVerified`), so this rule is defense in
523/// depth and the grace only bounds how long an orphan answers 409.
524pub async fn hitl_may_overwrite(
525 store: &dyn ObjectStore,
526 cfg: &LeanConfig,
527 path: &str,
528 current: &str,
529 last_modified_unix: Option<u64>,
530 now: u64,
531) -> LeanResult<bool> {
532 let same = |a: &str, b: &str| a.trim_matches('"') == b.trim_matches('"');
533 if let Some(m) = super::manifest::load(store, cfg).await? {
534 if m.manifest.entries.get(path).map(|e| same(&e.etag, current)).unwrap_or(false) {
535 return Ok(true);
536 }
537 }
538 let ib = load(store, cfg).await?;
539 if ib.doc.entries.iter().any(|e| e.path == path && same(&e.etag, current)) {
540 return Ok(true);
541 }
542 Ok(last_modified_unix.map(|t| now.saturating_sub(t) > UNTRACKED_GRACE_SECS).unwrap_or(false))
543}
544
545/// Clear the window (after the manifest CAS) and, in the same CAS,
546/// queue `queued` entries (the merge-preserved foreign entries handed
547/// to the next consume). Entries that arrived mid-barrier are
548/// preserved.
549pub async fn clear_window(
550 store: &dyn ObjectStore,
551 cfg: &LeanConfig,
552 epoch: u64,
553 queued: &[InboxEntry],
554) -> LeanResult<()> {
555 clear_window_settling(store, cfg, epoch, queued, &[], &[]).await
556}
557
558/// `clear_window`, and in the SAME CAS drop what this barrier
559/// integrated: the entries it consumed and the DECLARED removals it
560/// performed. Both stay in the cell until here — after the manifest
561/// CAS — for two reasons that are one. A consumed entry's only other
562/// record is the pod's emptyDir baseline, which a pod REPLACEMENT
563/// takes with it; dropped at the window-open commitment, a HITL write
564/// acked and consumed but not yet cited had nothing in the bucket
565/// tracking it through the whole upload phase, and a successor's
566/// checkout was blind to it. And a listing that subtracts pending
567/// removals must keep hiding the path for exactly as long as the
568/// manifest cites it, not reappear it for the length of the uploads.
569/// A crash before this CAS leaves both in the cell; the next
570/// incarnation re-consumes and re-declares, idempotently (the consume
571/// skips an entry its baseline already holds, and a declared path
572/// already absent re-declares as it stands).
573pub async fn clear_window_settling(
574 store: &dyn ObjectStore,
575 cfg: &LeanConfig,
576 epoch: u64,
577 queued: &[InboxEntry],
578 consumed: &[InboxEntry],
579 applied_removals: &[Removal],
580) -> LeanResult<()> {
581 for _ in 0..5 {
582 let loaded = load(store, cfg).await?;
583 let mut doc = loaded.doc.clone();
584 if let Some(w) = &doc.window {
585 if w.epoch > epoch {
586 return Err(LeanError::Fenced(format!(
587 "window rotated to higher epoch {} (ours {})",
588 w.epoch, epoch
589 )));
590 }
591 }
592 doc.window = None;
593 doc.entries.retain(|e| !consumed.contains(e));
594 for q in queued {
595 if !doc.entries.iter().any(|e| e.path == q.path && e.etag == q.etag) {
596 doc.entries.push(q.clone());
597 }
598 }
599 doc.removals.retain(|r| {
600 !applied_removals
601 .iter()
602 .any(|a| a.path == r.path && a.requested_unix == r.requested_unix)
603 });
604 match cas_write(store, cfg, &doc, loaded.etag.as_deref(), epoch).await {
605 Ok(_) => return Ok(()),
606 Err(LeanError::Store(StoreError::PreconditionFailed(_))) => continue,
607 Err(e) => return Err(e),
608 }
609 }
610 Err(LeanError::State("window clear lost 5 CAS races".into()))
611}