Skip to main content

onlyne_client/session/dispatch/
state.rs

1use super::*;
2
3use super::outbound::Outbox;
4use super::projection::{projection_of, stored_task_state};
5use super::transport::names_session;
6
7#[derive(Clone)]
8pub struct DispatchState {
9    pub(super) inner: Arc<Mutex<DispatchInner>>,
10}
11pub(super) struct DispatchInner {
12    pub(super) role: String,
13    pub(super) workspace: PathBuf,
14    pub(super) command: Vec<String>,
15    pub(super) max_sessions: u32,
16    /// Downstream roles a session of this role owes a handoff to, from the
17    /// server's spec slice (`relay_required`). Empty is the default and means
18    /// the guard is off.
19    pub(super) relay_required: Vec<String>,
20    /// The count form of the same policy (`relay_count`).
21    pub(super) relay_count: Option<u32>,
22    pub(super) backend: Arc<dyn SessionBackend>,
23    pub(super) store: ClientStore,
24    pub(super) bridge: Bridge,
25    pub(super) sessions: HashMap<String, SessionSlot>,
26    /// Live link, installed by the runloop while the connection is up.
27    pub(super) outbox: Option<Arc<dyn Outbox>>,
28    /// Flag the runloop and the dispatcher share while the link is down.
29    pub(super) accept_new: Arc<AtomicBool>,
30    /// Whether the role holds a ready server link. The runloop owns it, and the
31    /// adapter socket reports it to the `status` verb.
32    pub(super) link_up: Arc<AtomicBool>,
33    /// Aggregate name this role supervises, empty for a plain role.
34    pub(super) cluster_ref: String,
35    /// The server's topology name, read from `welcome.cluster` (the server's own
36    /// `spec.toml [server] name`). A host backend uses it as the address of the
37    /// tree it puts sessions into: herdr keeps one workspace per server root,
38    /// labelled after this name. Empty until the first welcome arrives.
39    pub(super) topology: String,
40    /// The adapter connection serving each session of this role, keyed by the
41    /// session id the plugin mounted with (`ONLYNE_SESSION_ID`). A plugin the
42    /// client spawned names the one session it was spawned for, so a task
43    /// never rides the connection of an earlier one.
44    pub(super) transports: HashMap<String, (AdapterIo, Vec<Capability>)>,
45    /// A plugin that mounted naming no session: an always-running agent
46    /// waiting for this role's next assignment (plan §6 line 285).
47    pub(super) parked: Option<(AdapterIo, Vec<Capability>)>,
48    /// Zero-activity clock for running tasks. Applied persists refresh it.
49    pub(super) stall: crate::session::stall::StallWatch,
50    /// A plugin connection that mounted a session a live connection already
51    /// serves: the agent that dropped came back after a newer session took the
52    /// task. It is served nothing, and what it sends is held rather than sent,
53    /// keyed by the name it mounted with. The capabilities that mount came with
54    /// are kept beside it, because a session whose live connection goes away
55    /// hands itself to the first connection that was holding for it.
56    pub(super) revived: Vec<(String, AdapterIo, Vec<Capability>)>,
57    /// What those held connections sent, keyed by the task whose completion
58    /// carries it. `on_out` drains the key before it routes, so the recipient
59    /// reads one relay per downstream role.
60    pub(super) held_handoffs: HashMap<String, Vec<Handoff>>,
61    /// Tasks whose ending this client asked for with a `control` command.
62    ///
63    /// `recycle` and `cancel` reach the agent as a `notify`, so the completion
64    /// that answers the command races the retirement the same command runs, and
65    /// the row's phase at the moment the frame lands is that race's answer. The
66    /// note is written before the frame leaves, so both orders of the race read
67    /// one authority: the operator asked for this ending, and the settle door
68    /// (`settle.rs`) takes the note as its `SettleAuthority::ControlDriven`.
69    /// `on_out` consumes it, and the reconnect sweep drops the note of every task
70    /// it settles — the other way a command's answer stops coming.
71    ///
72    /// A note nothing answers is the watchdog's, and it is the same note: the
73    /// record of the word, held to [`CONTROL_SETTLE_BOUND`] and settled by the
74    /// tick's own sweep when no report has come to settle it instead.
75    pub(super) control_settles: Vec<ControlNote>,
76    /// Connections inside one of their own inbound frames right now.
77    ///
78    /// A frame handler runs to completion before `adapter_socket` answers the
79    /// frame, so a host frame written on the same connection during that handler
80    /// leaves first. The bye sweep in `retire_revived` skips these connections.
81    pub(super) in_frame: Vec<AdapterIo>,
82}
83
84#[derive(Clone)]
85pub struct SessionSlot {
86    pub(super) session: SessionRef,
87    pub(super) task_id: Option<String>,
88    pub(super) ready: bool,
89    /// Payload held until the adapter reports ready, which keeps the ready
90    /// barrier of §6 ahead of the `assign` frame.
91    pub(super) payload: Option<Envelope>,
92    /// Delivery handle, owed back to the server as one `ack`.
93    pub(super) msg_id: Option<String>,
94    /// Sender of the payload this session serves, kept for its `Completion`.
95    pub(super) origin: Option<Principal>,
96    /// The causality of the task this slot serves, read off the envelope that
97    /// arrived with it. A handoff the session reports afterwards is its child,
98    /// which is what `onlyne handoff` computes too. The whole link is kept here
99    /// so the family id and the family's figures travel with the child, the
100    /// depth included.
101    pub(super) causality: Causality,
102    /// When the connection that would have sent this session's next heartbeat
103    /// last left it: at birth for a session a plugin still has to mount, and
104    /// again each time a connection ends without a `detach` frame or says
105    /// goodbye while the session still owes a task. `None` while a connection is
106    /// attached, which is what clears it, and for a self-driven session, which
107    /// answers no adapter socket at all. The reconnect grace of `[client]
108    /// reconnect_grace_secs` reads it: an agent that comes back inside the window
109    /// clears it and keeps its session.
110    pub(super) dropped_at: Option<Instant>,
111    /// When a frame this session sent was last accepted.
112    ///
113    /// The other half of the same death window, and the half a socket cannot
114    /// answer for: a plugin whose event loop is blocked keeps its connection and
115    /// stops beating, so `dropped_at` stays `None` and no socket ever ends. Set
116    /// when the session is staged and refreshed wherever a frame of its own is
117    /// accepted, so it reads as the moment the agent last proved it was alive.
118    /// The reconnect sweep compares it against the protocol's heartbeat cadence.
119    pub(super) last_beat: Option<Instant>,
120    /// Whether this session's task has been taken by a newer session, leaving
121    /// this slot served only by a connection that came back for it. A read-only
122    /// slot is handed no assignment and no note, and what its agent sends is
123    /// held for the completion that merges it.
124    pub(super) read_only: bool,
125}
126
127/// One operator's word this client is still waiting to see answered.
128///
129/// The command is a `notify` the plugin may or may not live to answer, so the
130/// note is what this client knows on its own: which task the word named, when it
131/// was given, and what the operator said. It is not a second settle path — the
132/// note is the authority of the one settle door, which `settle.rs` reads as
133/// `SettleAuthority::ControlDriven` — and it is the record the watchdog holds a
134/// word to once no report arrives at all.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct ControlNote {
137    /// The task the word named.
138    pub task_id: String,
139    /// When the word was given. The watchdog's bound runs from here.
140    pub noted_at: Instant,
141    /// What the operator said.
142    pub word: ControlWord,
143}
144
145/// The operator's word one note is the record of.
146///
147/// `cancel` and `recycle` are the two commands that reach a live task, and each
148/// word carries both halves of what a note is for: the ending it gives the work,
149/// and the string a refusal of that task's delivery row is written with. The note
150/// holds the word rather than the verdict it stands for, because the verdict does
151/// not name the word back — `failed` is only the fallback a `recycle` leaves when
152/// nothing answers it — and the column the refusal lands in has to read what the
153/// operator actually said.
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub enum ControlWord {
156    /// `cancel`: the work ends now, and the task reads `cancelled`.
157    Cancel,
158    /// `recycle`: the plugin is asked for its own ending. It prescribes no
159    /// outcome, so a word nothing answers leaves the task `failed`.
160    Recycle,
161}
162
163impl ControlWord {
164    /// The outcome this word stands for: the verdict a settle answering the note
165    /// files when the plugin reports none of its own.
166    pub fn outcome(self) -> Outcome {
167        match self {
168            Self::Cancel => Outcome::Cancelled,
169            Self::Recycle => Outcome::Failed,
170        }
171    }
172
173    /// The word as the refusal that names it reads on the wire.
174    ///
175    /// `operator cancel` and `operator recycle` stand in the same column as the
176    /// `operator close` and `operator ack` an operator's other verbs already wrote
177    /// there, and each names the command that was given rather than the verdict it
178    /// left behind.
179    pub fn refusal(self) -> &'static str {
180        match self {
181            Self::Cancel => "operator cancel",
182            Self::Recycle => "operator recycle",
183        }
184    }
185}
186
187/// How long this client holds an operator's word open before settling it.
188///
189/// The words this bounds are `cancel` and `recycle`, and each one is a `notify`
190/// the plugin answers with a frame on the connection it already serves: a plugin
191/// that ends its turn to answer has answered inside one
192/// [`HEARTBEAT_INTERVAL`](crate::session::dispatch::HEARTBEAT_INTERVAL), and the
193/// request round trip the adapter bounds itself with
194/// ([`REQUEST_TIMEOUT`](crate::session::dispatch::REQUEST_TIMEOUT)) is well past
195/// that. Three intervals leaves a plugin that is stalled but still alive two
196/// missed beats before this client decides the word went unanswered — it is the
197/// window the reconnect sweep reads an agent's silence through, so the two
198/// readings agree — and it is half the sixty-second default of `[client]
199/// reconnect_grace_secs`. An operator watching a stuck row gave up on the live
200/// run in seconds and reached for `onlyne repair fail`; a minute would lose to
201/// that, and this does not.
202///
203/// A constant rather than a config key on purpose: what it bounds is not a policy
204/// an operator tunes, it is the point past which this client's own record of the
205/// word outlives the plugin that was asked to answer it.
206pub const CONTROL_SETTLE_BOUND: Duration = Duration::from_secs(HEARTBEAT_INTERVAL.as_secs() * 3);
207
208/// The notes whose operator's word has gone unanswered past
209/// [`CONTROL_SETTLE_BOUND`].
210///
211/// The reading consumes nothing. The caller settles each note through
212/// [`take_controlled_settle`](DispatchState::take_controlled_settle), the one
213/// door that spends a note, so a completion that answers a word between this read
214/// and that call takes the note first and the task needs no verdict from the
215/// sweep. A note stamped ahead of `now` is not due: an elapsed window is the only
216/// reading this makes.
217pub(super) fn due_control_settles(inner: &DispatchInner, now: Instant) -> Vec<ControlNote> {
218    inner
219        .control_settles
220        .iter()
221        .filter(|note| now.saturating_duration_since(note.noted_at) >= CONTROL_SETTLE_BOUND)
222        .cloned()
223        .collect()
224}
225
226/// Stamp the moment one session last had a frame of its own accepted.
227///
228/// The stamp is the liveness half of the reconnect sweep: a socket that is still
229/// up and still attached proves the connection survived, not that the agent
230/// behind it did. A plugin whose event loop is blocked keeps its socket and
231/// stops beating, and nothing but this stamp says so.
232///
233/// Called where a frame is accepted rather than where one arrives — the mount
234/// that binds the connection, the ready barrier, and each beat the reducer took
235/// — because a frame the client refused moved no state and is the evidence of
236/// nothing. A frame this client refused on other grounds still buys the stamp:
237/// the silence arm asks whether an agent lives behind the slot, and it reads
238/// this stamp only for a session whose task is still bound and unsettled, so a
239/// settled or taken task is never the stamp's question and a demoted slot still
240/// retires on its own schedule. What a refused frame never buys is a write: the
241/// dimensions stay where the connection serving the session left them.
242pub(super) fn note_beat(inner: &mut DispatchInner, task_id: &str, now: Instant) {
243    let Some(key) = slot_key_named(inner, task_id) else {
244        return;
245    };
246    if let Some(slot) = inner.sessions.get_mut(&key) {
247        slot.last_beat = Some(now);
248    }
249}
250
251pub(super) fn has_attached_transport(inner: &DispatchInner, key: &str, slot: &SessionSlot) -> bool {
252    inner
253        .transports
254        .keys()
255        .any(|session_id| names_session(key, slot, session_id))
256}
257
258/// Move one session's row onto the generation after the one it holds.
259///
260/// Two shapes leave a row behind a generation that is gone, and both close the
261/// gap the same way. A plugin that comes back to a session this client still
262/// serves reports from a new process, so its sequence starts at its base again.
263/// A session this client stages onto a task that already carries a row is born
264/// onto the record the session that last served the task left, watermark and
265/// all. The watermark is the counter this client's own feeds and the plugin's
266/// beats share — `upsert_session` writes the event's version into it, and
267/// `next_version` allocates the stored one plus one — so an inherited one drops
268/// every frame of the new reporter as a stale duplicate, and no dimension ever
269/// moves on that row again. A same-generation rebase is not available: the
270/// reducer's no-op detection compares the version-free tuple, so an event that
271/// moved only the watermark would be ignored and the row would keep the old one.
272/// The generation is what moves, and the sequence starts again under it.
273///
274/// The caller composes `body`, the content of the new generation, and the caller
275/// attests the old one dead: `Supersede` is the reducer's vocabulary for that
276/// pair, and each caller is the authority on the fact it asserts. Answers `None`
277/// when the task carries no row yet — a first dispatch, which has nothing to
278/// rebase because `feed_created` seeds the row instead.
279pub(super) fn rebase_generation(
280    inner: &DispatchInner,
281    task_id: &str,
282    body: impl FnOnce(&Observation) -> Observation,
283) -> anyhow::Result<Option<Verdict>> {
284    let Some(row) = inner.store.get_session(task_id)? else {
285        return Ok(None);
286    };
287    let stored = stored_observation(&inner.store, Some(&row));
288    let event = LifecycleEvent::Supersede {
289        v: Version::new(stored.version.generation.saturating_add(1), 0),
290        old_generation_dead: true,
291        body: body(&stored),
292    };
293    Ok(Some(apply_persist(
294        &inner.bridge,
295        &inner.store,
296        task_id,
297        &event,
298    )?))
299}
300
301/// The task one slot answers for: its current binding, or the task its session
302/// was spawned for while no task is bound.
303pub(super) fn slot_task(slot: &SessionSlot) -> String {
304    slot.task_id
305        .clone()
306        .unwrap_or_else(|| slot.session.task_id.clone())
307}
308
309/// The slot one adapter mount names, answered as its key.
310pub(super) fn slot_key_named(inner: &DispatchInner, session_id: &str) -> Option<String> {
311    inner
312        .sessions
313        .iter()
314        .find(|(key, slot)| names_session(key, slot, session_id))
315        .map(|(key, _)| key.clone())
316}
317
318/// The slot serving one task, preferring the one that still holds delivery
319/// rights.
320///
321/// Two slots answer to one task only when a session came back for a task a newer
322/// session already took, and `HashMap` order decides which one a search reaches
323/// first. Every handle that belongs to the task — its delivery `msg_id` above
324/// all — has to land on the session that is actually serving it, or the ack the
325/// retry earns would be written into a slot that can never answer and the server
326/// row would sit in flight.
327pub(super) fn slot_key_serving_task(inner: &DispatchInner, task_id: &str) -> Option<String> {
328    let mut bound = None;
329    for (key, slot) in inner.sessions.iter() {
330        if slot.task_id.as_deref() != Some(task_id) {
331            continue;
332        }
333        if !slot.read_only {
334            return Some(key.clone());
335        }
336        if bound.is_none() {
337            bound = Some(key.clone());
338        }
339    }
340    bound
341}
342
343/// One plugin connection held inside an inbound frame it is still being answered.
344///
345/// The bye sweep in `retire_revived` leaves such a connection alone, which keeps a
346/// bye behind the response to the frame. A plugin's bye handler drops the socket and
347/// rejects every request awaiting an answer, so a bye that overtakes the response
348/// turns work the ledger already holds into a failure the agent reports again.
349#[must_use = "the connection stops being held as soon as the guard is dropped"]
350pub struct FrameGuard<'a> {
351    pub(super) state: &'a DispatchState,
352    pub(super) io: AdapterIo,
353}
354
355impl Drop for FrameGuard<'_> {
356    fn drop(&mut self) {
357        self.state
358            .inner
359            .lock()
360            .in_frame
361            .retain(|held| !held.same_connection(&self.io));
362    }
363}
364
365pub(super) fn render_tokens(tokens: &[String], session: &str, task: &str) -> Vec<String> {
366    tokens
367        .iter()
368        .map(|token| token.replace("{session}", session).replace("{task}", task))
369        .collect()
370}
371
372/// Whether the session of one task is over.
373///
374/// The answer is derived, never read: `client.db` holds the session tuple the
375/// reducer wrote under the `(generation, seq)` gate, the task table holds the
376/// verdict the agent filed, and `project` is the only thing that says whether
377/// that pair is `exited` — so a session whose work finished cleanly leaves here,
378/// and one whose agent went away leaves here too. The rows stay readable after
379/// the session ends. A session with no row yet is live: it exists as a spawned
380/// resource alone, with nothing derived from it.
381pub(super) fn session_exited(inner: &DispatchInner, task_id: &str) -> bool {
382    let Some(row) = inner.store.get_session(task_id).ok().flatten() else {
383        return false;
384    };
385    projection_of(&row, stored_task_state(inner, task_id)).lifecycle == Lifecycle::Exited
386}
387
388/// Sessions that hold the role's concurrency: the staged slots whose tuple and
389/// task verdict have not projected to `Exited`.
390///
391/// §5's `max_sessions` caps concurrent sessions, and a session the reducer has
392/// ended answers no task, so it stops spending capacity the moment its tuple and
393/// verdict derive `exited` — whether that came from a completion report the task
394/// table settled or from the observation a plugin sends as its last heartbeat.
395/// The rows stay in `client.db` and stay queryable; a session with no row at all
396/// is live.
397pub(super) fn live_sessions(inner: &DispatchInner) -> usize {
398    inner
399        .sessions
400        .values()
401        .filter(|slot| !session_exited(inner, &slot.session.task_id))
402        .count()
403}