Skip to main content

flint_lean/
news.rs

1//! The news poll (latency plan 2026-10-23, lever B).
2//!
3//! The bucket used to be read only inside a barrier, and a barrier runs at
4//! the floor tick (`floor_secs`, 60 s) unless the agent publishes. So a
5//! peer's or the UI's change reached an agent's tree in up to a minute,
6//! and a gateway boundary request waited as long. While the workspace is
7//! ACTIVE, this arm reads what an idle tick's first two steps read, every
8//! `news_poll_secs`: a writer's cell (the gateway's requests ride it,
9//! §2.5), then the manifest pointer. When the pointer names a document
10//! newer than the news ticker's, the ticker moves at once (D5), and the
11//! floor's barrier runs early, at most once per `news_min_interval_secs`.
12//!
13//! What it never does:
14//! - Honour a pending publish or sync. Those stay the sentinel arm's and
15//!   the floor's, under the min-interval and the hourly budget: the early
16//!   barrier is `cadence_floor`, never `floor_tick`. A request it finds
17//!   standing is carried, as every barrier carries one (H10).
18//! - Run a barrier for this tree's own install: the ticker already names
19//!   it ("our own install is the newest news", `touch_remote_seq`).
20//! - Move the floor. Its interval stays independent of this arm (D3/D12):
21//!   resetting it on news would let a busy peer starve the floor's
22//!   deferred honours.
23//! - Poll an idle workspace. Past `news_active_secs` without work the arm
24//!   stops, and only the floor reads the bucket, as before.
25//!
26//! The model: nothing new. A poll is two reads. Folding a gateway request
27//! between barriers is the sentinel arm's `Touch`, which needs only an
28//! idle writer. The early barrier is a barrier, and the model does not
29//! time barriers.
30
31use std::time::{Duration, Instant};
32
33use super::sentinel::FloorOutcome;
34use super::{manifest, LeanConfig, LeanResult, Syncer};
35
36/// The arm's schedule: whether the workspace counts as active, whether
37/// news is waiting for a barrier, and when the last early barrier ran.
38/// Pure: every question takes `now`.
39#[derive(Debug, Clone)]
40pub struct NewsClock {
41    poll: Duration,
42    active_for: Duration,
43    min_interval: Duration,
44    active_until: Instant,
45    pending: bool,
46    last_early: Option<Instant>,
47}
48
49impl NewsClock {
50    /// A syncer starts active: an agent starts work at checkout.
51    pub fn new(cfg: &LeanConfig, now: Instant) -> NewsClock {
52        let active_for = Duration::from_secs(cfg.news_active_secs);
53        NewsClock {
54            poll: Duration::from_secs(cfg.news_poll_secs),
55            active_for,
56            min_interval: Duration::from_secs(cfg.news_min_interval_secs),
57            active_until: now + active_for,
58            pending: false,
59            last_early: None,
60        }
61    }
62
63    pub fn enabled(&self) -> bool {
64        !self.poll.is_zero()
65    }
66
67    /// The arm's interval (a disabled arm's is never polled).
68    pub fn every(&self) -> Duration {
69        self.poll.max(Duration::from_secs(1))
70    }
71
72    pub fn active(&self, now: Instant) -> bool {
73        self.enabled() && now < self.active_until
74    }
75
76    /// A sign of work: the active window runs from now.
77    pub fn touch(&mut self, now: Instant) {
78        self.active_until = self.active_until.max(now + self.active_for);
79    }
80
81    /// The pointer moved past what the tree knows.
82    pub fn saw_news(&mut self, now: Instant) {
83        self.pending = true;
84        self.touch(now);
85    }
86
87    /// A barrier ran (any arm's, after the news): its consume read the
88    /// pointer the news named, or a later one.
89    pub fn taken(&mut self) {
90        self.pending = false;
91    }
92
93    pub fn pending(&self) -> bool {
94        self.pending
95    }
96
97    /// An early barrier started now (it counts against the interval
98    /// whether or not it succeeds).
99    pub fn ran_early(&mut self, now: Instant) {
100        self.last_early = Some(now);
101    }
102
103    /// News is waiting and the last early barrier is at least the
104    /// minimum interval ago.
105    pub fn due(&self, now: Instant) -> bool {
106        self.pending
107            && self.last_early.map(|t| now.saturating_duration_since(t) >= self.min_interval).unwrap_or(true)
108    }
109}
110
111/// Did a tick do anything a workspace counts as work?
112pub fn worked(o: &FloorOutcome) -> bool {
113    !o.no_change || !o.acks.is_empty() || o.uploaded > 0 || o.deleted > 0 || o.consumed > 0
114}
115
116impl Syncer {
117    /// One poll: what an idle tick's first two steps read, nothing else.
118    /// A writer reads the cell first, so a gateway boundary request becomes
119    /// the pending publish the sentinel arm honours within its next second
120    /// (§2.5's door, a second instead of a floor). Then the pointer:
121    /// `Some(seq)` when it names a document newer than the ticker's
122    /// `observed_seq`, which moves to it now, so the agent sees the news
123    /// before a barrier takes it. `integrated_seq` stays where it was.
124    pub async fn news_poll(&mut self) -> LeanResult<Option<u64>> {
125        if !self.cfg.access.is_read() {
126            self.read_cell_requests().await?;
127        }
128        // A workspace from before the pointer layout has no seq to compare:
129        // its news waits for the floor.
130        let Some(p) = manifest::load_pointer(self.store.as_ref(), &self.cfg).await? else {
131            return Ok(None);
132        };
133        let t = self.load_remote_seq();
134        if p.pointer.seq <= t.observed_seq {
135            return Ok(None);
136        }
137        self.touch_remote_seq(Some(p.pointer.seq), Some(p.etag), t.integrated_seq)?;
138        Ok(Some(p.pointer.seq))
139    }
140
141    /// Once, before the arm's first poll: the ticker names the document the
142    /// tree holds. A checkout writes no ticker, so a fresh tree's first poll
143    /// would otherwise read its own checkout as news and run a barrier.
144    pub fn news_start(&self) -> LeanResult<()> {
145        self.ticker_from(None, None)
146    }
147
148    /// The early barrier: the floor's own, without the floor's honours. A
149    /// writer's cadence barrier, a reader's pull.
150    pub async fn news_tick(&mut self) -> LeanResult<FloorOutcome> {
151        let out = FloorOutcome::default();
152        if self.cfg.access.is_read() {
153            self.reader_floor(out).await
154        } else {
155            self.cadence_floor(out).await
156        }
157    }
158
159    /// The loop's news arm, one tick: nothing while the workspace is idle;
160    /// else a poll, and the early barrier when news is due. A failed
161    /// barrier keeps the news pending and still counts against the
162    /// interval, so a failing store is retried at that pace, not every
163    /// second.
164    pub async fn news_arm(&mut self, clock: &mut NewsClock, now: Instant) -> LeanResult<Option<FloorOutcome>> {
165        if !clock.active(now) {
166            return Ok(None);
167        }
168        if self.news_poll().await?.is_some() {
169            clock.saw_news(now);
170        }
171        if !clock.due(now) {
172            return Ok(None);
173        }
174        clock.ran_early(now);
175        let out = self.news_tick().await?;
176        clock.taken();
177        Ok(Some(out))
178    }
179}