Skip to main content

forge_orchestration/scheduler/
deadline.rs

1//! Tick-deadline scheduling discipline (EEVDF-as-frame-deadline).
2//!
3//! A simulation that must render/advance a frame every `tick` is a *soft
4//! real-time* workload: the value of running it is highest right before its next
5//! tick is due and drops (or is lost) once the tick is missed. Static priority
6//! cannot express this — two cells with equal priority but different tick phases
7//! must still be ordered by *which tick is due first*. This module orders
8//! schedulables by an **earliest-virtual-deadline-first** rule, where the
9//! virtual deadline is the simulation's next tick time.
10//!
11//! # EEVDF mapping
12//!
13//! EEVDF (Earliest Eligible Virtual Deadline First) schedules by two per-entity
14//! quantities computed in a *virtual time* `v` that advances inversely to total
15//! weight:
16//!
17//! - **lag** `l_i = w_i * (v - v_i)` — how far entity `i` is from its fair share.
18//!   `l_i > 0` means it is owed service (under-served); `l_i < 0` means it has
19//!   over-run. An entity is **eligible** only when `l_i >= 0` (`v >= v_i`,
20//!   i.e. its `eligible_time` has arrived).
21//! - **virtual deadline** `d_i = v_i + r_i / w_i` — eligible time plus the
22//!   virtual cost of one request `r_i` scaled by weight `w_i`.
23//!
24//! EEVDF runs the **eligible** entity with the **earliest virtual deadline**.
25//!
26//! ## How we map a sim tick onto this
27//!
28//! | EEVDF concept            | This module                                              |
29//! |--------------------------|----------------------------------------------------------|
30//! | virtual deadline `d_i`   | `virtual_deadline` = the cell's **next tick wall time**  |
31//! | eligible time `v_i`      | `eligible_time` = tick start = `next_deadline - tick`    |
32//! | request size `r_i`       | one tick's wall budget = `tick`                          |
33//! | weight `w_i`             | derived from static `priority` (`weight = priority+1>=1`) |
34//! | lag `l_i`                | `weight * (now - eligible_time)` in milliseconds         |
35//!
36//! So instead of EEVDF's abstract virtual time we use **wall-clock time as the
37//! virtual timeline** (every entity shares one global clock, weight 1 in time),
38//! and let `weight` break ties and bias lag. This is the documented divergence
39//! from textbook EEVDF (see *Differences* below) and is exact for the common
40//! case where every cell advances on real wall-clock ticks.
41//!
42//! ## Ordering rule
43//!
44//! Eligible entities are ordered by `(virtual_deadline asc, weight desc,
45//! sequence asc)`. Ineligible entities (tick not yet started) sort after all
46//! eligible ones. This is **earliest-deadline-first among the runnable set**,
47//! the EEVDF discipline restricted to our wall-clock virtual time.
48//!
49//! ## Missing a tick (overrun handling)
50//!
51//! When `now > virtual_deadline` the tick is **late**. [`TickOutcome`] selects
52//! the policy:
53//!
54//! - [`MissPolicy::Late`] — run anyway, then re-arm the deadline forward
55//!   (accumulating lag); good for replay/offline sims where every frame matters.
56//! - [`MissPolicy::Drop`] — skip the missed frame, re-arm to the next future
57//!   tick boundary; good for live/interactive sims (catch up, don't pile up).
58//! - [`MissPolicy::Backpressure`] — refuse to enqueue further ticks until the
59//!   backlog (`lag`) falls below a bound; surfaced as [`Eligibility::Backpressured`].
60//!
61//! # Differences from textbook EEVDF
62//!
63//! 1. **Virtual time is wall time.** Textbook EEVDF advances `v` as
64//!    `dv = dt / W` (W = sum of weights) so the timeline stretches under load.
65//!    We pin `v == now` (wall clock). Deadlines are therefore real instants the
66//!    sim genuinely must hit, not load-relative virtual instants. Weight then
67//!    only biases *lag* and *ties*, never the deadline scale.
68//! 2. **Bounded, integer lag.** Lag is computed in whole milliseconds
69//!    (`i64`), saturating, so ordering is total and panic-free — no float NaN
70//!    can corrupt the heap (textbook EEVDF carries real-valued lag).
71//! 3. **Explicit miss policy.** Classic EEVDF has no notion of dropping a
72//!    request; a missed sim frame is a first-class outcome here.
73
74use std::cmp::Ordering;
75use std::collections::BinaryHeap;
76use std::time::Duration;
77
78use chrono::{DateTime, Utc};
79use parking_lot::Mutex;
80
81use super::algorithms::SchedulingAlgorithm;
82use super::{NodeResources, Workload};
83
84/// Policy for handling a tick whose deadline has already passed.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum MissPolicy {
87    /// Run the late tick anyway; re-arm the deadline forward by one tick.
88    Late,
89    /// Skip the missed frame(s); re-arm to the next future tick boundary.
90    Drop,
91    /// Stop admitting new ticks while backlog exceeds `max_lag`.
92    Backpressure {
93        /// Maximum tolerated lag before backpressure engages.
94        max_lag: Duration,
95    },
96}
97
98impl Default for MissPolicy {
99    fn default() -> Self {
100        MissPolicy::Late
101    }
102}
103
104/// Result of evaluating a tick's eligibility against `now`.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum Eligibility {
107    /// Tick has not started yet (`now < eligible_time`); not runnable.
108    Pending,
109    /// Tick is runnable and on time (`eligible_time <= now <= virtual_deadline`).
110    Eligible,
111    /// Deadline already passed (`now > virtual_deadline`); the entity is late.
112    Late,
113    /// Backlog exceeds the configured bound; admission is paused.
114    Backpressured,
115}
116
117/// What to do with a late tick, per [`MissPolicy`].
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum TickOutcome {
120    /// Run the tick now.
121    Run,
122    /// Drop this frame and advance to the next boundary.
123    DropFrame,
124    /// Apply backpressure (do not admit).
125    Hold,
126}
127
128/// A schedulable carrying EEVDF-style timing. Cheap to copy except for the
129/// owned [`Workload`].
130#[derive(Debug, Clone)]
131pub struct DeadlineEntry {
132    /// The underlying workload (typically a sim cell member or its world anchor).
133    pub workload: Workload,
134    /// Eligible (tick-start) time `v_i`: when this entity *may* begin running.
135    pub eligible_time: DateTime<Utc>,
136    /// Virtual deadline `d_i`: the next tick's wall-clock due time.
137    pub virtual_deadline: DateTime<Utc>,
138    /// Tick cadence; one request's virtual cost `r_i`.
139    pub tick: Duration,
140    /// EEVDF weight `w_i` (>= 1), derived from static priority.
141    pub weight: u32,
142    /// FIFO tiebreaker assigned on enqueue.
143    pub sequence: u64,
144}
145
146impl DeadlineEntry {
147    /// Build an entry. `weight` is clamped to `>= 1`.
148    pub fn new(
149        workload: Workload,
150        eligible_time: DateTime<Utc>,
151        virtual_deadline: DateTime<Utc>,
152        tick: Duration,
153    ) -> Self {
154        let weight = (workload.priority.max(0) as u32).saturating_add(1);
155        Self {
156            workload,
157            eligible_time,
158            virtual_deadline,
159            tick,
160            weight,
161            sequence: 0,
162        }
163    }
164
165    /// Construct directly from a sim cell's next tick.
166    ///
167    /// `next_deadline` is the cell's `next_deadline`; `eligible_time` is
168    /// `next_deadline - tick` (the tick's start). This is the bridge from
169    /// [`crate::scheduler::sim::SimCell`] timing into the deadline queue.
170    pub fn from_tick(workload: Workload, next_deadline: DateTime<Utc>, tick: Duration) -> Self {
171        let tick_chrono = chrono::Duration::from_std(tick)
172            .unwrap_or_else(|_| chrono::Duration::milliseconds(0));
173        let eligible_time = next_deadline - tick_chrono;
174        Self::new(workload, eligible_time, next_deadline, tick)
175    }
176
177    /// Lag `l_i` in milliseconds: `weight * (now - eligible_time)`, saturating.
178    ///
179    /// Positive => owed service (eligible/under-served). Negative => its tick has
180    /// not started yet. Bounded integer math keeps ordering total.
181    pub fn lag_ms(&self, now: DateTime<Utc>) -> i64 {
182        let elapsed = (now - self.eligible_time).num_milliseconds();
183        elapsed.saturating_mul(self.weight as i64)
184    }
185
186    /// Classify the entity against `now` and a [`MissPolicy`].
187    pub fn eligibility(&self, now: DateTime<Utc>, policy: MissPolicy) -> Eligibility {
188        if now < self.eligible_time {
189            return Eligibility::Pending;
190        }
191        if now <= self.virtual_deadline {
192            return Eligibility::Eligible;
193        }
194        // now > virtual_deadline: late.
195        if let MissPolicy::Backpressure { max_lag } = policy {
196            let max_lag_ms = chrono::Duration::from_std(max_lag)
197                .map(|d| d.num_milliseconds())
198                .unwrap_or(i64::MAX);
199            // Use unit-weight overrun for the backpressure bound (real time owed).
200            let overrun_ms = (now - self.virtual_deadline).num_milliseconds();
201            if overrun_ms > max_lag_ms {
202                return Eligibility::Backpressured;
203            }
204        }
205        Eligibility::Late
206    }
207
208    /// Decide what to do with this entity right now under `policy`.
209    pub fn outcome(&self, now: DateTime<Utc>, policy: MissPolicy) -> TickOutcome {
210        match self.eligibility(now, policy) {
211            Eligibility::Eligible | Eligibility::Late => match policy {
212                MissPolicy::Drop if now > self.virtual_deadline => TickOutcome::DropFrame,
213                _ => TickOutcome::Run,
214            },
215            Eligibility::Backpressured => TickOutcome::Hold,
216            Eligibility::Pending => TickOutcome::Hold,
217        }
218    }
219
220    /// Re-arm timing after a tick is serviced.
221    ///
222    /// - `Late`: advance both eligible and deadline by exactly one tick
223    ///   (preserve accumulated lag).
224    /// - `Drop`: jump the deadline to the first future boundary at or after
225    ///   `now`, discarding skipped frames; eligible time is `deadline - tick`.
226    pub fn rearm(&mut self, now: DateTime<Utc>, policy: MissPolicy) {
227        let tick_chrono = chrono::Duration::from_std(self.tick)
228            .unwrap_or_else(|_| chrono::Duration::milliseconds(0));
229        match policy {
230            MissPolicy::Drop if self.tick.as_millis() > 0 && now > self.virtual_deadline => {
231                // Smallest k >= 1 such that deadline + k*tick >= now.
232                let behind_ms = (now - self.virtual_deadline).num_milliseconds().max(0);
233                let tick_ms = tick_chrono.num_milliseconds().max(1);
234                let k = behind_ms / tick_ms + 1;
235                self.virtual_deadline += tick_chrono * (k as i32);
236                self.eligible_time = self.virtual_deadline - tick_chrono;
237            }
238            _ => {
239                self.eligible_time = self.virtual_deadline;
240                self.virtual_deadline += tick_chrono;
241            }
242        }
243    }
244}
245
246/// A min-heap ordering wrapper: earliest virtual deadline pops first.
247///
248/// [`BinaryHeap`] is a max-heap, so [`Ord`] here is *reversed* — "greater" means
249/// "should run later". The full order is:
250/// `(virtual_deadline asc, weight desc, sequence asc)`.
251#[derive(Debug, Clone)]
252struct Earliest(DeadlineEntry);
253
254impl PartialEq for Earliest {
255    fn eq(&self, other: &Self) -> bool {
256        self.0.virtual_deadline == other.0.virtual_deadline
257            && self.0.weight == other.0.weight
258            && self.0.sequence == other.0.sequence
259    }
260}
261impl Eq for Earliest {}
262
263impl PartialOrd for Earliest {
264    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
265        Some(self.cmp(other))
266    }
267}
268
269impl Ord for Earliest {
270    fn cmp(&self, other: &Self) -> Ordering {
271        // Reverse so the heap's max is our *least* urgent.
272        // Earliest deadline = most urgent = should be heap-max.
273        other
274            .0
275            .virtual_deadline
276            .cmp(&self.0.virtual_deadline)
277            // Higher weight is more urgent => heap-greater.
278            .then_with(|| self.0.weight.cmp(&other.0.weight))
279            // Lower sequence (older) is more urgent => heap-greater.
280            .then_with(|| other.0.sequence.cmp(&self.0.sequence))
281    }
282}
283
284/// EEVDF-style deadline-ordered queue. Pops the earliest-virtual-deadline entry.
285///
286/// Thread-safe via an internal [`parking_lot::Mutex`], matching the existing
287/// [`super::queue::SchedulingQueue`] design.
288pub struct DeadlineQueue {
289    heap: Mutex<BinaryHeap<Earliest>>,
290    sequence: Mutex<u64>,
291    policy: MissPolicy,
292}
293
294impl DeadlineQueue {
295    /// Create a queue with the given miss policy.
296    pub fn new(policy: MissPolicy) -> Self {
297        Self {
298            heap: Mutex::new(BinaryHeap::new()),
299            sequence: Mutex::new(0),
300            policy,
301        }
302    }
303
304    /// The queue's miss policy.
305    pub fn policy(&self) -> MissPolicy {
306        self.policy
307    }
308
309    /// Enqueue a tick. Assigns a FIFO sequence for tie-breaking.
310    pub fn enqueue(&self, mut entry: DeadlineEntry) {
311        let mut seq = self.sequence.lock();
312        entry.sequence = *seq;
313        *seq += 1;
314        drop(seq);
315        self.heap.lock().push(Earliest(entry));
316    }
317
318    /// Number of queued ticks.
319    pub fn len(&self) -> usize {
320        self.heap.lock().len()
321    }
322
323    /// Whether the queue is empty.
324    pub fn is_empty(&self) -> bool {
325        self.heap.lock().is_empty()
326    }
327
328    /// Peek the workload id of the most-urgent (earliest-deadline) entry.
329    pub fn peek_id(&self) -> Option<String> {
330        self.heap.lock().peek().map(|e| e.0.workload.id.clone())
331    }
332
333    /// Pop the most-urgent (earliest virtual deadline) entry unconditionally.
334    pub fn pop_earliest(&self) -> Option<DeadlineEntry> {
335        self.heap.lock().pop().map(|e| e.0)
336    }
337
338    /// Pop the most-urgent entry that is runnable *now* under the miss policy.
339    ///
340    /// Pending entries (tick not started) and backpressured entries are kept in
341    /// the queue. Returns the entry plus the [`TickOutcome`] decided for it.
342    /// `Drop`ped frames are returned too (the caller re-arms and re-enqueues);
343    /// the outcome tells the caller whether to actually execute.
344    pub fn pop_runnable(&self, now: DateTime<Utc>) -> Option<(DeadlineEntry, TickOutcome)> {
345        let mut heap = self.heap.lock();
346        let mut deferred: Vec<Earliest> = Vec::new();
347        let result = loop {
348            match heap.pop() {
349                Some(top) => {
350                    let outcome = top.0.outcome(now, self.policy);
351                    match outcome {
352                        TickOutcome::Hold => {
353                            // Pending or backpressured: keep it, look deeper.
354                            deferred.push(top);
355                        }
356                        TickOutcome::Run | TickOutcome::DropFrame => {
357                            break Some((top.0, outcome));
358                        }
359                    }
360                }
361                None => break None,
362            }
363        };
364        for d in deferred {
365            heap.push(d);
366        }
367        result
368    }
369}
370
371/// A thin [`SchedulingAlgorithm`] that biases node scoring toward deadline-tagged
372/// workloads, so urgent ticks land on the emptiest (lowest-latency) node.
373///
374/// Ordering of *which* tick to run is the [`DeadlineQueue`]'s job; this is the
375/// *placement* half: given a chosen tick, prefer the node that will start it
376/// soonest (least contended). It wraps `inner` for the base score and adds an
377/// urgency bonus based on the workload's static priority (the deadline itself is
378/// queue-side state, not visible on a bare [`Workload`]).
379pub struct TickDeadlineScheduler<A: SchedulingAlgorithm> {
380    inner: A,
381    /// Weight of the urgency bonus added to the inner score.
382    urgency_weight: f64,
383}
384
385impl<A: SchedulingAlgorithm> TickDeadlineScheduler<A> {
386    /// Wrap an inner algorithm with a default urgency weight of `1.0`.
387    ///
388    /// At `1.0`, a maximally-urgent workload's headroom bonus can fully override
389    /// the inner algorithm's packing preference, so urgent ticks land on the
390    /// emptiest (lowest-latency-to-start) node. Lower it toward `0` to make
391    /// urgency only a tiebreaker on top of the inner score.
392    pub fn new(inner: A) -> Self {
393        Self {
394            inner,
395            urgency_weight: 1.0,
396        }
397    }
398
399    /// Set the urgency weight.
400    pub fn with_urgency_weight(mut self, w: f64) -> Self {
401        self.urgency_weight = w;
402        self
403    }
404}
405
406impl<A: SchedulingAlgorithm> SchedulingAlgorithm for TickDeadlineScheduler<A> {
407    fn score(&self, workload: &Workload, node: &NodeResources) -> f64 {
408        let base = self.inner.score(workload, node);
409        // Urgent (high-priority/short-tick) workloads prefer the *emptiest* node
410        // so they start immediately: reward available CPU headroom.
411        let headroom = if node.cpu_capacity > 0 {
412            node.cpu_available() as f64 / node.cpu_capacity as f64
413        } else {
414            0.0
415        };
416        // Bounded weight derived from static priority (0..1).
417        let urgency = (workload.priority.max(0) as f64 / 1000.0).min(1.0);
418        base + self.urgency_weight * urgency * headroom
419    }
420
421    fn name(&self) -> &str {
422        "tick-deadline"
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::scheduler::algorithms::BinPackScheduler;
430    use crate::types::NodeId;
431
432    fn at(ms: i64) -> DateTime<Utc> {
433        // A fixed epoch + ms; deterministic for ordering tests.
434        DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap()
435            + chrono::Duration::milliseconds(ms)
436    }
437
438    fn entry(id: &str, deadline_ms: i64, priority: i32) -> DeadlineEntry {
439        let wl = Workload::new(id, id).with_priority(priority);
440        DeadlineEntry::from_tick(wl, at(deadline_ms), Duration::from_millis(50))
441    }
442
443    #[test]
444    fn pops_earliest_deadline_first() {
445        let q = DeadlineQueue::new(MissPolicy::Late);
446        // Enqueue out of deadline order.
447        q.enqueue(entry("c", 300, 0));
448        q.enqueue(entry("a", 100, 0));
449        q.enqueue(entry("b", 200, 0));
450
451        assert_eq!(q.pop_earliest().unwrap().workload.id, "a");
452        assert_eq!(q.pop_earliest().unwrap().workload.id, "b");
453        assert_eq!(q.pop_earliest().unwrap().workload.id, "c");
454    }
455
456    #[test]
457    fn equal_deadline_breaks_ties_by_weight_then_sequence() {
458        let q = DeadlineQueue::new(MissPolicy::Late);
459        // Same deadline; higher priority (weight) should pop first.
460        q.enqueue(entry("low", 100, 1));
461        q.enqueue(entry("high", 100, 500));
462        q.enqueue(entry("low2", 100, 1)); // ties low on weight; later sequence
463
464        assert_eq!(q.pop_earliest().unwrap().workload.id, "high");
465        // Between the two weight-1 entries, the earlier-enqueued ("low") wins.
466        assert_eq!(q.pop_earliest().unwrap().workload.id, "low");
467        assert_eq!(q.pop_earliest().unwrap().workload.id, "low2");
468    }
469
470    #[test]
471    fn eligibility_classifies_pending_eligible_late() {
472        // Deadline at t=200, tick=50 => eligible_time at t=150.
473        let e = entry("x", 200, 0);
474        assert_eq!(e.eligibility(at(100), MissPolicy::Late), Eligibility::Pending);
475        assert_eq!(e.eligibility(at(175), MissPolicy::Late), Eligibility::Eligible);
476        assert_eq!(e.eligibility(at(200), MissPolicy::Late), Eligibility::Eligible);
477        assert_eq!(e.eligibility(at(250), MissPolicy::Late), Eligibility::Late);
478    }
479
480    #[test]
481    fn lag_is_weighted_and_bounded() {
482        // priority 0 => weight 1; priority 3 => weight 4.
483        let e0 = entry("a", 200, 0); // eligible at 150
484        let e3 = entry("b", 200, 3);
485        // 50ms past eligible.
486        assert_eq!(e0.lag_ms(at(200)), 50);
487        assert_eq!(e3.lag_ms(at(200)), 200); // 50ms * weight 4
488        // Before eligible => negative.
489        assert!(e0.lag_ms(at(100)) < 0);
490    }
491
492    #[test]
493    fn pop_runnable_skips_pending_and_returns_eligible() {
494        let q = DeadlineQueue::new(MissPolicy::Late);
495        // "future" deadline at 1000 (eligible 950) is most-urgent by deadline?
496        // No: smaller deadline = more urgent. Make the pending one the smallest
497        // deadline so we prove pop_runnable skips it despite being heap-top.
498        q.enqueue(entry("pending", 100, 0)); // eligible 50
499        q.enqueue(entry("ready", 500, 0)); // eligible 450
500
501        // now = 460: "pending" deadline 100 < now (late, runnable);
502        // To make pending truly pending, push its eligibility into the future.
503        // Re-do with explicit times:
504        let q2 = DeadlineQueue::new(MissPolicy::Late);
505        let future = DeadlineEntry::from_tick(
506            Workload::new("future", "future"),
507            at(1000), // eligible 950
508            Duration::from_millis(50),
509        );
510        let ready = DeadlineEntry::from_tick(
511            Workload::new("ready", "ready"),
512            at(500), // eligible 450
513            Duration::from_millis(50),
514        );
515        q2.enqueue(future);
516        q2.enqueue(ready);
517
518        // now = 470: future is Pending (now < 950), ready is Eligible.
519        let (got, outcome) = q2.pop_runnable(at(470)).unwrap();
520        assert_eq!(got.workload.id, "ready");
521        assert_eq!(outcome, TickOutcome::Run);
522        // The pending "future" remains queued.
523        assert_eq!(q2.len(), 1);
524        assert_eq!(q2.peek_id().as_deref(), Some("future"));
525
526        // Silence unused warnings from the first scratch queue.
527        let _ = q.len();
528    }
529
530    #[test]
531    fn drop_policy_advances_past_missed_frames() {
532        let policy = MissPolicy::Drop;
533        // Deadline 200, tick 50, eligible 150. now = 380 => 180ms late => skipped
534        // frames; next boundary should be the first deadline >= 380.
535        let mut e = entry("d", 200, 0);
536        assert_eq!(e.outcome(at(380), policy), TickOutcome::DropFrame);
537        e.rearm(at(380), policy);
538        // behind = 180ms, tick = 50 => k = 180/50 + 1 = 4; 200 + 4*50 = 400.
539        assert!(e.virtual_deadline >= at(380));
540        assert_eq!((e.virtual_deadline - at(0)).num_milliseconds(), 400);
541        assert_eq!((e.eligible_time - at(0)).num_milliseconds(), 350);
542    }
543
544    #[test]
545    fn late_policy_advances_exactly_one_tick() {
546        let policy = MissPolicy::Late;
547        let mut e = entry("l", 200, 0);
548        assert_eq!(e.outcome(at(280), policy), TickOutcome::Run);
549        e.rearm(at(280), policy);
550        // Late: eligible <- old deadline (200); deadline <- 250.
551        assert_eq!((e.eligible_time - at(0)).num_milliseconds(), 200);
552        assert_eq!((e.virtual_deadline - at(0)).num_milliseconds(), 250);
553    }
554
555    #[test]
556    fn backpressure_holds_when_lag_exceeds_bound() {
557        let policy = MissPolicy::Backpressure {
558            max_lag: Duration::from_millis(100),
559        };
560        // Deadline 200; now = 500 => 300ms overrun > 100ms bound => backpressure.
561        let e = entry("bp", 200, 0);
562        assert_eq!(e.eligibility(at(500), policy), Eligibility::Backpressured);
563        assert_eq!(e.outcome(at(500), policy), TickOutcome::Hold);
564
565        // Within bound (250 => 50ms overrun) => just Late, runs.
566        assert_eq!(e.eligibility(at(250), policy), Eligibility::Late);
567        assert_eq!(e.outcome(at(250), policy), TickOutcome::Run);
568    }
569
570    #[test]
571    fn tick_deadline_scheduler_prefers_emptier_node_for_urgent() {
572        let sched = TickDeadlineScheduler::new(BinPackScheduler::new());
573        let urgent = Workload::new("u", "u").with_priority(900);
574
575        let mut empty = NodeResources::new(NodeId::new(), 4000, 8192);
576        let mut busy = NodeResources::new(NodeId::new(), 4000, 8192);
577        busy.cpu_allocated = 3000;
578        empty.cpu_allocated = 0;
579
580        let s_empty = sched.score(&urgent, &empty);
581        let s_busy = sched.score(&urgent, &busy);
582        // Urgency bonus rewards headroom => empty node scores higher overall for
583        // an urgent workload, overriding bin-pack's lean toward the busy node.
584        assert!(
585            s_empty > s_busy,
586            "urgent tick should prefer emptier node: empty={s_empty} busy={s_busy}"
587        );
588        assert_eq!(sched.name(), "tick-deadline");
589    }
590}