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}