Skip to main content

arkhe_kernel/state/
scheduler.rs

1//! BTreeMap-based scheduler with immediate-remove cancellation.
2//!
3//! Three indexed tables maintained in lockstep:
4//! - `ready: BTreeMap<SchedKey, ScheduledEntry>` — primary execution queue,
5//!   ordered by `(at, seq, id)`.
6//! - `by_id: BTreeMap<ScheduledActionId, SchedKey>` — O(log n) cancel lookup.
7//! - `by_actor: BTreeMap<EntityId, BTreeSet<ScheduledActionId>>` — O(k log n)
8//!   actor-scoped cancel.
9//!
10//! No tombstones — `cancel` immediately removes from all three tables.
11//! Determinism: BTreeMap iteration order is total over `SchedKey`, and
12//! `seq` is monotonic per kernel lifetime, so identical schedule sequences
13//! produce identical pop_due streams (deterministic).
14
15use core::num::NonZeroU64;
16use serde::{Deserialize, Serialize};
17use std::collections::{BTreeMap, BTreeSet};
18
19use crate::abi::{CapabilityMask, EntityId, Principal, Tick, TypeCode};
20
21/// Sentinel-free scheduled-action handle (A6).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct ScheduledActionId(pub NonZeroU64);
25
26impl ScheduledActionId {
27    /// Returns `Some(_)` iff `v != 0`.
28    #[inline]
29    pub const fn new(v: u64) -> Option<Self> {
30        match NonZeroU64::new(v) {
31            Some(n) => Some(Self(n)),
32            None => None,
33        }
34    }
35
36    /// Underlying non-zero `u64`.
37    #[inline]
38    pub const fn get(self) -> u64 {
39        self.0.get()
40    }
41}
42
43/// Total-ordered key — `(at, seq, id)`. Tick first; same-tick FIFO by `seq`;
44/// final disambiguator by `id` (defensive — `seq` alone is unique).
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
46pub(crate) struct SchedKey {
47    pub at: Tick,
48    pub seq: u64,
49    pub id: ScheduledActionId,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub(crate) struct ScheduledEntry {
54    pub id: ScheduledActionId,
55    pub at: Tick,
56    pub actor: Option<EntityId>,
57    pub principal: Principal,
58    pub action_type_code: TypeCode,
59    /// Canonical bytes (postcard); deserialization through `ActionRegistry`
60    /// happens at dispatch time.
61    pub action_bytes: Vec<u8>,
62    /// Capability ceiling inherited from the scheduling context: the
63    /// effective caps under which the parent action ran. When this entry
64    /// pops, its effective caps are intersected with this ceiling, so a
65    /// scheduled action can only ever hold *less* authority than its
66    /// scheduler — privilege never widens across a schedule (closes the
67    /// time-shifted-escalation channel). An externally-submitted root is
68    /// scheduled with an all-permissive ceiling; its real bound is the
69    /// `caps_at_submit` recorded on its WAL Submit record. Snapshot-wire
70    /// only (schedules are not per-record WAL state under the CIL model).
71    pub caps_ceiling: CapabilityMask,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub(crate) struct Scheduler {
76    ready: BTreeMap<SchedKey, ScheduledEntry>,
77    by_id: BTreeMap<ScheduledActionId, SchedKey>,
78    by_actor: BTreeMap<EntityId, BTreeSet<ScheduledActionId>>,
79    /// Monotonic per kernel lifetime; same-tick FIFO discriminator.
80    next_seq: u64,
81    /// Monotonic ID counter (NonZeroU64 starts at 1).
82    next_id: u64,
83}
84
85impl Scheduler {
86    pub(crate) fn new() -> Self {
87        Self {
88            ready: BTreeMap::new(),
89            by_id: BTreeMap::new(),
90            by_actor: BTreeMap::new(),
91            next_seq: 0,
92            next_id: 0,
93        }
94    }
95
96    /// Insert into `ready` + `by_id` + `by_actor` atomically, auto-allocating
97    /// the next monotonic id. Production paths (`Kernel::submit`, the apply
98    /// commit) pre-allocate the id and use [`schedule_with_id`](Self::schedule_with_id)
99    /// so the id is decided once and reproduced on replay; this auto-id form
100    /// exercises the allocator directly under test.
101    #[cfg_attr(not(test), allow(dead_code))]
102    pub(crate) fn schedule(
103        &mut self,
104        at: Tick,
105        actor: Option<EntityId>,
106        principal: Principal,
107        caps_ceiling: CapabilityMask,
108        type_code: TypeCode,
109        bytes: Vec<u8>,
110    ) -> ScheduledActionId {
111        // Saturating (never wraps to 0), matching the kernel-wide A12
112        // panic-free arithmetic discipline; the NonZeroU64 below therefore
113        // cannot fail even after astronomically many schedules.
114        self.next_id = self.next_id.saturating_add(1);
115        let id = ScheduledActionId(
116            NonZeroU64::new(self.next_id).expect("next_id incremented before use; never zero"),
117        );
118
119        let seq = self.next_seq;
120        self.next_seq = self.next_seq.saturating_add(1);
121
122        let key = SchedKey { at, seq, id };
123        let entry = ScheduledEntry {
124            id,
125            at,
126            actor,
127            principal,
128            action_type_code: type_code,
129            action_bytes: bytes,
130            caps_ceiling,
131        };
132
133        self.ready.insert(key, entry);
134        self.by_id.insert(id, key);
135        if let Some(actor_id) = actor {
136            self.by_actor.entry(actor_id).or_default().insert(id);
137        }
138
139        id
140    }
141
142    /// Schedule with a caller-provided `id` (e.g. when Kernel pre-allocates
143    /// the ScheduledActionId so it can be returned from `submit`). Internal
144    /// `next_id` is bumped so future auto-allocations stay monotonic.
145    #[allow(clippy::too_many_arguments)]
146    pub(crate) fn schedule_with_id(
147        &mut self,
148        id: ScheduledActionId,
149        at: Tick,
150        actor: Option<EntityId>,
151        principal: Principal,
152        caps_ceiling: CapabilityMask,
153        type_code: TypeCode,
154        bytes: Vec<u8>,
155    ) {
156        if id.get() > self.next_id {
157            self.next_id = id.get();
158        }
159
160        let seq = self.next_seq;
161        self.next_seq = self.next_seq.saturating_add(1);
162
163        let key = SchedKey { at, seq, id };
164        let entry = ScheduledEntry {
165            id,
166            at,
167            actor,
168            principal,
169            action_type_code: type_code,
170            action_bytes: bytes,
171            caps_ceiling,
172        };
173
174        self.ready.insert(key, entry);
175        self.by_id.insert(id, key);
176        if let Some(actor_id) = actor {
177            self.by_actor.entry(actor_id).or_default().insert(id);
178        }
179    }
180
181    /// Immediate cancel — three-table consistent removal.
182    /// Returns the removed entry, or `None` if `id` was not scheduled
183    /// (collapsed `CancelMiss` semantics — never-scheduled,
184    /// already-executed, already-cancelled all return `None`).
185    pub(crate) fn cancel(&mut self, id: ScheduledActionId) -> Option<ScheduledEntry> {
186        let key = self.by_id.remove(&id)?;
187        let entry = self
188            .ready
189            .remove(&key)
190            .expect("ready/by_id consistency violated");
191        if let Some(actor_id) = entry.actor {
192            if let Some(set) = self.by_actor.get_mut(&actor_id) {
193                set.remove(&id);
194                if set.is_empty() {
195                    self.by_actor.remove(&actor_id);
196                }
197            }
198        }
199        Some(entry)
200    }
201
202    /// Bulk-cancel every entry owned by `actor`. Returns removed entries
203    /// in scheduler order (BTreeSet iteration over IDs is ascending).
204    /// Production wiring (entity-despawn cascade) lands with the
205    /// per-entity ownership refinement (deferred).
206    #[cfg_attr(not(test), allow(dead_code))]
207    pub(crate) fn cancel_by_actor(&mut self, actor: EntityId) -> Vec<ScheduledEntry> {
208        let Some(ids) = self.by_actor.remove(&actor) else {
209            return Vec::new();
210        };
211        let mut cancelled = Vec::with_capacity(ids.len());
212        for id in ids {
213            if let Some(key) = self.by_id.remove(&id) {
214                if let Some(entry) = self.ready.remove(&key) {
215                    cancelled.push(entry);
216                }
217            }
218        }
219        cancelled
220    }
221
222    /// Pop the earliest-due entry whose `at <= now`. Returns `None` if the
223    /// queue is empty or the head is in the future.
224    pub(crate) fn pop_due(&mut self, now: Tick) -> Option<ScheduledEntry> {
225        let (&key, _) = self.ready.first_key_value()?;
226        if key.at > now {
227            return None;
228        }
229        let entry = self
230            .ready
231            .remove(&key)
232            .expect("first_key_value just returned this key");
233        self.by_id.remove(&entry.id);
234        if let Some(actor_id) = entry.actor {
235            if let Some(set) = self.by_actor.get_mut(&actor_id) {
236                set.remove(&entry.id);
237                if set.is_empty() {
238                    self.by_actor.remove(&actor_id);
239                }
240            }
241        }
242        Some(entry)
243    }
244
245    /// Validate three-table index consistency. Run after a snapshot decode
246    /// so a corrupt or tampered snapshot is rejected up front rather than
247    /// triggering a latent panic in `cancel` / `pop_due` (both rely on
248    /// `ready`/`by_id` agreement). Returns `Err(reason)` on any mismatch.
249    pub(crate) fn validate(&self) -> Result<(), &'static str> {
250        // `ready` and `by_id` must be exact inverses.
251        if self.ready.len() != self.by_id.len() {
252            return Err("scheduler ready/by_id size mismatch");
253        }
254        for (key, entry) in &self.ready {
255            if entry.id != key.id {
256                return Err("scheduler entry id does not match its key");
257            }
258            match self.by_id.get(&entry.id) {
259                Some(k) if *k == *key => {}
260                _ => return Err("scheduler by_id does not map back to the ready key"),
261            }
262        }
263        // `by_actor` ids must exist in `by_id` and reference the right actor.
264        for (actor, ids) in &self.by_actor {
265            if ids.is_empty() {
266                return Err("scheduler by_actor holds an empty actor set");
267            }
268            for id in ids {
269                let Some(key) = self.by_id.get(id) else {
270                    return Err("scheduler by_actor references an unknown id");
271                };
272                match self.ready.get(key) {
273                    Some(entry) if entry.actor == Some(*actor) => {}
274                    _ => return Err("scheduler by_actor actor mismatch"),
275                }
276            }
277        }
278        // Reverse direction: every ready entry that names an actor MUST be
279        // indexed under by_actor[actor], else cancel_by_actor would silently
280        // miss it (the forward check above alone leaves that gap).
281        for entry in self.ready.values() {
282            if let Some(actor) = entry.actor {
283                match self.by_actor.get(&actor) {
284                    Some(set) if set.contains(&entry.id) => {}
285                    _ => return Err("scheduler ready entry missing from its by_actor index"),
286                }
287            }
288        }
289        // Monotonic counters must dominate every live key.
290        for key in self.ready.keys() {
291            if key.seq >= self.next_seq {
292                return Err("scheduler next_seq not monotonic over live keys");
293            }
294            if key.id.get() > self.next_id {
295                return Err("scheduler next_id not monotonic over live ids");
296            }
297        }
298        Ok(())
299    }
300
301    // Test-only observability accessors. Production introspection wiring
302    // lands with the future IntrospectHandle interface (deferred).
303    #[cfg_attr(not(test), allow(dead_code))]
304    #[inline]
305    pub(crate) fn len(&self) -> usize {
306        self.ready.len()
307    }
308
309    #[cfg_attr(not(test), allow(dead_code))]
310    #[inline]
311    pub(crate) fn is_empty(&self) -> bool {
312        self.ready.is_empty()
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::abi::{EntityId, Tick, TypeCode};
320
321    fn p() -> Principal {
322        Principal::System
323    }
324    fn caps() -> CapabilityMask {
325        CapabilityMask::all()
326    }
327    fn tc() -> TypeCode {
328        TypeCode(1)
329    }
330
331    #[test]
332    fn empty_state() {
333        let s = Scheduler::new();
334        assert_eq!(s.len(), 0);
335        assert!(s.is_empty());
336    }
337
338    #[test]
339    fn schedule_then_pop_due_single() {
340        let mut s = Scheduler::new();
341        let id = s.schedule(Tick(5), None, p(), caps(), tc(), vec![1, 2, 3]);
342        assert_eq!(s.len(), 1);
343        let entry = s.pop_due(Tick(5)).expect("entry due");
344        assert_eq!(entry.id, id);
345        assert_eq!(entry.at, Tick(5));
346        assert_eq!(entry.action_bytes, vec![1, 2, 3]);
347        assert!(s.is_empty());
348    }
349
350    #[test]
351    fn pop_due_before_time_returns_none() {
352        let mut s = Scheduler::new();
353        s.schedule(Tick(10), None, p(), caps(), tc(), vec![]);
354        assert!(s.pop_due(Tick(9)).is_none());
355        assert_eq!(s.len(), 1);
356    }
357
358    #[test]
359    fn pop_due_at_exact_tick_pops() {
360        let mut s = Scheduler::new();
361        let id = s.schedule(Tick(5), None, p(), caps(), tc(), vec![]);
362        assert_eq!(s.pop_due(Tick(5)).unwrap().id, id);
363    }
364
365    #[test]
366    fn pop_due_ordering_by_tick() {
367        let mut s = Scheduler::new();
368        let id_late = s.schedule(Tick(20), None, p(), caps(), tc(), vec![]);
369        let id_early = s.schedule(Tick(5), None, p(), caps(), tc(), vec![]);
370        let id_mid = s.schedule(Tick(10), None, p(), caps(), tc(), vec![]);
371        assert_eq!(s.pop_due(Tick(100)).unwrap().id, id_early);
372        assert_eq!(s.pop_due(Tick(100)).unwrap().id, id_mid);
373        assert_eq!(s.pop_due(Tick(100)).unwrap().id, id_late);
374    }
375
376    #[test]
377    fn pop_due_tiebreak_by_seq() {
378        let mut s = Scheduler::new();
379        let id1 = s.schedule(Tick(5), None, p(), caps(), tc(), vec![1]);
380        let id2 = s.schedule(Tick(5), None, p(), caps(), tc(), vec![2]);
381        let id3 = s.schedule(Tick(5), None, p(), caps(), tc(), vec![3]);
382        assert_eq!(s.pop_due(Tick(5)).unwrap().id, id1);
383        assert_eq!(s.pop_due(Tick(5)).unwrap().id, id2);
384        assert_eq!(s.pop_due(Tick(5)).unwrap().id, id3);
385    }
386
387    #[test]
388    fn cancel_removes_entry() {
389        let mut s = Scheduler::new();
390        let id = s.schedule(Tick(5), None, p(), caps(), tc(), vec![]);
391        let cancelled = s.cancel(id).expect("found");
392        assert_eq!(cancelled.id, id);
393        assert!(s.is_empty());
394        assert!(s.pop_due(Tick(100)).is_none());
395    }
396
397    #[test]
398    fn cancel_unknown_returns_none() {
399        let mut s = Scheduler::new();
400        let bogus = ScheduledActionId::new(999).unwrap();
401        assert!(s.cancel(bogus).is_none());
402    }
403
404    #[test]
405    fn cancel_by_actor_removes_all() {
406        let mut s = Scheduler::new();
407        let actor = EntityId::new(1).unwrap();
408        let other = EntityId::new(2).unwrap();
409        let _ = s.schedule(Tick(5), Some(actor), p(), caps(), tc(), vec![]);
410        let _ = s.schedule(Tick(10), Some(actor), p(), caps(), tc(), vec![]);
411        let id_other = s.schedule(Tick(7), Some(other), p(), caps(), tc(), vec![]);
412        assert_eq!(s.len(), 3);
413        let cancelled = s.cancel_by_actor(actor);
414        assert_eq!(cancelled.len(), 2);
415        assert_eq!(s.len(), 1);
416        assert_eq!(s.pop_due(Tick(100)).unwrap().id, id_other);
417    }
418
419    #[test]
420    fn cancel_by_actor_unknown_returns_empty() {
421        let mut s = Scheduler::new();
422        let actor = EntityId::new(99).unwrap();
423        assert!(s.cancel_by_actor(actor).is_empty());
424    }
425
426    #[test]
427    fn schedule_id_monotonic() {
428        let mut s = Scheduler::new();
429        let id1 = s.schedule(Tick(0), None, p(), caps(), tc(), vec![]);
430        let id2 = s.schedule(Tick(0), None, p(), caps(), tc(), vec![]);
431        let id3 = s.schedule(Tick(0), None, p(), caps(), tc(), vec![]);
432        assert!(id1 < id2);
433        assert!(id2 < id3);
434        assert_eq!(id1.get(), 1);
435        assert_eq!(id3.get(), 3);
436    }
437
438    #[test]
439    fn no_tombstones() {
440        // After cancel, len decrements immediately — no lazy deletion.
441        let mut s = Scheduler::new();
442        let id1 = s.schedule(Tick(5), None, p(), caps(), tc(), vec![]);
443        let _id2 = s.schedule(Tick(5), None, p(), caps(), tc(), vec![]);
444        assert_eq!(s.len(), 2);
445        s.cancel(id1);
446        assert_eq!(s.len(), 1);
447    }
448
449    #[test]
450    fn determinism_same_sequence() {
451        fn run() -> Vec<u64> {
452            let mut s = Scheduler::new();
453            s.schedule(Tick(3), None, p(), caps(), tc(), vec![]);
454            s.schedule(Tick(1), None, p(), caps(), tc(), vec![]);
455            s.schedule(Tick(2), None, p(), caps(), tc(), vec![]);
456            s.schedule(Tick(1), None, p(), caps(), tc(), vec![]);
457            let mut out = Vec::new();
458            while let Some(e) = s.pop_due(Tick(100)) {
459                out.push(e.id.get());
460            }
461            out
462        }
463        assert_eq!(run(), run());
464    }
465
466    #[test]
467    fn validate_accepts_consistent_scheduler() {
468        let mut s = Scheduler::new();
469        s.schedule(Tick(5), Some(EntityId::new(1).unwrap()), p(), caps(), tc(), vec![1]);
470        s.schedule(Tick(3), None, p(), caps(), tc(), vec![2]);
471        s.schedule(Tick(5), Some(EntityId::new(1).unwrap()), p(), caps(), tc(), vec![3]);
472        assert!(s.validate().is_ok());
473    }
474
475    #[test]
476    fn validate_rejects_broken_ready_by_id_bijection() {
477        let mut s = Scheduler::new();
478        s.schedule(Tick(5), None, p(), caps(), tc(), vec![1]);
479        s.by_id.clear(); // ready holds an entry with no by_id mapping
480        assert!(s.validate().is_err());
481    }
482
483    #[test]
484    fn validate_rejects_missing_by_actor_index() {
485        let mut s = Scheduler::new();
486        s.schedule(Tick(5), Some(EntityId::new(1).unwrap()), p(), caps(), tc(), vec![1]);
487        s.by_actor.clear(); // actor-owned entry no longer indexed
488        assert!(s.validate().is_err());
489    }
490}