onlyne_client/session/dispatch/slots.rs
1use super::*;
2
3use super::outbound::store_ack;
4use super::projection::{stored_task_state, task_state_of};
5use super::retire::stored_close_reason;
6use super::state::{
7 ControlNote, ControlWord, DispatchInner, DispatchState, due_control_settles, live_sessions,
8 session_exited, slot_key_serving_task,
9};
10use super::transport::names_session;
11
12impl DispatchState {
13 pub fn new(
14 role: impl Into<String>,
15 workspace: impl Into<PathBuf>,
16 command: Vec<String>,
17 max_sessions: u32,
18 backend: Arc<dyn SessionBackend>,
19 store: ClientStore,
20 ) -> Self {
21 Self {
22 inner: Arc::new(Mutex::new(DispatchInner {
23 role: role.into(),
24 workspace: workspace.into(),
25 command,
26 max_sessions,
27 relay_required: Vec::new(),
28 relay_count: None,
29 backend,
30 store,
31 bridge: Bridge::new(),
32 sessions: HashMap::new(),
33 outbox: None,
34 accept_new: Arc::new(AtomicBool::new(true)),
35 link_up: Arc::new(AtomicBool::new(false)),
36 cluster_ref: String::new(),
37 topology: String::new(),
38 transports: HashMap::new(),
39 parked: None,
40 stall: crate::session::stall::StallWatch::new(),
41 revived: Vec::new(),
42 held_handoffs: HashMap::new(),
43 control_settles: Vec::new(),
44 in_frame: Vec::new(),
45 })),
46 }
47 }
48
49 pub fn session_count(&self) -> usize {
50 self.inner.lock().sessions.len()
51 }
52 pub fn role(&self) -> String {
53 self.inner.lock().role.clone()
54 }
55 pub fn command(&self) -> Vec<String> {
56 self.inner.lock().command.clone()
57 }
58 pub fn backend_name(&self, task_id: &str) -> Option<String> {
59 self.inner
60 .lock()
61 .sessions
62 .values()
63 .find(|slot| slot.task_id.as_deref() == Some(task_id))
64 .map(|slot| slot.session.backend.clone())
65 }
66 /// The terminal-fact stream of a backend that drives its own agent.
67 pub fn outcome_feed(&self) -> Option<onlyne_session::OutcomeFeed> {
68 self.inner.lock().backend.outcomes()
69 }
70
71 /// Queue a delivery ack when the plugin refuses an assignment.
72 ///
73 /// An accepted assignment is not terminal for the server row: the normal
74 /// completion path still settles that delivery. A refused assignment is a
75 /// terminal local decision, so it uses the same durable ack queue as every
76 /// other delivery settlement.
77 pub fn push_assign_ack(&self, ack: onlyne_proto::AssignAckArgs) -> bool {
78 if ack.accepted {
79 return false;
80 }
81 let mut inner = self.inner.lock();
82 let msg_id = slot_key_serving_task(&inner, &ack.task_id)
83 .and_then(|key| inner.sessions.get_mut(&key))
84 .and_then(|slot| slot.msg_id.take());
85 let Some(msg_id) = msg_id else {
86 return false;
87 };
88 store_ack(
89 &inner,
90 AckArgs {
91 msg_id,
92 op_id: None,
93 accepted: false,
94 reason: ack.reason.or_else(|| Some("assign rejected".to_string())),
95 },
96 );
97 true
98 }
99
100 /// Queue an ack the client owes the server.
101 ///
102 /// Record an ack the client owes the server.
103 ///
104 /// The ack is durable: D11's control plane is at-least-once, and a settled
105 /// session whose ack is lost leaves the row in flight forever. The intent
106 /// queue carries it across a link that is down, and the flusher is the
107 /// sender.
108 pub fn push_settled(&self, ack: AckArgs) {
109 store_ack(&self.inner.lock(), ack);
110 }
111
112 /// Note that one task's plugin has been told by this client's own `control`
113 /// command to report the ending of that task.
114 ///
115 /// `on_control` runs this before the `recycle` frame leaves, which is the
116 /// point where the client still knows the order: the plugin's completion and
117 /// the retirement that command triggers race over the session's row, and a
118 /// guard that read the row would answer the same operator action two ways. One
119 /// note per task is kept, so a command issued twice waits for one answer.
120 ///
121 /// `word` is what the operator said and `now` is when they said it, and both
122 /// are the caller's to name rather than this call's to invent: the command is
123 /// the authority on the ending it asked for, and the instant it reads is the
124 /// one the watchdog's bound runs from.
125 pub fn owe_controlled_settle(&self, task_id: &str, word: ControlWord, now: Instant) {
126 let mut inner = self.inner.lock();
127 if !inner
128 .control_settles
129 .iter()
130 .any(|owed| owed.task_id == task_id)
131 {
132 inner.control_settles.push(ControlNote {
133 task_id: task_id.to_string(),
134 noted_at: now,
135 word,
136 });
137 }
138 }
139
140 /// Whether one completion answers a command noted above, consuming the note.
141 ///
142 /// The note is spent whichever way the settle it authorises goes: a refused
143 /// verdict leaves no second answer owed, and an applied one has travelled the
144 /// command's own completion. A later report for the same task is the plugin
145 /// speaking for itself again, and reads the ordinary door.
146 pub fn take_controlled_settle(&self, task_id: &str) -> bool {
147 let mut inner = self.inner.lock();
148 let Some(at) = inner
149 .control_settles
150 .iter()
151 .position(|owed| owed.task_id == task_id)
152 else {
153 return false;
154 };
155 inner.control_settles.swap_remove(at);
156 true
157 }
158
159 /// The notes whose operator's word has gone unanswered past the bound.
160 ///
161 /// The reading a sweep takes before it acts, and it spends nothing: the
162 /// settle below goes through [`take_controlled_settle`], so a completion that
163 /// answers a word between this read and that call takes the note first.
164 ///
165 /// [`take_controlled_settle`]: DispatchState::take_controlled_settle
166 pub fn control_settles_due(&self, now: Instant) -> Vec<ControlNote> {
167 due_control_settles(&self.inner.lock(), now)
168 }
169
170 /// Settle the work one operator's word left open, with no report behind it.
171 ///
172 /// The word asks a plugin for its own ending and the completion that answers
173 /// it is a frame of the plugin's, so a plugin that never sends one — it left
174 /// with the command's frame, or implements no `recycle` at all — leaves the
175 /// task open and the delivery row this client was handed in flight, with no
176 /// later caller to answer either. This is that caller.
177 ///
178 /// The writes are the ones `retire_dropped_ghosts` makes for the task its
179 /// owed session left: the verdict through the task's own record, which
180 /// refuses to overwrite one that landed first, and the still-held delivery row
181 /// refused with the operator's word, which is terminal for that row the way
182 /// every refusal is. The publish is the caller's, because it cannot run under
183 /// this lock.
184 ///
185 /// Answers `false` when this call is not the settle: the note is already spent
186 /// by a completion that answered the word, or the task's record carries a
187 /// verdict already, and either way nothing here is written and nothing is for
188 /// the caller to publish.
189 ///
190 /// [`retire_dropped_ghosts`]: DispatchState::retire_dropped_ghosts
191 pub fn settle_unanswered_control(&self, note: &ControlNote) -> bool {
192 // The note is taken first and at once: a report that answers the word
193 // while this call waits for the lock spends it, and the task then needs no
194 // verdict from here.
195 if !self.take_controlled_settle(¬e.task_id) {
196 return false;
197 }
198 let mut inner = self.inner.lock();
199 // The verdict goes through the task's own record, which keeps the first
200 // one it was handed: a row an earlier settle answered stays as that settle
201 // left it. A verdict that was not this call's is a settle that already
202 // happened — every door that writes one answers the delivery row in the
203 // same breath — so there is nothing left here to refuse or to publish.
204 let verdict = task_state_of(note.word.outcome());
205 match inner.store.settle_task(¬e.task_id, verdict) {
206 Ok(true) => {}
207 Ok(false) => {
208 tracing::warn!(
209 task = %note.task_id,
210 ?verdict,
211 "an unanswered control command's task was already settled; the first verdict stands"
212 );
213 return false;
214 }
215 Err(error) => {
216 // A store that refused the write must not cost the word its
217 // answer: the note goes back where it came from — through the door
218 // that records one, stamped where it was — and the next tick tries
219 // again rather than leaving the task open forever.
220 tracing::warn!(
221 task = %note.task_id,
222 error = %error,
223 "the task of an unanswered control command was not settled; the word stays owed"
224 );
225 drop(inner);
226 self.owe_controlled_settle(¬e.task_id, note.word, note.noted_at);
227 return false;
228 }
229 }
230 // The delivery row this client is still holding is refused, and the
231 // reason is the operator's own word: the row is answered once, by whoever
232 // still holds its handle, and a plugin's report arriving later finds no
233 // handle left to spend.
234 let held = slot_key_serving_task(&inner, ¬e.task_id)
235 .and_then(|key| inner.sessions.get_mut(&key))
236 .and_then(|slot| slot.msg_id.take());
237 if let Some(msg_id) = held {
238 store_ack(
239 &inner,
240 AckArgs {
241 msg_id,
242 op_id: None,
243 accepted: false,
244 reason: Some(note.word.refusal().to_string()),
245 },
246 );
247 }
248 true
249 }
250
251 /// The role slice the dispatcher currently runs.
252 pub fn role_slice(&self) -> crate::session::slice::RoleSlice {
253 let inner = self.inner.lock();
254 crate::session::slice::RoleSlice {
255 command: inner.command.clone(),
256 max_sessions: inner.max_sessions,
257 relay_required: inner.relay_required.clone(),
258 relay_count: inner.relay_count,
259 }
260 }
261
262 /// Task ids currently occupying a live slot.
263 pub fn live_task_ids(&self) -> std::collections::HashSet<String> {
264 let inner = self.inner.lock();
265 inner
266 .sessions
267 .values()
268 .filter_map(|slot| slot.task_id.clone())
269 .collect()
270 }
271
272 /// Sorted live-slot task ids for a hello claim.
273 pub fn hello_live_tasks(&self) -> Vec<String> {
274 crate::session::claim::from_slots(self.live_task_ids())
275 }
276
277 /// Start the stall clock for a newly assigned task.
278 pub fn note_stall_assigned(&self, task_id: &str, now: Instant) {
279 self.inner.lock().stall.note_assigned(task_id, now);
280 }
281
282 /// Refresh the stall clock after an Applied persist.
283 pub fn note_stall_applied(&self, task_id: &str, now: Instant) {
284 self.inner.lock().stall.note_applied(task_id, now);
285 }
286
287 /// Task ids whose freeze exceeds `threshold_secs` in this episode.
288 /// Exited projections retire their remaining progress clocks.
289 pub fn stall_due(&self, now: Instant, threshold_secs: u64) -> Vec<String> {
290 let mut inner = self.inner.lock();
291 let due = inner.stall.due(now, threshold_secs);
292 let mut active = Vec::with_capacity(due.len());
293 for task_id in due {
294 if session_exited(&inner, &task_id) {
295 inner.stall.forget(&task_id);
296 } else {
297 active.push(task_id);
298 }
299 }
300 active
301 }
302
303 /// Remember that this freeze episode has been reported.
304 pub fn mark_stalled(&self, task_id: &str) {
305 self.inner.lock().stall.mark_reported(task_id);
306 }
307
308 /// Observation-only stall fault for an active task, carrying the stored
309 /// watermark. A tuple and verdict that derive `exited` retire their progress
310 /// clock before the send boundary.
311 pub fn stall_report(&self, task_id: &str) -> Option<Report> {
312 let mut inner = self.inner.lock();
313 let row = inner.store.get_session(task_id).ok().flatten();
314 let exited = row.as_ref().is_some_and(|row| {
315 projection_of(row, stored_task_state(&inner, task_id)).lifecycle == Lifecycle::Exited
316 });
317 if exited {
318 inner.stall.forget(task_id);
319 return None;
320 }
321 Some(crate::session::stall::report(
322 task_id,
323 Some(task_id.to_string()),
324 row.as_ref().map(|row| row.generation as u64),
325 row.as_ref().map(|row| row.seq as u64),
326 ))
327 }
328
329 /// Whether any adapter is currently mounted (named or parked).
330 pub fn has_mounted_adapter(&self) -> bool {
331 let inner = self.inner.lock();
332 !inner.transports.is_empty() || inner.parked.is_some()
333 }
334
335 /// Adopt a role slice: the one `welcome` carried, or the one a reload's
336 /// role row carries.
337 pub fn reconfigure(&self, slice: crate::session::slice::RoleSlice) {
338 let mut inner = self.inner.lock();
339 inner.command = slice.command;
340 inner.max_sessions = slice.max_sessions;
341 inner.relay_required = slice.relay_required;
342 inner.relay_count = slice.relay_count;
343 }
344
345 /// Role prose last cached from `welcome`.
346 pub fn role_prose(&self) -> String {
347 let inner = self.inner.lock();
348 inner
349 .store
350 .prose(&inner.role)
351 .ok()
352 .flatten()
353 .map(|(prose, _)| prose)
354 .unwrap_or_default()
355 }
356
357 /// Whether one task names a session this client holds, in memory or in its
358 /// durable session rows.
359 pub fn holds_task(&self, task_id: &str) -> bool {
360 let inner = self.inner.lock();
361 inner
362 .sessions
363 .values()
364 .any(|slot| slot.task_id.as_deref() == Some(task_id))
365 || inner.store.get_session(task_id).ok().flatten().is_some()
366 }
367
368 /// Generation the reducer holds for one task, before any hand-off.
369 pub fn session_generation(&self, task_id: &str) -> Option<u64> {
370 self.inner
371 .lock()
372 .sessions
373 .values()
374 .find(|slot| slot.task_id.as_deref() == Some(task_id))
375 .map(|slot| slot.session.generation)
376 }
377
378 /// Whether a delivery has somewhere to run.
379 ///
380 /// §5's `max_sessions` caps concurrency, so a delivery that arrives at the
381 /// cap waits on the server: the row stays in flight and the next pull
382 /// offers it again once a session frees. Each task runs in its own session,
383 /// so a slot whose task has finished still spends capacity until it retires.
384 pub fn has_capacity(&self) -> bool {
385 let inner = self.inner.lock();
386 live_sessions(&inner) < inner.max_sessions as usize
387 }
388
389 /// Whether this role already finished one task with a terminal `Done`.
390 ///
391 /// The task's own record is the account: the settle writes the verdict the
392 /// agent filed and refuses to overwrite it, so a record reading `done` means
393 /// this role answered for this task id once already. A redelivery of that
394 /// task is not new work — running it again would stage its payload on
395 /// whichever session happens to be idle, so one chain's task executes inside
396 /// another conversation and the second answer collides with the verdict the
397 /// first one settled.
398 ///
399 /// Only `done` counts. A session killed or crashed mid-flight leaves its task
400 /// open, or settles it `failed`, and the server's requeue, `repair_retry`, and
401 /// `control retry` all re-offer that task on purpose, so those deliveries
402 /// still run.
403 pub fn task_completed_here(&self, task_id: &str) -> bool {
404 let inner = self.inner.lock();
405 matches!(
406 stored_close_reason(&inner, task_id),
407 Some(onlyne_session::CloseReason::Completed)
408 )
409 }
410
411 /// The task of one session that holds a payload with no connection bound.
412 ///
413 /// A work item that arrives before its always-running agent mounts waits in
414 /// exactly this state, and the mount ends the wait. A session answers through
415 /// the transport its first task claimed, so it stays served.
416 pub fn staged_without_transport(&self) -> Option<String> {
417 let inner = self.inner.lock();
418 inner
419 .sessions
420 .iter()
421 .find(|(_, slot)| slot.payload.is_some())
422 .filter(|(key, slot)| {
423 !inner
424 .transports
425 .keys()
426 .any(|session| names_session(key, slot, session))
427 })
428 .and_then(|(_, slot)| slot.task_id.clone())
429 }
430}