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}