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::{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}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub(crate) struct Scheduler {
66    ready: BTreeMap<SchedKey, ScheduledEntry>,
67    by_id: BTreeMap<ScheduledActionId, SchedKey>,
68    by_actor: BTreeMap<EntityId, BTreeSet<ScheduledActionId>>,
69    /// Monotonic per kernel lifetime; same-tick FIFO discriminator.
70    next_seq: u64,
71    /// Monotonic ID counter (NonZeroU64 starts at 1).
72    next_id: u64,
73}
74
75impl Scheduler {
76    pub(crate) fn new() -> Self {
77        Self {
78            ready: BTreeMap::new(),
79            by_id: BTreeMap::new(),
80            by_actor: BTreeMap::new(),
81            next_seq: 0,
82            next_id: 0,
83        }
84    }
85
86    /// Insert into `ready` + `by_id` + `by_actor` atomically.
87    pub(crate) fn schedule(
88        &mut self,
89        at: Tick,
90        actor: Option<EntityId>,
91        principal: Principal,
92        type_code: TypeCode,
93        bytes: Vec<u8>,
94    ) -> ScheduledActionId {
95        // Saturating (never wraps to 0), matching the kernel-wide A12
96        // panic-free arithmetic discipline; the NonZeroU64 below therefore
97        // cannot fail even after astronomically many schedules.
98        self.next_id = self.next_id.saturating_add(1);
99        let id = ScheduledActionId(
100            NonZeroU64::new(self.next_id).expect("next_id incremented before use; never zero"),
101        );
102
103        let seq = self.next_seq;
104        self.next_seq = self.next_seq.saturating_add(1);
105
106        let key = SchedKey { at, seq, id };
107        let entry = ScheduledEntry {
108            id,
109            at,
110            actor,
111            principal,
112            action_type_code: type_code,
113            action_bytes: bytes,
114        };
115
116        self.ready.insert(key, entry);
117        self.by_id.insert(id, key);
118        if let Some(actor_id) = actor {
119            self.by_actor.entry(actor_id).or_default().insert(id);
120        }
121
122        id
123    }
124
125    /// Schedule with a caller-provided `id` (e.g. when Kernel pre-allocates
126    /// the ScheduledActionId so it can be returned from `submit`). Internal
127    /// `next_id` is bumped so future auto-allocations stay monotonic.
128    pub(crate) fn schedule_with_id(
129        &mut self,
130        id: ScheduledActionId,
131        at: Tick,
132        actor: Option<EntityId>,
133        principal: Principal,
134        type_code: TypeCode,
135        bytes: Vec<u8>,
136    ) {
137        if id.get() > self.next_id {
138            self.next_id = id.get();
139        }
140
141        let seq = self.next_seq;
142        self.next_seq = self.next_seq.saturating_add(1);
143
144        let key = SchedKey { at, seq, id };
145        let entry = ScheduledEntry {
146            id,
147            at,
148            actor,
149            principal,
150            action_type_code: type_code,
151            action_bytes: bytes,
152        };
153
154        self.ready.insert(key, entry);
155        self.by_id.insert(id, key);
156        if let Some(actor_id) = actor {
157            self.by_actor.entry(actor_id).or_default().insert(id);
158        }
159    }
160
161    /// Immediate cancel — three-table consistent removal.
162    /// Returns the removed entry, or `None` if `id` was not scheduled
163    /// (collapsed `CancelMiss` semantics — never-scheduled,
164    /// already-executed, already-cancelled all return `None`).
165    pub(crate) fn cancel(&mut self, id: ScheduledActionId) -> Option<ScheduledEntry> {
166        let key = self.by_id.remove(&id)?;
167        let entry = self
168            .ready
169            .remove(&key)
170            .expect("ready/by_id consistency violated");
171        if let Some(actor_id) = entry.actor {
172            if let Some(set) = self.by_actor.get_mut(&actor_id) {
173                set.remove(&id);
174                if set.is_empty() {
175                    self.by_actor.remove(&actor_id);
176                }
177            }
178        }
179        Some(entry)
180    }
181
182    /// Bulk-cancel every entry owned by `actor`. Returns removed entries
183    /// in scheduler order (BTreeSet iteration over IDs is ascending).
184    /// Production wiring (entity-despawn cascade) lands with the
185    /// per-entity ownership refinement (deferred).
186    #[cfg_attr(not(test), allow(dead_code))]
187    pub(crate) fn cancel_by_actor(&mut self, actor: EntityId) -> Vec<ScheduledEntry> {
188        let Some(ids) = self.by_actor.remove(&actor) else {
189            return Vec::new();
190        };
191        let mut cancelled = Vec::with_capacity(ids.len());
192        for id in ids {
193            if let Some(key) = self.by_id.remove(&id) {
194                if let Some(entry) = self.ready.remove(&key) {
195                    cancelled.push(entry);
196                }
197            }
198        }
199        cancelled
200    }
201
202    /// Pop the earliest-due entry whose `at <= now`. Returns `None` if the
203    /// queue is empty or the head is in the future.
204    pub(crate) fn pop_due(&mut self, now: Tick) -> Option<ScheduledEntry> {
205        let (&key, _) = self.ready.first_key_value()?;
206        if key.at > now {
207            return None;
208        }
209        let entry = self
210            .ready
211            .remove(&key)
212            .expect("first_key_value just returned this key");
213        self.by_id.remove(&entry.id);
214        if let Some(actor_id) = entry.actor {
215            if let Some(set) = self.by_actor.get_mut(&actor_id) {
216                set.remove(&entry.id);
217                if set.is_empty() {
218                    self.by_actor.remove(&actor_id);
219                }
220            }
221        }
222        Some(entry)
223    }
224
225    /// Validate three-table index consistency. Run after a snapshot decode
226    /// so a corrupt or tampered snapshot is rejected up front rather than
227    /// triggering a latent panic in `cancel` / `pop_due` (both rely on
228    /// `ready`/`by_id` agreement). Returns `Err(reason)` on any mismatch.
229    pub(crate) fn validate(&self) -> Result<(), &'static str> {
230        // `ready` and `by_id` must be exact inverses.
231        if self.ready.len() != self.by_id.len() {
232            return Err("scheduler ready/by_id size mismatch");
233        }
234        for (key, entry) in &self.ready {
235            if entry.id != key.id {
236                return Err("scheduler entry id does not match its key");
237            }
238            match self.by_id.get(&entry.id) {
239                Some(k) if *k == *key => {}
240                _ => return Err("scheduler by_id does not map back to the ready key"),
241            }
242        }
243        // `by_actor` ids must exist in `by_id` and reference the right actor.
244        for (actor, ids) in &self.by_actor {
245            if ids.is_empty() {
246                return Err("scheduler by_actor holds an empty actor set");
247            }
248            for id in ids {
249                let Some(key) = self.by_id.get(id) else {
250                    return Err("scheduler by_actor references an unknown id");
251                };
252                match self.ready.get(key) {
253                    Some(entry) if entry.actor == Some(*actor) => {}
254                    _ => return Err("scheduler by_actor actor mismatch"),
255                }
256            }
257        }
258        // Reverse direction: every ready entry that names an actor MUST be
259        // indexed under by_actor[actor], else cancel_by_actor would silently
260        // miss it (the forward check above alone leaves that gap).
261        for entry in self.ready.values() {
262            if let Some(actor) = entry.actor {
263                match self.by_actor.get(&actor) {
264                    Some(set) if set.contains(&entry.id) => {}
265                    _ => return Err("scheduler ready entry missing from its by_actor index"),
266                }
267            }
268        }
269        // Monotonic counters must dominate every live key.
270        for key in self.ready.keys() {
271            if key.seq >= self.next_seq {
272                return Err("scheduler next_seq not monotonic over live keys");
273            }
274            if key.id.get() > self.next_id {
275                return Err("scheduler next_id not monotonic over live ids");
276            }
277        }
278        Ok(())
279    }
280
281    // Test-only observability accessors. Production introspection wiring
282    // lands with the future IntrospectHandle interface (deferred).
283    #[cfg_attr(not(test), allow(dead_code))]
284    #[inline]
285    pub(crate) fn len(&self) -> usize {
286        self.ready.len()
287    }
288
289    #[cfg_attr(not(test), allow(dead_code))]
290    #[inline]
291    pub(crate) fn is_empty(&self) -> bool {
292        self.ready.is_empty()
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::abi::{EntityId, Tick, TypeCode};
300
301    fn p() -> Principal {
302        Principal::System
303    }
304    fn tc() -> TypeCode {
305        TypeCode(1)
306    }
307
308    #[test]
309    fn empty_state() {
310        let s = Scheduler::new();
311        assert_eq!(s.len(), 0);
312        assert!(s.is_empty());
313    }
314
315    #[test]
316    fn schedule_then_pop_due_single() {
317        let mut s = Scheduler::new();
318        let id = s.schedule(Tick(5), None, p(), tc(), vec![1, 2, 3]);
319        assert_eq!(s.len(), 1);
320        let entry = s.pop_due(Tick(5)).expect("entry due");
321        assert_eq!(entry.id, id);
322        assert_eq!(entry.at, Tick(5));
323        assert_eq!(entry.action_bytes, vec![1, 2, 3]);
324        assert!(s.is_empty());
325    }
326
327    #[test]
328    fn pop_due_before_time_returns_none() {
329        let mut s = Scheduler::new();
330        s.schedule(Tick(10), None, p(), tc(), vec![]);
331        assert!(s.pop_due(Tick(9)).is_none());
332        assert_eq!(s.len(), 1);
333    }
334
335    #[test]
336    fn pop_due_at_exact_tick_pops() {
337        let mut s = Scheduler::new();
338        let id = s.schedule(Tick(5), None, p(), tc(), vec![]);
339        assert_eq!(s.pop_due(Tick(5)).unwrap().id, id);
340    }
341
342    #[test]
343    fn pop_due_ordering_by_tick() {
344        let mut s = Scheduler::new();
345        let id_late = s.schedule(Tick(20), None, p(), tc(), vec![]);
346        let id_early = s.schedule(Tick(5), None, p(), tc(), vec![]);
347        let id_mid = s.schedule(Tick(10), None, p(), tc(), vec![]);
348        assert_eq!(s.pop_due(Tick(100)).unwrap().id, id_early);
349        assert_eq!(s.pop_due(Tick(100)).unwrap().id, id_mid);
350        assert_eq!(s.pop_due(Tick(100)).unwrap().id, id_late);
351    }
352
353    #[test]
354    fn pop_due_tiebreak_by_seq() {
355        let mut s = Scheduler::new();
356        let id1 = s.schedule(Tick(5), None, p(), tc(), vec![1]);
357        let id2 = s.schedule(Tick(5), None, p(), tc(), vec![2]);
358        let id3 = s.schedule(Tick(5), None, p(), tc(), vec![3]);
359        assert_eq!(s.pop_due(Tick(5)).unwrap().id, id1);
360        assert_eq!(s.pop_due(Tick(5)).unwrap().id, id2);
361        assert_eq!(s.pop_due(Tick(5)).unwrap().id, id3);
362    }
363
364    #[test]
365    fn cancel_removes_entry() {
366        let mut s = Scheduler::new();
367        let id = s.schedule(Tick(5), None, p(), tc(), vec![]);
368        let cancelled = s.cancel(id).expect("found");
369        assert_eq!(cancelled.id, id);
370        assert!(s.is_empty());
371        assert!(s.pop_due(Tick(100)).is_none());
372    }
373
374    #[test]
375    fn cancel_unknown_returns_none() {
376        let mut s = Scheduler::new();
377        let bogus = ScheduledActionId::new(999).unwrap();
378        assert!(s.cancel(bogus).is_none());
379    }
380
381    #[test]
382    fn cancel_by_actor_removes_all() {
383        let mut s = Scheduler::new();
384        let actor = EntityId::new(1).unwrap();
385        let other = EntityId::new(2).unwrap();
386        let _ = s.schedule(Tick(5), Some(actor), p(), tc(), vec![]);
387        let _ = s.schedule(Tick(10), Some(actor), p(), tc(), vec![]);
388        let id_other = s.schedule(Tick(7), Some(other), p(), tc(), vec![]);
389        assert_eq!(s.len(), 3);
390        let cancelled = s.cancel_by_actor(actor);
391        assert_eq!(cancelled.len(), 2);
392        assert_eq!(s.len(), 1);
393        assert_eq!(s.pop_due(Tick(100)).unwrap().id, id_other);
394    }
395
396    #[test]
397    fn cancel_by_actor_unknown_returns_empty() {
398        let mut s = Scheduler::new();
399        let actor = EntityId::new(99).unwrap();
400        assert!(s.cancel_by_actor(actor).is_empty());
401    }
402
403    #[test]
404    fn schedule_id_monotonic() {
405        let mut s = Scheduler::new();
406        let id1 = s.schedule(Tick(0), None, p(), tc(), vec![]);
407        let id2 = s.schedule(Tick(0), None, p(), tc(), vec![]);
408        let id3 = s.schedule(Tick(0), None, p(), tc(), vec![]);
409        assert!(id1 < id2);
410        assert!(id2 < id3);
411        assert_eq!(id1.get(), 1);
412        assert_eq!(id3.get(), 3);
413    }
414
415    #[test]
416    fn no_tombstones() {
417        // After cancel, len decrements immediately — no lazy deletion.
418        let mut s = Scheduler::new();
419        let id1 = s.schedule(Tick(5), None, p(), tc(), vec![]);
420        let _id2 = s.schedule(Tick(5), None, p(), tc(), vec![]);
421        assert_eq!(s.len(), 2);
422        s.cancel(id1);
423        assert_eq!(s.len(), 1);
424    }
425
426    #[test]
427    fn determinism_same_sequence() {
428        fn run() -> Vec<u64> {
429            let mut s = Scheduler::new();
430            s.schedule(Tick(3), None, p(), tc(), vec![]);
431            s.schedule(Tick(1), None, p(), tc(), vec![]);
432            s.schedule(Tick(2), None, p(), tc(), vec![]);
433            s.schedule(Tick(1), None, p(), tc(), vec![]);
434            let mut out = Vec::new();
435            while let Some(e) = s.pop_due(Tick(100)) {
436                out.push(e.id.get());
437            }
438            out
439        }
440        assert_eq!(run(), run());
441    }
442
443    #[test]
444    fn validate_accepts_consistent_scheduler() {
445        let mut s = Scheduler::new();
446        s.schedule(Tick(5), Some(EntityId::new(1).unwrap()), p(), tc(), vec![1]);
447        s.schedule(Tick(3), None, p(), tc(), vec![2]);
448        s.schedule(Tick(5), Some(EntityId::new(1).unwrap()), p(), tc(), vec![3]);
449        assert!(s.validate().is_ok());
450    }
451
452    #[test]
453    fn validate_rejects_broken_ready_by_id_bijection() {
454        let mut s = Scheduler::new();
455        s.schedule(Tick(5), None, p(), tc(), vec![1]);
456        s.by_id.clear(); // ready holds an entry with no by_id mapping
457        assert!(s.validate().is_err());
458    }
459
460    #[test]
461    fn validate_rejects_missing_by_actor_index() {
462        let mut s = Scheduler::new();
463        s.schedule(Tick(5), Some(EntityId::new(1).unwrap()), p(), tc(), vec![1]);
464        s.by_actor.clear(); // actor-owned entry no longer indexed
465        assert!(s.validate().is_err());
466    }
467}