Skip to main content

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(&note.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(&note.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(&note.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, &note.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}