Skip to main content

cronoq_core/
engine.rs

1//! The sans-IO scheduling engine.
2//!
3//! All methods take `now`; the engine never reads a clock or performs I/O. After each call the
4//! driver drains [`Core::poll_action`] and arranges to call [`Core::tick`] at
5//! [`Core::next_wakeup`].
6
7use core::cmp::Reverse;
8use core::time::Duration;
9use std::collections::{BTreeMap, BinaryHeap, VecDeque};
10use std::sync::Arc;
11
12use rustc_hash::{FxHashMap, FxHashSet};
13
14/// Maps and sets keyed by caller-supplied strings (task keys, kinds, schedule names) use std's
15/// randomly seeded SipHash: FxHash is unseeded, so crafted keys could all collide.
16type StrMap<V> = std::collections::HashMap<Arc<str>, V>;
17type StrSet = std::collections::HashSet<Arc<str>>;
18
19use crate::autoscale::{AutoscaleState, Signals};
20use crate::config::{CoreConfig, OverflowPolicy, PoolConfig, ResourceKind, UnknownDependency};
21use crate::io::*;
22use crate::lane::Lane;
23use crate::reservations::Reservations;
24use crate::rng::Rng;
25use crate::snapshot::{GroupSnapshot, PoolSnapshot, Snapshot};
26use crate::spec::*;
27use crate::stats::{DurStats, P90Stats, Window};
28use crate::time::{Time, secs};
29
30// ---------------------------------------------------------------------------------------------
31// Internal types
32// ---------------------------------------------------------------------------------------------
33
34#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
35struct Key {
36    idx: u32,
37    generation: u32,
38}
39
40#[derive(Copy, Clone, Debug, PartialEq, Eq)]
41enum Loc {
42    None,
43    Lane,
44    Edf,
45    Due,
46    Parked(ResourceId),
47    /// Waiting for a free slot in a worker group with the labels it requires.
48    LabelWait,
49    /// Queued in its class's fair queue (pools with tenant fairness).
50    Fair,
51}
52
53#[derive(Clone, Debug)]
54struct FixedInfo {
55    at: Time,
56    tolerance: Duration,
57    on_miss: MissPolicy,
58    /// Reservation interval start (for removal).
59    res_start: Time,
60    held: bool,
61    hold_pending: bool,
62    missed: bool,
63    /// Recurring overlap guard: skip if this task is still live at start.
64    skip_if_live: Option<TaskId>,
65}
66
67struct Entry<P> {
68    id: TaskId,
69    seq: u64,
70    arrival: Time,
71    payload: Arc<P>,
72    kind: u32,
73    pool: u16,
74    class: u8,
75    status: TaskStatus,
76    loc: Loc,
77    key: Option<Arc<str>>,
78    deadline: Time,
79    lst: Time,
80    hint_ns: u64,
81    timeout_ns: u64,
82    retry: Option<Box<RetryPolicy>>,
83    attempt: u32,
84    unmet: u32,
85    awaiting: u32,
86    /// Registered awaits on unknown keys (listed in `Core::awaited_by`, cleaned up at the end).
87    has_awaits: bool,
88    /// Counted in the pending bounds.
89    pending: bool,
90    lease: Option<LeaseId>,
91    retry_at: Time,
92    dependents: Vec<(TaskId, EdgeCond)>,
93    resources: Option<Box<[(ResourceId, u32)]>>,
94    tags: Option<Tags>,
95    fixed: Option<Box<FixedInfo>>,
96    /// Required worker labels, interned in `Core::constraints` (0 = none).
97    constraint: u32,
98    /// Tenant, interned in `Core::tenants` (0 = none; only set in pools with fairness).
99    tenant: u32,
100    /// Start tag in the class's fair queue (pools with fairness).
101    vtag: u64,
102}
103
104struct Slot<P> {
105    generation: u32,
106    entry: Option<Entry<P>>,
107}
108
109#[derive(Clone, Debug)]
110struct Lease {
111    key: Key,
112    id: TaskId,
113    pool: u16,
114    /// Worker group of the attempt.
115    group: u16,
116    started: Time,
117    expires: Time,
118}
119
120#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
121enum Timer {
122    Retry(TaskId),
123    HoldAt(TaskId),
124    StartAt(TaskId),
125    MissAt(TaskId),
126    AwaitTimeout(TaskId),
127    AttemptTimeout(LeaseId),
128    LeaseExpiry(LeaseId),
129    Refill(u16),
130    Autoscale(u16),
131    Recurring(Arc<str>, Time),
132}
133
134struct Resource {
135    name: Arc<str>,
136    kind: ResourceKind,
137    available: f64,
138    capacity: f64,
139    last_refill: Time,
140    waiters: VecDeque<Key>,
141    refill_at: Option<Time>,
142}
143
144impl Resource {
145    fn refill(&mut self, now: Time) {
146        if let ResourceKind::TokenBucket { rate, .. } = self.kind {
147            let dt = now.diff_secs(self.last_refill);
148            if dt > 0.0 && rate > 0.0 {
149                self.available = (self.available + rate * dt).min(self.capacity);
150            }
151        }
152        self.last_refill = self.last_refill.max(now);
153    }
154}
155
156struct Pool {
157    cfg: PoolConfig,
158    /// Sum of the groups' capacity.
159    capacity: u32,
160    /// Sum of the groups' busy slots.
161    busy: u32,
162    held: u32,
163    /// Worker groups by label set; group 0 is the unlabeled one.
164    groups: Vec<Group>,
165    /// Tasks waiting for a free slot in a group with the labels they require, by constraint.
166    label_waiters: BTreeMap<u32, VecDeque<Key>>,
167    /// Weighted fair queues, when the pool shares capacity between tenants.
168    fair: Option<Box<FairState>>,
169    lanes: [Lane<Key>; Priority::COUNT],
170    edf: [BTreeMap<(Time, u64), Key>; Priority::COUNT],
171    due: BTreeMap<(Time, u64), Key>,
172    pending_holds: BTreeMap<(Time, u64), Key>,
173    /// Fixed-start tasks currently holding a slot (at most the reservation limit).
174    held_tasks: BTreeMap<(Time, u64), Key>,
175    reservations: Reservations,
176    /// Reservations of fixed-start tasks that have not started yet (autoscaling signal).
177    unstarted: Reservations,
178    /// Tasks waiting for a retry backoff, per class.
179    retrying: [usize; Priority::COUNT],
180    pending: usize,
181    pending_class: [usize; Priority::COUNT],
182    autoscale: Option<AutoscaleState>,
183    arrivals: u64,
184    last_eval: Time,
185    stats: DurStats,
186    /// Runtimes of non-fixed work only (drives the hold lead for fixed starts).
187    adhoc_stats: P90Stats,
188    window: Window,
189    last_acct: Time,
190    worker_secs: f64,
191    cost: f64,
192    dirty: bool,
193}
194
195/// Start-time fair queuing per priority class: a task's start tag is the later of the class's
196/// virtual time and its tenant's previous finish tag; its finish tag adds `1e6 / weight`.
197/// Dispatching in tag order gives each backlogged tenant shares proportional to its weight, and
198/// an idle tenant cannot bank credit because its tags never fall behind the virtual time.
199struct FairState {
200    queues: [BTreeMap<(u64, u64), Key>; Priority::COUNT],
201    /// Start tag of the latest dispatched task, per class.
202    vtime: [u64; Priority::COUNT],
203    /// Latest finish tag per (class, tenant).
204    finish: FxHashMap<(u8, u32), u64>,
205    /// Tag increment per tenant (from its weight).
206    cost: FxHashMap<u32, u64>,
207    weights: StrMap<f64>,
208    default_weight: f64,
209}
210
211impl FairState {
212    fn new(cfg: &crate::config::Fairness) -> Self {
213        FairState {
214            queues: Default::default(),
215            vtime: [0; Priority::COUNT],
216            finish: FxHashMap::default(),
217            cost: FxHashMap::default(),
218            weights: cfg.weights.iter().cloned().collect(),
219            default_weight: cfg.default_weight,
220        }
221    }
222
223    fn len(&self) -> usize {
224        self.queues.iter().map(|q| q.len()).sum()
225    }
226}
227
228/// Workers of a pool that share one label set.
229struct Group {
230    labels: Labels,
231    capacity: u32,
232    busy: u32,
233}
234
235impl Group {
236    #[inline]
237    fn free(&self) -> u32 {
238        self.capacity.saturating_sub(self.busy)
239    }
240}
241
242/// Whether sorted `group` labels include every pair of sorted `need`.
243fn satisfies(group: &[(Arc<str>, Arc<str>)], need: &[(Arc<str>, Arc<str>)]) -> bool {
244    need.iter().all(|n| group.binary_search(n).is_ok())
245}
246
247/// Labels sorted and without duplicates.
248fn normalize(labels: &[(Arc<str>, Arc<str>)]) -> Labels {
249    let mut v = labels.to_vec();
250    v.sort();
251    v.dedup();
252    v.into()
253}
254
255impl Pool {
256    #[inline]
257    fn free(&self) -> u32 {
258        self.capacity.saturating_sub(self.busy + self.held)
259    }
260    fn account(&mut self, now: Time) {
261        if self.last_acct == Time::ZERO {
262            // First observation: engine time may start anywhere (e.g. Unix nanoseconds).
263            self.last_acct = now;
264            return;
265        }
266        let dt = now.diff_secs(self.last_acct);
267        if dt <= 0.0 {
268            return;
269        }
270        self.last_acct = now;
271        let cap = self.capacity as f64;
272        let busy = self.busy as f64;
273        let b = self.window.at(now);
274        b.busy_secs += busy * dt;
275        b.capacity_secs += cap * dt;
276        self.worker_secs += cap * dt;
277        if let Some(a) = &self.cfg.autoscale {
278            self.cost += cap * dt * a.cost_per_worker_second;
279        }
280    }
281    fn ready_len(&self) -> usize {
282        self.lanes.iter().map(|l| l.len()).sum::<usize>()
283            + self.edf.iter().map(|l| l.len()).sum::<usize>()
284            + self.due.len()
285            + self.fair.as_ref().map_or(0, |f| f.len())
286    }
287}
288
289struct Recurring<P> {
290    spec: RecurringSpec<P>,
291    cron: croner::Cron,
292    tz: chrono_tz::Tz,
293    next: Option<Time>,
294    last_task: Option<TaskId>,
295    /// Latest occurrence already turned into a task.
296    materialized: Option<Time>,
297    /// Task the next occurrence of a queued schedule depends on; reset when the schedule is
298    /// added again.
299    chain_last: Option<TaskId>,
300}
301
302enum Parent {
303    Live(TaskId),
304    Done(TaskStatus),
305    Await(Arc<str>),
306}
307
308/// Engine-wide totals since start.
309#[derive(Clone, Debug, Default)]
310pub struct Totals {
311    /// Tasks accepted.
312    pub submitted: u64,
313    /// Attempts started.
314    pub dispatched: u64,
315    /// Tasks that succeeded.
316    pub succeeded: u64,
317    /// Tasks that failed permanently.
318    pub failed: u64,
319    /// Tasks that exhausted their retries.
320    pub dead_lettered: u64,
321    /// Tasks cancelled.
322    pub cancelled: u64,
323    /// Tasks skipped because a dependency condition could not be met.
324    pub skipped: u64,
325    /// Fixed-start tasks that missed their tolerance.
326    pub missed: u64,
327    /// Tasks evicted under overload.
328    pub shed: u64,
329    /// Retries scheduled.
330    pub retries: u64,
331    /// Attempts aborted for exceeding their timeout.
332    pub timeouts: u64,
333    /// Attempts aborted because their lease expired.
334    pub lease_expiries: u64,
335    /// Recurring occurrences not created because the reservation limit was reached.
336    pub recurring_conflicts: u64,
337    /// Recovered recurring schedules not armed because the configuration no longer supports
338    /// their template (a removed pool or resource).
339    pub recurring_invalid: u64,
340    /// Completions reported for leases that are no longer valid (late, duplicate, or from an
341    /// earlier leader); they were ignored.
342    pub stale_completions: u64,
343}
344
345// ---------------------------------------------------------------------------------------------
346// Core
347// ---------------------------------------------------------------------------------------------
348
349/// The scheduling engine. See the crate docs.
350pub struct Core<P> {
351    cfg: CoreConfig,
352    pools: Vec<Pool>,
353    pool_index: FxHashMap<Arc<str>, u16>,
354    resources: Vec<Resource>,
355    resource_index: FxHashMap<Arc<str>, u16>,
356    slots: Vec<Slot<P>>,
357    free_slots: Vec<u32>,
358    index: FxHashMap<TaskId, Key>,
359    keys: StrMap<TaskId>,
360    awaits: StrMap<Vec<(TaskId, EdgeCond)>>,
361    retained: FxHashMap<TaskId, (TaskStatus, Time)>,
362    retained_keys: StrMap<TaskId>,
363    retention_q: VecDeque<(Time, TaskId, Option<Arc<str>>)>,
364    leases: FxHashMap<LeaseId, Lease>,
365    timers: BinaryHeap<Reverse<(Time, u64, Timer)>>,
366    timer_seq: u64,
367    kinds: Vec<Arc<str>>,
368    kind_index: StrMap<u32>,
369    /// Interned tenant names of tasks in pools with fairness; index 0 is "none".
370    tenants: Vec<Arc<str>>,
371    tenant_index: StrMap<u32>,
372    /// Interned label requirements of tasks; index 0 is "none".
373    constraints: Vec<Labels>,
374    constraint_index: std::collections::HashMap<Labels, u32>,
375    dur: FxHashMap<(u16, u32), DurStats>,
376    recurring: BTreeMap<Arc<str>, Recurring<P>>,
377    actions: VecDeque<Action<P>>,
378    rng: Rng,
379    next_id: u64,
380    next_seq: u64,
381    next_lease: u64,
382    now: Time,
383    dirty: Vec<u16>,
384    total_pending: usize,
385    pending_class: [usize; Priority::COUNT],
386    space_waiters: bool,
387    emit_transitions: bool,
388    totals: Totals,
389    empty_tags: Tags,
390    /// Keys of tasks being recovered (only set while `recover` runs).
391    recovering_keys: Option<StrSet>,
392    /// Overlap guard and materialization state of removed recurring schedules, so re-adding
393    /// one does not duplicate an occurrence that already exists.
394    recurring_history: StrMap<(Option<TaskId>, Option<Time>)>,
395    /// Whether time-based accounting has been anchored to the first observed `now`.
396    started: bool,
397    /// Unknown keys each awaiting task registered for, so they can be cleaned up when it ends.
398    awaited_by: FxHashMap<TaskId, Box<[Arc<str>]>>,
399}
400
401impl<P> Core<P> {
402    /// Create an engine. Structural errors (no pools, duplicate names, `min_workers >
403    /// max_workers`) panic; use [`CoreConfig::validate`] first to report them gracefully.
404    /// Out-of-range numeric settings are clamped to safe values.
405    pub fn new(cfg: CoreConfig) -> Self {
406        let cfg = cfg.sanitized();
407        assert!(!cfg.pools.is_empty(), "at least one pool is required");
408        assert!(cfg.pools.len() <= u16::MAX as usize && cfg.resources.len() <= u16::MAX as usize);
409        let mut pool_index = FxHashMap::default();
410        let mut pools = Vec::new();
411        let mut timers = BinaryHeap::new();
412        for (i, pc) in cfg.pools.iter().enumerate() {
413            assert!(
414                pool_index.insert(pc.name.clone(), i as u16).is_none(),
415                "duplicate pool name"
416            );
417            assert!(
418                pc.min_workers <= pc.max_workers,
419                "min_workers > max_workers"
420            );
421            // `min_workers` bounds the autoscaler only; capacity may start below it.
422            let initial = pc.initial_workers.min(pc.max_workers);
423            if let Some(a) = &pc.autoscale {
424                timers.push(Reverse((
425                    Time::ZERO + a.interval,
426                    i as u64,
427                    Timer::Autoscale(i as u16),
428                )));
429            }
430            pools.push(Pool {
431                cfg: pc.clone(),
432                capacity: initial,
433                busy: 0,
434                held: 0,
435                groups: vec![Group {
436                    labels: Arc::from(Vec::new()),
437                    capacity: initial,
438                    busy: 0,
439                }],
440                label_waiters: BTreeMap::new(),
441                fair: pc.fairness.as_ref().map(|f| Box::new(FairState::new(f))),
442                lanes: Default::default(),
443                edf: Default::default(),
444                due: BTreeMap::new(),
445                pending_holds: BTreeMap::new(),
446                held_tasks: BTreeMap::new(),
447                reservations: Reservations::default(),
448                unstarted: Reservations::default(),
449                retrying: [0; Priority::COUNT],
450                pending: 0,
451                pending_class: [0; Priority::COUNT],
452                autoscale: pc.autoscale.as_ref().map(|_| AutoscaleState::default()),
453                arrivals: 0,
454                last_eval: Time::ZERO,
455                stats: DurStats::default(),
456                adhoc_stats: P90Stats::default(),
457                window: Window::new(cfg.metrics_window),
458                last_acct: Time::ZERO,
459                worker_secs: 0.0,
460                cost: 0.0,
461                dirty: false,
462            });
463        }
464        let mut resource_index = FxHashMap::default();
465        let resources = cfg
466            .resources
467            .iter()
468            .enumerate()
469            .map(|(i, rc)| {
470                assert!(
471                    resource_index.insert(rc.name.clone(), i as u16).is_none(),
472                    "duplicate resource name"
473                );
474                let cap = match rc.kind {
475                    ResourceKind::Semaphore { permits } => permits as f64,
476                    ResourceKind::TokenBucket { burst, .. } => burst as f64,
477                };
478                Resource {
479                    name: rc.name.clone(),
480                    kind: rc.kind.clone(),
481                    available: cap,
482                    capacity: cap,
483                    last_refill: Time::ZERO,
484                    waiters: VecDeque::new(),
485                    refill_at: None,
486                }
487            })
488            .collect();
489        let timer_seq = pools.len() as u64;
490        let id_epoch = cfg.id_epoch as u64;
491        Core {
492            rng: Rng::new(cfg.seed),
493            next_id: match cfg.id_shard {
494                Some(s) => TaskId::first_in_shard(s, cfg.id_epoch).0,
495                None => id_epoch << 48 | 1,
496            },
497            cfg,
498            pools,
499            pool_index,
500            resources,
501            resource_index,
502            slots: Vec::new(),
503            free_slots: Vec::new(),
504            index: FxHashMap::default(),
505            keys: StrMap::default(),
506            awaits: StrMap::default(),
507            retained: FxHashMap::default(),
508            retained_keys: StrMap::default(),
509            retention_q: VecDeque::new(),
510            leases: FxHashMap::default(),
511            timers,
512            timer_seq,
513            kinds: Vec::new(),
514            kind_index: StrMap::default(),
515            tenants: vec![Arc::from("")],
516            tenant_index: StrMap::default(),
517            constraints: vec![Arc::from(Vec::new())],
518            constraint_index: std::collections::HashMap::new(),
519            dur: FxHashMap::default(),
520            recurring: BTreeMap::new(),
521            actions: VecDeque::new(),
522            next_seq: 0,
523            next_lease: id_epoch << 48 | 1,
524            now: Time::ZERO,
525            dirty: Vec::new(),
526            total_pending: 0,
527            pending_class: [0; Priority::COUNT],
528            space_waiters: false,
529            emit_transitions: false,
530            totals: Totals::default(),
531            empty_tags: Arc::from(Vec::new()),
532            recovering_keys: None,
533            recurring_history: StrMap::default(),
534            started: false,
535            awaited_by: FxHashMap::default(),
536        }
537    }
538
539    /// Emit `Action::Status` for every transition, not only terminal ones.
540    pub fn set_emit_transitions(&mut self, on: bool) {
541        self.emit_transitions = on;
542    }
543
544    /// The (sanitized) configuration the engine runs with.
545    pub fn config(&self) -> &CoreConfig {
546        &self.cfg
547    }
548
549    /// Id of the pool named `name`.
550    pub fn pool_id(&self, name: &str) -> Option<PoolId> {
551        self.pool_index.get(name).map(|&i| PoolId(i))
552    }
553
554    /// Name of `pool`. Panics if `pool` is not from this engine.
555    pub fn pool_name(&self, pool: PoolId) -> &Arc<str> {
556        &self.pools[pool.0 as usize].cfg.name
557    }
558
559    /// Current worker capacity of `pool`. Panics if `pool` is not from this engine.
560    pub fn pool_capacity(&self, pool: PoolId) -> u32 {
561        self.pools[pool.0 as usize].capacity
562    }
563
564    /// Counters since start.
565    pub fn totals(&self) -> &Totals {
566        &self.totals
567    }
568
569    /// Next action for the driver.
570    #[inline]
571    pub fn poll_action(&mut self) -> Option<Action<P>> {
572        self.actions.pop_front()
573    }
574
575    /// Move all pending actions into `out`.
576    pub fn drain_actions(&mut self, out: &mut Vec<Action<P>>) {
577        out.extend(self.actions.drain(..));
578    }
579
580    /// Whether actions are waiting to be polled.
581    pub fn has_actions(&self) -> bool {
582        !self.actions.is_empty()
583    }
584
585    /// Earliest time at which `tick` has work to do.
586    pub fn next_wakeup(&self) -> Option<Time> {
587        self.timers.peek().map(|Reverse((t, _, _))| *t)
588    }
589
590    /// Advance time: fire due timers and dispatch.
591    pub fn tick(&mut self, now: Time) {
592        self.advance(now);
593        self.pump();
594    }
595
596    // -----------------------------------------------------------------------------------------
597    // Submission
598    // -----------------------------------------------------------------------------------------
599
600    /// Submit one task.
601    pub fn submit(&mut self, now: Time, spec: TaskSpec<P>) -> Result<Submitted, SubmitError> {
602        self.try_submit(now, spec).map_err(|(e, _)| e)
603    }
604
605    /// Like [`Core::submit`], but hands the spec back on rejection (e.g. to retry after
606    /// `SubmitError::Full`).
607    #[allow(clippy::result_large_err)] // returning the spec by value avoids an allocation
608    pub fn try_submit(
609        &mut self,
610        now: Time,
611        spec: TaskSpec<P>,
612    ) -> Result<Submitted, (SubmitError, TaskSpec<P>)> {
613        self.advance(now);
614        if let Some(id) = self.existing_key(spec.key.as_deref()) {
615            return Ok(Submitted {
616                id,
617                duplicate: true,
618            });
619        }
620        let pool = match self.validate(&spec, &StrSet::default()) {
621            Ok(p) => p,
622            Err(e) => return Err((e, spec)),
623        };
624        // Reservations are checked before bounds, so a submission that will be rejected anyway
625        // never sheds other work first.
626        if let Some(f) = &spec.fixed_start {
627            let (s, e) = self.reservation_interval(pool, &spec, f);
628            if self.pools[pool as usize].reservations.max_overlap(s, e)
629                >= self.pools[pool as usize].cfg.reservation_limit()
630            {
631                return Err((SubmitError::ReservationConflict, spec));
632            }
633        }
634        if let Err(e) = self.check_bounds(pool, spec.priority, 1, true) {
635            return Err((e, spec));
636        }
637        let fixed = spec.fixed_start.is_some();
638        let id = self.alloc_id();
639        let seq = self.alloc_seq();
640        self.insert(now, id, seq, now, spec, pool, 0, None);
641        if fixed {
642            // A fixed start already past its window applies its miss policy before dispatch.
643            self.advance(now);
644        }
645        self.pump();
646        Ok(Submitted {
647            id,
648            duplicate: false,
649        })
650    }
651
652    /// Submit several tasks atomically. Dependencies may reference keys defined in the batch
653    /// (in any order). Either all are admitted or none.
654    pub fn submit_batch(
655        &mut self,
656        now: Time,
657        specs: Vec<TaskSpec<P>>,
658    ) -> Result<Vec<Submitted>, (usize, SubmitError)> {
659        self.advance(now);
660        // Keys defined in the batch.
661        let mut batch_keys: StrMap<usize> = StrMap::default();
662        for (i, s) in specs.iter().enumerate() {
663            if let Some(k) = &s.key
664                && self.existing_key(Some(k)).is_none()
665                && batch_keys.insert(k.clone(), i).is_some()
666            {
667                return Err((i, SubmitError::DuplicateKeyInBatch(k.clone())));
668            }
669        }
670        let key_set: StrSet = batch_keys.keys().cloned().collect();
671        let mut pools = Vec::with_capacity(specs.len());
672        for (i, s) in specs.iter().enumerate() {
673            if self.existing_key(s.key.as_deref()).is_some() {
674                pools.push(u16::MAX);
675                continue;
676            }
677            pools.push(self.validate(s, &key_set).map_err(|e| (i, e))?);
678        }
679        // Topological order over in-batch key dependencies (Kahn).
680        let n = specs.len();
681        let mut indeg = vec![0u32; n];
682        let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
683        for (i, s) in specs.iter().enumerate() {
684            if pools[i] == u16::MAX {
685                continue;
686            }
687            for d in &s.deps {
688                if let TaskRef::Key(k) = &d.target
689                    && let Some(&j) = batch_keys.get(k)
690                {
691                    if self.keys.contains_key(k) || self.retained_keys.contains_key(k) {
692                        continue;
693                    }
694                    indeg[i] += 1;
695                    children[j].push(i);
696                }
697            }
698        }
699        let mut order: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
700        let mut h = 0;
701        while h < order.len() {
702            let j = order[h];
703            h += 1;
704            for &c in &children[j] {
705                indeg[c] -= 1;
706                if indeg[c] == 0 {
707                    order.push(c);
708                }
709            }
710        }
711        if order.len() != n {
712            let bad = (0..n).find(|&i| indeg[i] > 0).unwrap_or(0);
713            return Err((bad, SubmitError::Cycle));
714        }
715        // Bounds per pool/class.
716        let mut counts: FxHashMap<(u16, Priority), usize> = FxHashMap::default();
717        for (i, s) in specs.iter().enumerate() {
718            if pools[i] != u16::MAX {
719                *counts.entry((pools[i], s.priority)).or_default() += 1;
720            }
721        }
722        if let Some(i) = self.batch_cycle(&specs, &pools, &batch_keys) {
723            return Err((i, SubmitError::Cycle));
724        }
725        self.check_batch_bounds(&counts).map_err(|e| (0, e))?;
726        // Reservations: tentative insertion so batch members see each other.
727        let mut tentative = Vec::new();
728        let mut conflict = None;
729        for (i, s) in specs.iter().enumerate() {
730            if pools[i] == u16::MAX {
731                continue;
732            }
733            if let Some(f) = &s.fixed_start {
734                let (st, en) = self.reservation_interval(pools[i], s, f);
735                let pool = &mut self.pools[pools[i] as usize];
736                if pool.reservations.max_overlap(st, en) >= pool.cfg.reservation_limit() {
737                    conflict = Some(i);
738                    break;
739                }
740                let tid = TaskId(u64::MAX - i as u64);
741                pool.reservations.insert(tid, st, en);
742                tentative.push((pools[i], tid, st));
743            }
744        }
745        for (p, tid, st) in tentative {
746            self.pools[p as usize].reservations.remove(tid, st);
747        }
748        if let Some(i) = conflict {
749            return Err((i, SubmitError::ReservationConflict));
750        }
751        // Commit in topological order.
752        let mut out = vec![
753            Submitted {
754                id: TaskId(0),
755                duplicate: false
756            };
757            n
758        ];
759        let mut specs: Vec<Option<TaskSpec<P>>> = specs.into_iter().map(Some).collect();
760        for i in order {
761            let spec = specs[i].take().expect("each index once");
762            if pools[i] == u16::MAX {
763                let id = self.existing_key(spec.key.as_deref()).expect("checked");
764                out[i] = Submitted {
765                    id,
766                    duplicate: true,
767                };
768                continue;
769            }
770            let id = self.alloc_id();
771            let seq = self.alloc_seq();
772            self.insert(now, id, seq, now, spec, pools[i], 0, None);
773            out[i] = Submitted {
774                id,
775                duplicate: false,
776            };
777        }
778        self.advance(now);
779        self.pump();
780        Ok(out)
781    }
782
783    /// Find a dependency cycle through batch members, including cycles closed by existing
784    /// tasks that await a key the batch defines. Returns a batch index on the cycle.
785    fn batch_cycle(
786        &self,
787        specs: &[TaskSpec<P>],
788        pools: &[u16],
789        batch_keys: &StrMap<usize>,
790    ) -> Option<usize> {
791        #[derive(Copy, Clone, PartialEq, Eq, Hash)]
792        enum N {
793            B(usize),
794            L(TaskId),
795        }
796        let n = specs.len();
797        let mut batch_children: Vec<Vec<usize>> = vec![Vec::new(); n];
798        let mut live_children: FxHashMap<TaskId, Vec<usize>> = FxHashMap::default();
799        for (i, s) in specs.iter().enumerate() {
800            if pools[i] == u16::MAX {
801                continue;
802            }
803            for d in &s.deps {
804                match &d.target {
805                    TaskRef::Key(k) => {
806                        if let Some(&id) = self.keys.get(k) {
807                            live_children.entry(id).or_default().push(i);
808                        } else if let Some(&j) = batch_keys.get(k) {
809                            batch_children[j].push(i);
810                        }
811                    }
812                    TaskRef::Id(id) => {
813                        if self.index.contains_key(id) {
814                            live_children.entry(*id).or_default().push(i);
815                        }
816                    }
817                }
818            }
819        }
820        let succ = |node: N| -> Vec<N> {
821            let mut out = Vec::new();
822            match node {
823                N::B(j) => {
824                    out.extend(batch_children[j].iter().map(|&i| N::B(i)));
825                    if let Some(k) = &specs[j].key
826                        && let Some(w) = self.awaits.get(k)
827                    {
828                        out.extend(
829                            w.iter()
830                                .filter(|(id, _)| self.index.contains_key(id))
831                                .map(|(id, _)| N::L(*id)),
832                        );
833                    }
834                }
835                N::L(id) => {
836                    if let Some(e) = self.entry_by_id(id) {
837                        out.extend(e.dependents.iter().map(|d| N::L(d.0)));
838                        if let Some(k) = &e.key
839                            && let Some(w) = self.awaits.get(k)
840                        {
841                            out.extend(w.iter().map(|(id, _)| N::L(*id)));
842                        }
843                    }
844                    if let Some(c) = live_children.get(&id) {
845                        out.extend(c.iter().map(|&i| N::B(i)));
846                    }
847                }
848            }
849            out
850        };
851        // Iterative three-colour DFS.
852        let mut color: FxHashMap<N, u8> = FxHashMap::default(); // 1 = on stack, 2 = done
853        for start in (0..n).filter(|&i| pools[i] != u16::MAX) {
854            if color.contains_key(&N::B(start)) {
855                continue;
856            }
857            let mut stack: Vec<(N, Vec<N>)> = vec![(N::B(start), succ(N::B(start)))];
858            color.insert(N::B(start), 1);
859            while let Some((node, children)) = stack.last_mut() {
860                if let Some(next) = children.pop() {
861                    match color.get(&next) {
862                        Some(1) => {
863                            return Some(match next {
864                                N::B(i) => i,
865                                N::L(_) => match *node {
866                                    N::B(i) => i,
867                                    N::L(_) => start,
868                                },
869                            });
870                        }
871                        Some(_) => {}
872                        None => {
873                            color.insert(next, 1);
874                            let c = succ(next);
875                            stack.push((next, c));
876                        }
877                    }
878                } else {
879                    color.insert(*node, 2);
880                    stack.pop();
881                }
882            }
883        }
884        None
885    }
886
887    fn existing_key(&self, key: Option<&str>) -> Option<TaskId> {
888        let k = key?;
889        self.keys
890            .get(k)
891            .or_else(|| self.retained_keys.get(k))
892            .copied()
893    }
894
895    fn alloc_id(&mut self) -> TaskId {
896        let id = TaskId(self.next_id);
897        self.next_id += 1;
898        id
899    }
900
901    fn alloc_seq(&mut self) -> u64 {
902        let s = self.next_seq;
903        self.next_seq += 1;
904        s
905    }
906
907    /// Static validation; returns the pool index.
908    fn validate(&self, spec: &TaskSpec<P>, batch_keys: &StrSet) -> Result<u16, SubmitError> {
909        let pool = self.check_config(spec)?;
910        self.check_deps(spec, pool, batch_keys)?;
911        Ok(pool)
912    }
913
914    /// The checks that depend only on the configuration (pool, resources, retry policy);
915    /// returns the pool index. `insert` relies on them, so recurring templates pass them too.
916    fn check_config(&self, spec: &TaskSpec<P>) -> Result<u16, SubmitError> {
917        let pool = *self
918            .pool_index
919            .get(&spec.pool)
920            .ok_or_else(|| SubmitError::UnknownPool(spec.pool.clone()))?;
921        for (name, amount) in &spec.resources {
922            let r = *self
923                .resource_index
924                .get(name)
925                .ok_or_else(|| SubmitError::UnknownResource(name.clone()))?;
926            if *amount as f64 > self.resources[r as usize].capacity {
927                return Err(SubmitError::ResourceExceedsCapacity(name.clone()));
928            }
929            if *amount == 0 {
930                return Err(SubmitError::Invalid("resource amount must be > 0"));
931            }
932        }
933        if let Some(r) = &spec.retry
934            && r.max_attempts == 0
935        {
936            return Err(SubmitError::Invalid("retry.max_attempts must be >= 1"));
937        }
938        Ok(pool)
939    }
940
941    fn check_deps(
942        &self,
943        spec: &TaskSpec<P>,
944        pool: u16,
945        batch_keys: &StrSet,
946    ) -> Result<(), SubmitError> {
947        let await_ok = matches!(
948            self.pools[pool as usize].cfg.unknown_dependency,
949            UnknownDependency::Await { .. }
950        );
951        let mut live_parents: FxHashSet<TaskId> = FxHashSet::default();
952        for d in &spec.deps {
953            match &d.target {
954                TaskRef::Id(id) => {
955                    if self.index.contains_key(id) {
956                        live_parents.insert(*id);
957                    } else if !self.retained.contains_key(id) {
958                        return Err(SubmitError::UnknownDependency(id.to_string().into()));
959                    }
960                }
961                TaskRef::Key(k) => {
962                    if spec.key.as_ref() == Some(k) {
963                        return Err(SubmitError::Cycle);
964                    }
965                    if let Some(id) = self.keys.get(k) {
966                        live_parents.insert(*id);
967                    } else if !self.retained_keys.contains_key(k)
968                        && !batch_keys.contains(k)
969                        && !await_ok
970                    {
971                        return Err(SubmitError::UnknownDependency(k.clone()));
972                    }
973                }
974            }
975        }
976        // A cycle needs this task's key to be awaited by a descendant of one of its parents.
977        if let Some(k) = &spec.key
978            && let Some(waiting) = self.awaits.get(k)
979            && !live_parents.is_empty()
980            && self.reaches_any(waiting.iter().map(|w| w.0), &live_parents)
981        {
982            return Err(SubmitError::Cycle);
983        }
984        Ok(())
985    }
986
987    fn reaches_any(
988        &self,
989        start: impl Iterator<Item = TaskId>,
990        targets: &FxHashSet<TaskId>,
991    ) -> bool {
992        let mut stack: Vec<TaskId> = start.collect();
993        let mut seen: FxHashSet<TaskId> = FxHashSet::default();
994        while let Some(id) = stack.pop() {
995            if targets.contains(&id) {
996                return true;
997            }
998            if !seen.insert(id) {
999                continue;
1000            }
1001            if let Some(e) = self.entry_by_id(id) {
1002                stack.extend(e.dependents.iter().map(|d| d.0));
1003                if let Some(k) = &e.key
1004                    && let Some(w) = self.awaits.get(k)
1005                {
1006                    stack.extend(w.iter().map(|d| d.0));
1007                }
1008            }
1009        }
1010        false
1011    }
1012
1013    fn check_bounds(
1014        &mut self,
1015        pool: u16,
1016        class: Priority,
1017        n: usize,
1018        allow_shed: bool,
1019    ) -> Result<(), SubmitError> {
1020        let c = class.index();
1021        loop {
1022            let p = &self.pools[pool as usize];
1023            let over = |cur: usize, lim: Option<usize>| lim.is_some_and(|l| cur + n > l);
1024            // Bounds on the incoming task's own class can only be relieved by evicting tasks of
1025            // that same class; totals can be relieved by evicting any task of this pool.
1026            let class_full = over(
1027                self.pending_class[c],
1028                self.cfg.global_bounds.max_pending_per_class[c],
1029            ) || over(p.pending_class[c], p.cfg.bounds.max_pending_per_class[c]);
1030            let total_full = over(self.total_pending, self.cfg.global_bounds.max_pending)
1031                || over(p.pending, p.cfg.bounds.max_pending);
1032            if !class_full && !total_full {
1033                return Ok(());
1034            }
1035            let victim = match (p.cfg.overflow, allow_shed) {
1036                (OverflowPolicy::Wait, _) => {
1037                    self.space_waiters = true;
1038                    return Err(SubmitError::Full);
1039                }
1040                (OverflowPolicy::Reject, _) | (_, false) => return Err(SubmitError::Overloaded),
1041                // Victims are strictly lower classes, so they never relieve a class bound.
1042                (OverflowPolicy::ShedLowest, true) if class_full => None,
1043                (OverflowPolicy::ShedLowest, true) => (c + 1..Priority::COUNT)
1044                    .rev()
1045                    .find_map(|vc| self.shed_victim(pool, vc, true)),
1046                (OverflowPolicy::ShedOldest, true) => {
1047                    let low = Priority::Low.index();
1048                    if class_full && c != low {
1049                        None
1050                    } else {
1051                        self.shed_victim(pool, low, false)
1052                    }
1053                }
1054            };
1055            match victim {
1056                Some(k) => self.finish(k, TaskStatus::Shed, Some("shed under overload".into())),
1057                None => return Err(SubmitError::Overloaded),
1058            }
1059        }
1060    }
1061
1062    /// The newest (or oldest) waiting task of `class` in `pool` that may be shed: ready tasks
1063    /// in lanes and deadline queues, and tasks parked on resources. Fixed-start tasks are never
1064    /// shed: they hold capacity reservations that were promised at admission.
1065    fn shed_victim(&self, pool: u16, class: usize, newest: bool) -> Option<Key> {
1066        let p = &self.pools[pool as usize];
1067        let seq = |k: &Key| self.entry(*k).seq;
1068        let lane = if newest {
1069            p.lanes[class].last()
1070        } else {
1071            p.lanes[class].first()
1072        };
1073        let edf = p.edf[class].values();
1074        let edf = if newest {
1075            edf.max_by_key(|k| seq(k))
1076        } else {
1077            edf.min_by_key(|k| seq(k))
1078        };
1079        let parked = self
1080            .resources
1081            .iter()
1082            .flat_map(|r| r.waiters.iter())
1083            .filter(|k| {
1084                self.slot_entry(**k).is_some_and(|e| {
1085                    matches!(e.loc, Loc::Parked(_)) && e.pool == pool && e.class as usize == class
1086                })
1087            });
1088        let parked = if newest {
1089            parked.max_by_key(|k| seq(k))
1090        } else {
1091            parked.min_by_key(|k| seq(k))
1092        };
1093        let waiting = p.label_waiters.values().flatten().filter(|k| {
1094            self.slot_entry(**k)
1095                .is_some_and(|e| e.loc == Loc::LabelWait && e.class as usize == class)
1096        });
1097        let waiting = if newest {
1098            waiting.max_by_key(|k| seq(k))
1099        } else {
1100            waiting.min_by_key(|k| seq(k))
1101        };
1102        let fair = p.fair.as_ref().and_then(|f| {
1103            let q = f.queues[class].values();
1104            if newest {
1105                q.max_by_key(|k| seq(k))
1106            } else {
1107                q.min_by_key(|k| seq(k))
1108            }
1109        });
1110        let candidates = [
1111            lane,
1112            edf.copied(),
1113            parked.copied(),
1114            waiting.copied(),
1115            fair.copied(),
1116        ]
1117        .into_iter()
1118        .flatten();
1119        if newest {
1120            candidates.max_by_key(|k| seq(k))
1121        } else {
1122            candidates.min_by_key(|k| seq(k))
1123        }
1124    }
1125
1126    /// Bounds for a whole batch: per pool and class, then summed globally.
1127    fn check_batch_bounds(
1128        &mut self,
1129        counts: &FxHashMap<(u16, Priority), usize>,
1130    ) -> Result<(), SubmitError> {
1131        for (&(p, c), &k) in counts {
1132            self.check_bounds(p, c, k, false)?;
1133        }
1134        let over = |cur: usize, n: usize, lim: Option<usize>| lim.is_some_and(|l| cur + n > l);
1135        let total: usize = counts.values().sum();
1136        let mut full = over(
1137            self.total_pending,
1138            total,
1139            self.cfg.global_bounds.max_pending,
1140        );
1141        let mut per_pool: FxHashMap<u16, usize> = FxHashMap::default();
1142        for c in Priority::ALL {
1143            let n: usize = counts
1144                .iter()
1145                .filter(|((_, pc), _)| *pc == c)
1146                .map(|(_, k)| k)
1147                .sum();
1148            full |= over(
1149                self.pending_class[c.index()],
1150                n,
1151                self.cfg.global_bounds.max_pending_per_class[c.index()],
1152            );
1153        }
1154        for (&(p, _), &k) in counts {
1155            *per_pool.entry(p).or_default() += k;
1156        }
1157        for (&p, &k) in &per_pool {
1158            let pool = &self.pools[p as usize];
1159            full |= over(pool.pending, k, pool.cfg.bounds.max_pending);
1160        }
1161        if !full {
1162            return Ok(());
1163        }
1164        let wait = per_pool
1165            .keys()
1166            .any(|p| self.pools[*p as usize].cfg.overflow == OverflowPolicy::Wait);
1167        if wait {
1168            self.space_waiters = true;
1169            Err(SubmitError::Full)
1170        } else {
1171            Err(SubmitError::Overloaded)
1172        }
1173    }
1174
1175    fn reservation_interval(&self, pool: u16, spec: &TaskSpec<P>, f: &FixedStart) -> (Time, Time) {
1176        let kind = self.kind_index.get(&spec.kind).copied();
1177        let d = self.estimate_raw(pool, kind, spec.duration_hint.map(nanos).unwrap_or(0));
1178        (f.at - f.tolerance, f.at + d + f.tolerance)
1179    }
1180
1181    fn intern_tenant(&mut self, t: &Arc<str>) -> u32 {
1182        if let Some(&i) = self.tenant_index.get(t) {
1183            return i;
1184        }
1185        let i = self.tenants.len() as u32;
1186        self.tenants.push(t.clone());
1187        self.tenant_index.insert(t.clone(), i);
1188        i
1189    }
1190
1191    /// Next start tag for a task of `tenant` in `class` of a fair pool.
1192    fn fair_tag(&mut self, pool: u16, class: usize, tenant: u32) -> u64 {
1193        let name = self.tenants[tenant as usize].clone();
1194        let f = self.pools[pool as usize].fair.as_mut().unwrap();
1195        let cost = *f.cost.entry(tenant).or_insert_with(|| {
1196            let w = f.weights.get(&name).copied().unwrap_or(f.default_weight);
1197            ((1e6 / w).round() as u64).max(1)
1198        });
1199        let start = f.vtime[class].max(f.finish.get(&(class as u8, tenant)).copied().unwrap_or(0));
1200        f.finish.insert((class as u8, tenant), start + cost);
1201        if f.finish.len() > 4096 {
1202            // Tags at or behind the virtual time carry no state: forget them.
1203            let vtime = f.vtime;
1204            f.finish.retain(|(c, _), fin| *fin > vtime[*c as usize]);
1205        }
1206        start
1207    }
1208
1209    fn intern_constraint(&mut self, labels: &[(Arc<str>, Arc<str>)]) -> u32 {
1210        if labels.is_empty() {
1211            return 0;
1212        }
1213        let labels = normalize(labels);
1214        if let Some(&c) = self.constraint_index.get(&labels) {
1215            return c;
1216        }
1217        let c = self.constraints.len() as u32;
1218        self.constraints.push(labels.clone());
1219        self.constraint_index.insert(labels, c);
1220        c
1221    }
1222
1223    /// A group of `pool` with a free slot whose labels satisfy `constraint`: the least
1224    /// specialized one (fewest labels), then the one with the most free slots, so groups with
1225    /// rare labels stay available for tasks that need them.
1226    fn select_group(&self, pool: u16, constraint: u32) -> Option<u16> {
1227        let p = &self.pools[pool as usize];
1228        if p.groups.len() == 1 {
1229            let g = &p.groups[0];
1230            return (g.free() > 0 && constraint == 0).then_some(0);
1231        }
1232        let need = &self.constraints[constraint as usize];
1233        p.groups
1234            .iter()
1235            .enumerate()
1236            .filter(|(_, g)| g.free() > 0 && satisfies(&g.labels, need))
1237            .min_by_key(|(_, g)| (g.labels.len(), std::cmp::Reverse(g.free())))
1238            .map(|(i, _)| i as u16)
1239    }
1240
1241    /// Park a ready task until a group with its labels has a free slot.
1242    fn park_label(&mut self, k: Key) {
1243        let (pool, c) = {
1244            let e = self.entry(k);
1245            (e.pool as usize, e.constraint)
1246        };
1247        self.entry_mut(k).loc = Loc::LabelWait;
1248        self.pools[pool]
1249            .label_waiters
1250            .entry(c)
1251            .or_default()
1252            .push_back(k);
1253    }
1254
1255    /// Move tasks waiting for labels that `group` satisfies back to their queues, oldest
1256    /// first, up to the group's free slots.
1257    fn wake_labels(&mut self, pool: u16, group: u16) {
1258        let p = &self.pools[pool as usize];
1259        if p.label_waiters.is_empty() {
1260            return;
1261        }
1262        let labels = p.groups[group as usize].labels.clone();
1263        let mut budget = p.groups[group as usize].free();
1264        let matching: Vec<u32> = p
1265            .label_waiters
1266            .keys()
1267            .copied()
1268            .filter(|c| satisfies(&labels, &self.constraints[*c as usize]))
1269            .collect();
1270        while budget > 0 {
1271            // The oldest head among the matching queues (dropping stale entries).
1272            let mut best: Option<(u64, u32)> = None;
1273            for &c in &matching {
1274                loop {
1275                    let front = self.pools[pool as usize]
1276                        .label_waiters
1277                        .get(&c)
1278                        .and_then(|q| q.front().copied());
1279                    match front {
1280                        Some(k) if !self.slots_live(k, Loc::LabelWait) => {
1281                            let q = self.pools[pool as usize].label_waiters.get_mut(&c);
1282                            q.unwrap().pop_front();
1283                        }
1284                        _ => break,
1285                    }
1286                }
1287                if let Some(&k) = self.pools[pool as usize]
1288                    .label_waiters
1289                    .get(&c)
1290                    .and_then(|q| q.front())
1291                {
1292                    let seq = self.entry(k).seq;
1293                    if best.is_none_or(|(s, _)| seq < s) {
1294                        best = Some((seq, c));
1295                    }
1296                }
1297            }
1298            let Some((_, c)) = best else { break };
1299            let q = self.pools[pool as usize].label_waiters.get_mut(&c).unwrap();
1300            let k = q.pop_front().unwrap();
1301            if q.is_empty() {
1302                self.pools[pool as usize].label_waiters.remove(&c);
1303            }
1304            self.requeue_ready(k);
1305            budget -= 1;
1306        }
1307        for c in matching {
1308            if self.pools[pool as usize]
1309                .label_waiters
1310                .get(&c)
1311                .is_some_and(|q| q.is_empty())
1312            {
1313                self.pools[pool as usize].label_waiters.remove(&c);
1314            }
1315        }
1316    }
1317
1318    /// Whether `k` is a live entry currently at `loc`.
1319    fn slots_live(&self, k: Key, loc: Loc) -> bool {
1320        self.slot_entry(k).is_some_and(|e| e.loc == loc)
1321    }
1322
1323    /// Put a ready task that was parked back into its lane or deadline queue.
1324    fn requeue_ready(&mut self, k: Key) {
1325        let (pool, class, seq, has_deadline, lst) = {
1326            let e = self.entry(k);
1327            (
1328                e.pool,
1329                e.class as usize,
1330                e.seq,
1331                e.deadline != Time::MAX,
1332                e.lst,
1333            )
1334        };
1335        let vtag = self.entry(k).vtag;
1336        let p = &mut self.pools[pool as usize];
1337        if has_deadline {
1338            p.edf[class].insert((lst, seq), k);
1339            self.entry_mut(k).loc = Loc::Edf;
1340        } else if let Some(f) = &mut p.fair {
1341            // A woken task keeps its place in the fair order.
1342            f.queues[class].insert((vtag, seq), k);
1343            self.entry_mut(k).loc = Loc::Fair;
1344        } else {
1345            p.lanes[class].insert(seq, k);
1346            self.entry_mut(k).loc = Loc::Lane;
1347        }
1348        self.mark_dirty(pool);
1349    }
1350
1351    fn intern_kind(&mut self, k: &Arc<str>) -> u32 {
1352        if let Some(&i) = self.kind_index.get(k) {
1353            return i;
1354        }
1355        let i = self.kinds.len() as u32;
1356        self.kinds.push(k.clone());
1357        self.kind_index.insert(k.clone(), i);
1358        i
1359    }
1360
1361    fn estimate_raw(&self, pool: u16, kind: Option<u32>, hint_ns: u64) -> Duration {
1362        if hint_ns > 0 {
1363            return Duration::from_nanos(hint_ns);
1364        }
1365        kind.and_then(|k| self.dur.get(&(pool, k)))
1366            .and_then(|s| s.estimate())
1367            .or_else(|| self.pools[pool as usize].stats.estimate())
1368            .unwrap_or(self.pools[pool as usize].cfg.default_duration)
1369    }
1370
1371    /// How long before a fixed start a worker is held: long enough for the ad-hoc work that
1372    /// might otherwise take the slot (p90 of non-fixed runtimes) to finish.
1373    fn hold_lead(&self, pool: u16, tolerance: Duration) -> Duration {
1374        let p = &self.pools[pool as usize];
1375        let p90 = p.adhoc_stats.p90().unwrap_or(p.cfg.default_duration);
1376        tolerance.max(p90).min(p.cfg.max_hold)
1377    }
1378
1379    /// Insert a validated task. Infallible.
1380    #[allow(clippy::too_many_arguments)]
1381    fn insert(
1382        &mut self,
1383        now: Time,
1384        id: TaskId,
1385        seq: u64,
1386        arrival: Time,
1387        spec: TaskSpec<P>,
1388        pool: u16,
1389        attempt: u32,
1390        skip_if_live: Option<TaskId>,
1391    ) {
1392        self.totals.submitted += 1;
1393        if self.cfg.persist {
1394            self.actions
1395                .push_back(Action::Persist(Box::new(Mutation::Submitted {
1396                    id,
1397                    seq,
1398                    arrival,
1399                    spec: spec.clone(),
1400                })));
1401        }
1402        let kind = self.intern_kind(&spec.kind);
1403        let constraint = self.intern_constraint(&spec.labels);
1404        let tenant = match (&spec.tenant, self.pools[pool as usize].fair.is_some()) {
1405            (Some(t), true) => self.intern_tenant(t),
1406            _ => 0,
1407        };
1408        let hint_ns = spec.duration_hint.map(nanos).unwrap_or(0);
1409        let resources = if spec.resources.is_empty() {
1410            None
1411        } else {
1412            Some(
1413                spec.resources
1414                    .iter()
1415                    .map(|(n, a)| (ResourceId(self.resource_index[n]), *a))
1416                    .collect::<Vec<_>>()
1417                    .into_boxed_slice(),
1418            )
1419        };
1420        let fixed = spec.fixed_start.map(|f| {
1421            let (s, e) = self.reservation_interval(pool, &spec, &f);
1422            let p = &mut self.pools[pool as usize];
1423            p.reservations.insert(id, s, e);
1424            if attempt == 0 {
1425                p.unstarted.insert(id, s, e);
1426            }
1427            Box::new(FixedInfo {
1428                at: f.at,
1429                tolerance: f.tolerance,
1430                on_miss: f.on_miss,
1431                res_start: s,
1432                held: false,
1433                hold_pending: false,
1434                missed: false,
1435                skip_if_live,
1436            })
1437        });
1438        // Resolve parents.
1439        let await_timeout = match self.pools[pool as usize].cfg.unknown_dependency {
1440            UnknownDependency::Await { timeout } => timeout,
1441            UnknownDependency::Reject => None,
1442        };
1443        let mut parents = Vec::with_capacity(spec.deps.len());
1444        for d in spec.deps.iter() {
1445            let recovering = self.recovering_keys.is_some();
1446            let p = match &d.target {
1447                TaskRef::Id(pid) => match self.index.get(pid) {
1448                    Some(_) => Parent::Live(*pid),
1449                    None => match self.retained.get(pid) {
1450                        Some(r) => Parent::Done(r.0),
1451                        // Recovering a live task whose parent left retention: the parent was
1452                        // terminal and the child is still live, so its edge was satisfied.
1453                        None if recovering => continue,
1454                        // Unreachable for validated submissions.
1455                        None => Parent::Done(TaskStatus::Succeeded),
1456                    },
1457                },
1458                TaskRef::Key(k) => {
1459                    if let Some(&pid) = self.keys.get(k) {
1460                        Parent::Live(pid)
1461                    } else if let Some(r) = self
1462                        .retained_keys
1463                        .get(k)
1464                        .and_then(|pid| self.retained.get(pid))
1465                    {
1466                        Parent::Done(r.0)
1467                    } else if self
1468                        .recovering_keys
1469                        .as_ref()
1470                        .is_some_and(|ks| !ks.contains(k))
1471                    {
1472                        // As above: a key that is neither live, retained, nor being recovered
1473                        // belonged to a parent that finished long ago.
1474                        continue;
1475                    } else {
1476                        Parent::Await(k.clone())
1477                    }
1478                }
1479            };
1480            parents.push((p, d.cond));
1481        }
1482        let entry = Entry {
1483            id,
1484            seq,
1485            arrival,
1486            payload: spec.payload,
1487            kind,
1488            pool,
1489            class: spec.priority as u8,
1490            status: TaskStatus::Blocked,
1491            loc: Loc::None,
1492            key: spec.key.clone(),
1493            deadline: spec.deadline.unwrap_or(Time::MAX),
1494            lst: Time::MAX,
1495            hint_ns,
1496            timeout_ns: spec.timeout.map(nanos).unwrap_or(0),
1497            retry: spec.retry.map(Box::new),
1498            attempt,
1499            unmet: 0,
1500            awaiting: 0,
1501            has_awaits: false,
1502            pending: true,
1503            lease: None,
1504            retry_at: Time::ZERO,
1505            dependents: Vec::new(),
1506            resources,
1507            tags: if spec.tags.is_empty() {
1508                None
1509            } else {
1510                Some(spec.tags.into())
1511            },
1512            fixed,
1513            constraint,
1514            tenant,
1515            vtag: 0,
1516        };
1517        let key = self.slab_insert(entry);
1518        self.index.insert(id, key);
1519        if let Some(k) = &spec.key {
1520            self.keys.insert(k.clone(), id);
1521        }
1522        {
1523            let p = &mut self.pools[pool as usize];
1524            p.pending += 1;
1525            p.pending_class[spec.priority.index()] += 1;
1526        }
1527        self.total_pending += 1;
1528        self.pending_class[spec.priority.index()] += 1;
1529
1530        // Wire dependency edges.
1531        let mut unsatisfied: Option<TaskStatus> = None;
1532        let (mut unmet, mut awaiting) = (0u32, 0u32);
1533        let mut awaited: Option<Vec<Arc<str>>> = None;
1534        for (p, cond) in parents {
1535            match p {
1536                Parent::Live(pid) => {
1537                    let pk = self.index[&pid];
1538                    self.entry_mut(pk).dependents.push((id, cond));
1539                    unmet += 1;
1540                }
1541                Parent::Done(st) => {
1542                    if !edge_satisfied(cond, st) {
1543                        unsatisfied = Some(st);
1544                    }
1545                }
1546                Parent::Await(k) => {
1547                    self.awaits.entry(k.clone()).or_default().push((id, cond));
1548                    awaited.get_or_insert_with(Vec::new).push(k);
1549                    unmet += 1;
1550                    awaiting += 1;
1551                }
1552            }
1553        }
1554        // Tasks awaiting this key become our dependents.
1555        if let Some(k) = &spec.key
1556            && let Some(waiting) = self.awaits.remove(k)
1557        {
1558            for (cid, _) in &waiting {
1559                if let Some(ck) = self.index.get(cid).copied() {
1560                    let c = self.entry_mut(ck);
1561                    c.awaiting = c.awaiting.saturating_sub(1);
1562                }
1563            }
1564            self.entry_mut(key).dependents.extend(waiting);
1565        }
1566        {
1567            let e = self.entry_mut(key);
1568            e.unmet = unmet;
1569            e.awaiting = awaiting;
1570            if let Some(keys) = awaited {
1571                e.has_awaits = true;
1572                self.awaited_by.insert(id, keys.into_boxed_slice());
1573            }
1574        }
1575        if awaiting > 0
1576            && let Some(t) = await_timeout
1577        {
1578            self.schedule(now + t, Timer::AwaitTimeout(id));
1579        }
1580        if let Some(f) = spec.fixed_start {
1581            // The hold lead is re-evaluated when this timer fires (see `Timer::HoldAt`), so it
1582            // tracks duration statistics learned after submission.
1583            let max_hold = self.pools[pool as usize].cfg.max_hold;
1584            self.schedule((f.at - max_hold).max(now), Timer::HoldAt(id));
1585            self.schedule(f.at, Timer::StartAt(id));
1586            // Missed only when strictly past `at + tolerance`.
1587            self.schedule(
1588                f.at + f.tolerance + Duration::from_nanos(1),
1589                Timer::MissAt(id),
1590            );
1591        }
1592        if let Some(st) = unsatisfied {
1593            self.finish(
1594                key,
1595                TaskStatus::Skipped,
1596                Some(format!("dependency ended as {st}").into()),
1597            );
1598            return;
1599        }
1600        if attempt > 0 && unmet == 0 {
1601            // Recovered task whose last attempt was lost: it counts against the retry budget.
1602            let max = {
1603                let e = self.entry(key);
1604                e.retry
1605                    .as_deref()
1606                    .copied()
1607                    .unwrap_or(self.pools[pool as usize].cfg.retry)
1608                    .max_attempts
1609            };
1610            if attempt >= max {
1611                self.finish(
1612                    key,
1613                    TaskStatus::DeadLettered,
1614                    Some("attempt lost in failover; retries exhausted".into()),
1615                );
1616                return;
1617            }
1618            if self.entry(key).fixed.is_none() {
1619                self.schedule_retry(now, key, None);
1620                return;
1621            }
1622            // Fixed-start tasks fall through: they wait for their slot (and miss policy) again.
1623        }
1624        self.emit(key, TaskStatus::Blocked, None);
1625        if unmet == 0 {
1626            self.make_eligible(key);
1627        }
1628    }
1629
1630    // -----------------------------------------------------------------------------------------
1631    // Execution feedback
1632    // -----------------------------------------------------------------------------------------
1633
1634    /// Report the outcome of an attempt. Returns `false` for unknown or stale leases.
1635    pub fn complete(&mut self, now: Time, lease: LeaseId, outcome: Outcome) -> bool {
1636        self.advance(now);
1637        let Some(l) = self.leases.remove(&lease) else {
1638            // A late or duplicate report (the attempt timed out, was aborted, or belongs to an
1639            // earlier leader): it changes nothing.
1640            self.totals.stale_completions += 1;
1641            return false;
1642        };
1643        self.end_attempt(&l);
1644        let ran = now.saturating_sub(l.started);
1645        match outcome {
1646            Outcome::Success => {
1647                let (pool, kind, fixed) = {
1648                    let e = self.entry(l.key);
1649                    (e.pool, e.kind, e.fixed.is_some())
1650                };
1651                let s = ran.as_secs_f64();
1652                self.dur.entry((pool, kind)).or_default().observe(s);
1653                let p = &mut self.pools[pool as usize];
1654                p.stats.observe(s);
1655                if !fixed {
1656                    p.adhoc_stats.observe(s);
1657                }
1658                let b = p.window.at(now);
1659                b.succeeded += 1;
1660                b.work_secs += s;
1661                b.run.record(ran);
1662                self.finish(l.key, TaskStatus::Succeeded, None);
1663            }
1664            Outcome::Cancelled => self.finish(l.key, TaskStatus::Cancelled, None),
1665            Outcome::Failure {
1666                retryable,
1667                retry_after,
1668                message,
1669            } => {
1670                self.fail_attempt(now, l.key, retryable, retry_after, message);
1671            }
1672        }
1673        self.pump();
1674        true
1675    }
1676
1677    /// Extend a lease (remote workers). Returns `false` if the lease is unknown.
1678    pub fn heartbeat(&mut self, now: Time, lease: LeaseId) -> bool {
1679        self.advance(now);
1680        let known = match self.leases.get_mut(&lease) {
1681            Some(l) => {
1682                if let Some(ttl) = self.pools[l.pool as usize].cfg.lease_ttl {
1683                    l.expires = now + ttl;
1684                }
1685                true
1686            }
1687            None => false,
1688        };
1689        // Timers fired by `advance` may have made work ready.
1690        self.pump();
1691        known
1692    }
1693
1694    fn end_attempt(&mut self, l: &Lease) {
1695        let now = self.now;
1696        let pool = &mut self.pools[l.pool as usize];
1697        pool.account(now);
1698        pool.busy -= 1;
1699        let g = &mut pool.groups[l.group as usize];
1700        g.busy = g.busy.saturating_sub(1);
1701        pool.dirty = true;
1702        self.dirty.push(l.pool);
1703        self.wake_labels(l.pool, l.group);
1704        let e = self.entry_mut(l.key);
1705        e.lease = None;
1706        let res = e.resources.clone();
1707        if let Some(res) = res {
1708            for (r, amount) in res.iter() {
1709                let rs = &mut self.resources[r.0 as usize];
1710                if let ResourceKind::Semaphore { .. } = rs.kind {
1711                    rs.available += *amount as f64;
1712                    self.wake_waiters(r.0);
1713                }
1714            }
1715            self.dirty_due_pools();
1716        }
1717    }
1718
1719    fn dirty_due_pools(&mut self) {
1720        for i in 0..self.pools.len() {
1721            if !self.pools[i].due.is_empty() {
1722                self.mark_dirty(i as u16);
1723            }
1724        }
1725    }
1726
1727    fn fail_attempt(
1728        &mut self,
1729        now: Time,
1730        key: Key,
1731        retryable: bool,
1732        retry_after: Option<Duration>,
1733        message: Arc<str>,
1734    ) {
1735        let (attempt, max) = {
1736            let e = self.entry(key);
1737            let policy = e
1738                .retry
1739                .as_deref()
1740                .copied()
1741                .unwrap_or(self.pools[e.pool as usize].cfg.retry);
1742            (e.attempt, policy.max_attempts)
1743        };
1744        let pool = self.entry(key).pool;
1745        self.pools[pool as usize].window.at(now).failed += 1;
1746        if retryable && attempt < max {
1747            self.schedule_retry(now, key, retry_after);
1748            let e = self.entry(key);
1749            let (id, pool, k, at) = (e.id, e.pool, e.key.clone(), e.attempt);
1750            self.push_status(id, pool, k, TaskStatus::Retrying, at, Some(message), true);
1751        } else if retryable {
1752            self.finish(key, TaskStatus::DeadLettered, Some(message));
1753        } else {
1754            self.finish(key, TaskStatus::Failed, Some(message));
1755        }
1756    }
1757
1758    fn schedule_retry(&mut self, now: Time, key: Key, retry_after: Option<Duration>) {
1759        self.totals.retries += 1;
1760        let (pool, attempt, policy) = {
1761            let e = self.entry(key);
1762            (
1763                e.pool,
1764                e.attempt,
1765                e.retry
1766                    .as_deref()
1767                    .copied()
1768                    .unwrap_or(self.pools[e.pool as usize].cfg.retry),
1769            )
1770        };
1771        let mut delay = policy.backoff(attempt + 1);
1772        if policy.jitter {
1773            delay = delay.mul_f64(self.rng.next_f64());
1774        }
1775        if let Some(ra) = retry_after {
1776            delay = delay.max(ra);
1777        }
1778        let at = now + delay;
1779        let (id, class) = {
1780            let e = self.entry_mut(key);
1781            e.status = TaskStatus::Retrying;
1782            e.retry_at = at;
1783            (e.id, e.class as usize)
1784        };
1785        self.pools[pool as usize].retrying[class] += 1;
1786        self.pools[pool as usize].window.at(now).retried += 1;
1787        self.add_pending(key, pool, class);
1788        self.schedule(at, Timer::Retry(id));
1789    }
1790
1791    fn add_pending(&mut self, key: Key, pool: u16, class: usize) {
1792        let e = self.entry_mut(key);
1793        if !e.pending {
1794            e.pending = true;
1795            let p = &mut self.pools[pool as usize];
1796            p.pending += 1;
1797            p.pending_class[class] += 1;
1798            self.total_pending += 1;
1799            self.pending_class[class] += 1;
1800        }
1801    }
1802
1803    fn remove_pending(&mut self, key: Key) {
1804        let e = self.entry_mut(key);
1805        if e.pending {
1806            e.pending = false;
1807            let (pool, class) = (e.pool as usize, e.class as usize);
1808            let p = &mut self.pools[pool];
1809            p.pending -= 1;
1810            p.pending_class[class] -= 1;
1811            self.total_pending -= 1;
1812            self.pending_class[class] -= 1;
1813            if self.space_waiters {
1814                self.space_waiters = false;
1815                self.actions.push_back(Action::SpaceAvailable);
1816            }
1817        }
1818    }
1819
1820    /// Cancel a task. Running attempts are aborted. Returns `false` if not live.
1821    pub fn cancel(&mut self, now: Time, target: &TaskRef) -> bool {
1822        self.advance(now);
1823        let Some(key) = self.lookup(target) else {
1824            return false;
1825        };
1826        if let Some(lease) = self.entry(key).lease
1827            && let Some(l) = self.leases.remove(&lease)
1828        {
1829            self.end_attempt(&l);
1830            self.actions.push_back(Action::Abort {
1831                lease,
1832                id: l.id,
1833                reason: AbortReason::Cancelled,
1834            });
1835        }
1836        self.finish(key, TaskStatus::Cancelled, Some("cancelled".into()));
1837        self.pump();
1838        true
1839    }
1840
1841    /// Report the capacity actually available in a pool (its unlabeled workers).
1842    pub fn set_capacity(&mut self, now: Time, pool: PoolId, capacity: u32) {
1843        self.set_group_capacity(now, pool, &[], capacity);
1844    }
1845
1846    /// Report the capacity of the workers of `pool` that carry exactly `labels`. Tasks that
1847    /// require labels run only in groups whose labels include them; tasks without
1848    /// requirements run in any group, preferring the least specialized. The pool's capacity is
1849    /// the sum over its groups.
1850    pub fn set_group_capacity(
1851        &mut self,
1852        now: Time,
1853        pool: PoolId,
1854        labels: &[(Arc<str>, Arc<str>)],
1855        capacity: u32,
1856    ) {
1857        self.advance(now);
1858        let labels = normalize(labels);
1859        let p = &mut self.pools[pool.0 as usize];
1860        p.account(now);
1861        let g = match p.groups.iter().position(|g| g.labels == labels) {
1862            Some(g) => g,
1863            None => {
1864                p.groups.push(Group {
1865                    labels,
1866                    capacity: 0,
1867                    busy: 0,
1868                });
1869                p.groups.len() - 1
1870            }
1871        };
1872        p.groups[g].capacity = capacity;
1873        p.capacity = p.groups.iter().map(|g| g.capacity).sum();
1874        p.dirty = true;
1875        self.dirty.push(pool.0);
1876        self.wake_labels(pool.0, g as u16);
1877        self.pump();
1878    }
1879
1880    /// Current status of a task (live or retained).
1881    pub fn status(&self, target: &TaskRef) -> Option<TaskStatus> {
1882        if let Some(k) = self.lookup(target) {
1883            return Some(self.entry(k).status);
1884        }
1885        let id = match target {
1886            TaskRef::Id(id) => *id,
1887            TaskRef::Key(k) => *self.retained_keys.get(k)?,
1888        };
1889        self.retained.get(&id).map(|r| r.0)
1890    }
1891
1892    /// Id of a live or retained task by id or key.
1893    pub fn resolve(&self, target: &TaskRef) -> Option<TaskId> {
1894        match target {
1895            TaskRef::Id(id) => {
1896                (self.index.contains_key(id) || self.retained.contains_key(id)).then_some(*id)
1897            }
1898            TaskRef::Key(k) => self
1899                .keys
1900                .get(k)
1901                .or_else(|| self.retained_keys.get(k))
1902                .copied(),
1903        }
1904    }
1905
1906    /// Number of live (non-terminal) tasks.
1907    pub fn live_tasks(&self) -> usize {
1908        self.index.len()
1909    }
1910
1911    /// Live tasks in id order: those in one of `pools` (any pool if empty) with one of
1912    /// `statuses` (any if empty), with ids above `after`, at most `limit`. Unknown pools match
1913    /// nothing.
1914    pub fn list_tasks<S: AsRef<str>>(
1915        &self,
1916        pools: &[S],
1917        statuses: &[TaskStatus],
1918        after: Option<TaskId>,
1919        limit: usize,
1920    ) -> Vec<TaskSummary> {
1921        let pool = if pools.is_empty() {
1922            None
1923        } else {
1924            let ids: Vec<u16> = pools
1925                .iter()
1926                .filter_map(|p| self.pool_index.get(p.as_ref()).copied())
1927                .collect();
1928            if ids.is_empty() {
1929                return Vec::new();
1930            }
1931            Some(ids)
1932        };
1933        let mut ids: Vec<(TaskId, Key)> = self
1934            .index
1935            .iter()
1936            .filter(|(id, _)| after.is_none_or(|a| **id > a))
1937            .map(|(id, k)| (*id, *k))
1938            .filter(|(_, k)| {
1939                let e = self.entry(*k);
1940                pool.as_ref().is_none_or(|p| p.contains(&e.pool))
1941                    && (statuses.is_empty() || statuses.contains(&e.status))
1942            })
1943            .collect();
1944        ids.sort_unstable_by_key(|(id, _)| *id);
1945        ids.truncate(limit);
1946        ids.into_iter()
1947            .map(|(id, k)| {
1948                let e = self.entry(k);
1949                TaskSummary {
1950                    id,
1951                    key: e.key.clone(),
1952                    pool: self.pools[e.pool as usize].cfg.name.clone(),
1953                    kind: self.kinds[e.kind as usize].clone(),
1954                    priority: Priority::from_index(e.class as usize).unwrap_or_default(),
1955                    status: e.status,
1956                    attempt: e.attempt,
1957                    tenant: (e.tenant != 0).then(|| self.tenants[e.tenant as usize].clone()),
1958                }
1959            })
1960            .collect()
1961    }
1962
1963    /// Number of distinct unknown keys that live tasks are waiting for.
1964    #[doc(hidden)]
1965    pub fn awaited_keys(&self) -> usize {
1966        self.awaits.len()
1967    }
1968
1969    fn lookup(&self, target: &TaskRef) -> Option<Key> {
1970        let id = match target {
1971            TaskRef::Id(id) => *id,
1972            TaskRef::Key(k) => *self.keys.get(k)?,
1973        };
1974        self.index.get(&id).copied()
1975    }
1976
1977    // -----------------------------------------------------------------------------------------
1978    // Recurring schedules
1979    // -----------------------------------------------------------------------------------------
1980
1981    /// Add or replace a recurring (cron) schedule.
1982    pub fn add_recurring(&mut self, now: Time, spec: RecurringSpec<P>) -> Result<(), String> {
1983        self.advance(now);
1984        self.add_recurring_inner(now, spec, None, None)?;
1985        self.pump();
1986        Ok(())
1987    }
1988
1989    /// `next` and `chain_last` are the stored state of a recovered schedule.
1990    fn add_recurring_inner(
1991        &mut self,
1992        now: Time,
1993        spec: RecurringSpec<P>,
1994        next: Option<Time>,
1995        chain_last: Option<TaskId>,
1996    ) -> Result<(), String> {
1997        let cron = croner::parser::CronParser::builder()
1998            .seconds(croner::parser::Seconds::Optional)
1999            .build()
2000            .parse(&spec.cron)
2001            .map_err(|e| format!("invalid cron '{}': {e}", spec.cron))?;
2002        let tz: chrono_tz::Tz = match &spec.tz {
2003            Some(t) => t.parse().map_err(|_| format!("unknown time zone '{t}'"))?,
2004            None => chrono_tz::UTC,
2005        };
2006        self.check_config(&spec.template)
2007            .map_err(|e| format!("invalid template: {e}"))?;
2008        if self.cfg.persist {
2009            self.actions
2010                .push_back(Action::Persist(Box::new(Mutation::RecurringUpsert {
2011                    spec: spec.clone(),
2012                })));
2013        }
2014        let name = spec.name.clone();
2015        // Replacing a schedule keeps its history, so an occurrence that was already
2016        // materialized is neither duplicated nor allowed to overlap.
2017        let (last_task, materialized) = self
2018            .recurring
2019            .get(&name)
2020            .map(|old| (old.last_task, old.materialized))
2021            .or_else(|| self.recurring_history.remove(&name))
2022            .unwrap_or((None, None));
2023        let mut r = Recurring {
2024            spec,
2025            cron,
2026            tz,
2027            next: None,
2028            last_task,
2029            materialized,
2030            chain_last,
2031        };
2032        let mut next = match next {
2033            Some(n) => Some(n),
2034            None => self.next_occurrence(&r, now),
2035        };
2036        if let (Some(n), Some(m)) = (next, materialized)
2037            && n <= m
2038        {
2039            next = self.next_occurrence(&r, m);
2040        }
2041        r.next = next;
2042        self.recurring.insert(name.clone(), r);
2043        self.arm_recurring(now, &name);
2044        Ok(())
2045    }
2046
2047    /// Whether live task `id` is an occurrence of schedule `name` in `pool`.
2048    fn is_occurrence_of(&self, id: TaskId, pool: u16, name: &str) -> bool {
2049        let Some(&k) = self.index.get(&id) else {
2050            return false;
2051        };
2052        let e = self.entry(k);
2053        e.pool == pool
2054            && e.tags.as_ref().is_some_and(|t| {
2055                t.iter()
2056                    .any(|(k, v)| &**k == "cronoq.recurring" && &**v == name)
2057            })
2058    }
2059
2060    /// Remove a recurring schedule. An occurrence already materialized still runs.
2061    pub fn remove_recurring(&mut self, now: Time, name: &str) -> bool {
2062        self.advance(now);
2063        let removed = self.recurring.remove(name);
2064        if let Some(r) = &removed {
2065            // Remembered so re-adding the schedule does not duplicate that occurrence.
2066            self.recurring_history
2067                .insert(name.into(), (r.last_task, r.materialized));
2068            if self.cfg.persist {
2069                self.actions
2070                    .push_back(Action::Persist(Box::new(Mutation::RecurringRemove {
2071                        name: name.into(),
2072                    })));
2073            }
2074        }
2075        self.pump();
2076        removed.is_some()
2077    }
2078
2079    /// Names of all recurring schedules.
2080    pub fn recurring_names(&self) -> Vec<Arc<str>> {
2081        self.recurring.keys().cloned().collect()
2082    }
2083
2084    /// All recurring schedules, by name.
2085    pub fn list_recurring(&self) -> Vec<RecurringInfo> {
2086        self.recurring
2087            .values()
2088            .map(|r| RecurringInfo {
2089                name: r.spec.name.clone(),
2090                cron: r.spec.cron.clone(),
2091                tz: r.spec.tz.clone(),
2092                pool: r.spec.template.pool.clone(),
2093                kind: r.spec.template.kind.clone(),
2094                overlap: r.spec.overlap,
2095                next: r.next,
2096                last_task: r.last_task,
2097            })
2098            .collect()
2099    }
2100
2101    fn next_occurrence(&self, r: &Recurring<P>, after: Time) -> Option<Time> {
2102        use chrono::TimeZone;
2103        let wall = self.cfg.epoch_unix_nanos as i128 + after.0 as i128;
2104        // Cron has whole-second resolution: drop the sub-second part so occurrences land
2105        // exactly on the second instead of inheriting the offset of `after`.
2106        let secs = i64::try_from(wall.div_euclid(1_000_000_000)).ok()?;
2107        let dt = r.tz.timestamp_opt(secs, 0).single()?;
2108        let next = r.cron.find_next_occurrence(&dt, false).ok()?;
2109        let ns = next.timestamp_nanos_opt()? as i128 - self.cfg.epoch_unix_nanos as i128;
2110        (ns >= 0).then_some(Time(ns as u64))
2111    }
2112
2113    fn recurring_lead(&self, pool: u16, tolerance: Duration) -> Duration {
2114        (self.hold_lead(pool, tolerance) + tolerance).min(self.pools[pool as usize].cfg.max_hold)
2115    }
2116
2117    /// Arm the materialization timer for the next occurrence, never before `not_before`.
2118    fn arm_recurring(&mut self, not_before: Time, name: &Arc<str>) {
2119        let Some(r) = self.recurring.get(name) else {
2120            return;
2121        };
2122        let Some(next) = r.next else { return };
2123        let pool = self.pool_index[&r.spec.template.pool];
2124        let lead = self.recurring_lead(pool, r.spec.tolerance);
2125        let at = (next - lead).max(not_before);
2126        self.schedule(at, Timer::Recurring(name.clone(), next));
2127    }
2128
2129    fn fire_recurring(&mut self, now: Time, name: Arc<str>, occurrence: Time) {
2130        let Some(r) = self.recurring.get(&name) else {
2131            return;
2132        };
2133        if r.next != Some(occurrence) {
2134            return; // stale timer from a replaced schedule
2135        }
2136        let at = occurrence;
2137        // Far past its start (e.g. after downtime): run at most this one occurrence late,
2138        // then resume from the present instead of replaying every missed occurrence.
2139        let late = at + r.spec.tolerance < now;
2140        if late && r.spec.on_miss != MissPolicy::RunLate {
2141            self.totals.missed += 1;
2142            let next = self.next_occurrence(r, now);
2143            let r = self.recurring.get_mut(&name).unwrap();
2144            r.next = next;
2145            let last = r.chain_last;
2146            if self.cfg.persist {
2147                self.actions
2148                    .push_back(Action::Persist(Box::new(Mutation::RecurringNext {
2149                        name: name.clone(),
2150                        next,
2151                        last,
2152                    })));
2153            }
2154            self.arm_recurring(now, &name);
2155            return;
2156        }
2157        let pool = self.pool_index[&r.spec.template.pool];
2158        let mut spec = r.spec.template.clone();
2159        spec.fixed_start = Some(FixedStart {
2160            at,
2161            tolerance: r.spec.tolerance,
2162            on_miss: r.spec.on_miss,
2163        });
2164        spec.key = r
2165            .spec
2166            .template
2167            .key
2168            .as_ref()
2169            .map(|k| occurrence_key(k, self.cfg.epoch_unix_nanos, at));
2170        spec.deadline = r.spec.deadline_after.map(|d| at + d);
2171        spec.deps.clear();
2172        let chain = match r.spec.overlap {
2173            OverlapPolicy::Queue => Some(EdgeCond::OnComplete),
2174            OverlapPolicy::QueueOnSuccess => Some(EdgeCond::OnSuccess),
2175            OverlapPolicy::Skip | OverlapPolicy::Allow => None,
2176        };
2177        if let (Some(cond), Some(prev)) = (chain, r.chain_last) {
2178            // A predecessor that left retention counts as succeeded (see `insert`).
2179            spec.deps.push(Dep {
2180                target: TaskRef::Id(prev),
2181                cond,
2182            });
2183        }
2184        let prev = match r.spec.overlap {
2185            OverlapPolicy::Skip => r.last_task,
2186            _ => None,
2187        };
2188        let (s, e) = self.reservation_interval(pool, &spec, spec.fixed_start.as_ref().unwrap());
2189        let fits = self.pools[pool as usize].reservations.max_overlap(s, e)
2190            < self.pools[pool as usize].cfg.reservation_limit();
2191        let mut task = None;
2192        let existing = self.existing_key(spec.key.as_deref());
2193        if let Some(id) = existing.filter(|id| self.is_occurrence_of(*id, pool, &name)) {
2194            // Already created (e.g. before a failover): the occurrence is that task.
2195            task = Some(id);
2196        } else if fits {
2197            if existing.is_some() {
2198                // Another task holds the key: run the occurrence without it rather than let that
2199                // task stand in for the schedule.
2200                spec.key = None;
2201            }
2202            let id = self.alloc_id();
2203            let seq = self.alloc_seq();
2204            spec.tags.push(("cronoq.recurring".into(), name.clone()));
2205            self.insert(now, id, seq, now, spec, pool, 0, prev);
2206            task = Some(id);
2207        } else {
2208            self.totals.recurring_conflicts += 1;
2209        }
2210        let from = if late { now } else { at };
2211        let next = self.next_occurrence(self.recurring.get(&name).unwrap(), from);
2212        let r = self.recurring.get_mut(&name).unwrap();
2213        if task.is_some() {
2214            r.last_task = task;
2215            r.chain_last = task;
2216        }
2217        r.materialized = Some(at);
2218        r.next = next;
2219        let last = r.chain_last;
2220        if self.cfg.persist {
2221            self.actions
2222                .push_back(Action::Persist(Box::new(Mutation::RecurringNext {
2223                    name: name.clone(),
2224                    next,
2225                    last,
2226                })));
2227        }
2228        // At most one materialized occurrence ahead: the next one waits until this one is due.
2229        self.arm_recurring(at.max(now), &name);
2230    }
2231
2232    // -----------------------------------------------------------------------------------------
2233    // Recovery
2234    // -----------------------------------------------------------------------------------------
2235
2236    /// Restore state from durable storage. Call once, before any submission.
2237    pub fn recover(&mut self, now: Time, rec: Recovered<P>) {
2238        self.advance(now);
2239        self.next_id = self.next_id.max(rec.next_id);
2240        for t in rec.terminal {
2241            self.retain(t.id, t.key, t.status, t.at);
2242        }
2243        let mut tasks = rec.tasks;
2244        tasks.sort_by_key(|t| t.id);
2245        // Keys that will exist once recovery completes; dependencies on any other unknown key
2246        // belong to parents that finished outside the retention window.
2247        self.recovering_keys = Some(tasks.iter().filter_map(|t| t.spec.key.clone()).collect());
2248        for t in tasks {
2249            self.next_id = self.next_id.max(t.id.0 + 1);
2250            self.next_seq = self.next_seq.max(t.seq + 1);
2251            let pool = self.pool_index.get(&t.spec.pool).copied();
2252            let missing =
2253                match pool {
2254                    None => Some(format!("pool '{}' no longer exists", t.spec.pool)),
2255                    Some(_) => t.spec.resources.iter().find_map(|(n, a)| {
2256                        match self.resource_index.get(n) {
2257                            None => Some(format!("resource '{n}' no longer exists")),
2258                            Some(&r) if *a as f64 > self.resources[r as usize].capacity => Some(
2259                                format!("resource '{n}' no longer has capacity for this task"),
2260                            ),
2261                            Some(_) => None,
2262                        }
2263                    }),
2264                };
2265            if let Some(reason) = missing {
2266                // Close it out durably so its key is released and it is not recovered again.
2267                self.totals.failed += 1;
2268                self.retain(t.id, t.spec.key.clone(), TaskStatus::Failed, now);
2269                self.push_status(
2270                    t.id,
2271                    0,
2272                    t.spec.key,
2273                    TaskStatus::Failed,
2274                    t.attempt,
2275                    Some(reason.into()),
2276                    true,
2277                );
2278                continue;
2279            }
2280            self.insert(
2281                now,
2282                t.id,
2283                t.seq,
2284                t.arrival,
2285                t.spec,
2286                pool.unwrap(),
2287                t.attempt,
2288                None,
2289            );
2290        }
2291        self.recovering_keys = None;
2292        // The latest live occurrence of each schedule restores its overlap guard.
2293        let mut latest: StrMap<(TaskId, Time)> = StrMap::default();
2294        for k in self.index.values() {
2295            let e = self.entry(*k);
2296            let (Some(tags), Some(f)) = (&e.tags, &e.fixed) else {
2297                continue;
2298            };
2299            if let Some((_, name)) = tags.iter().find(|(t, _)| &**t == "cronoq.recurring") {
2300                let slot = latest.entry(name.clone()).or_insert((e.id, f.at));
2301                if f.at > slot.1 {
2302                    *slot = (e.id, f.at);
2303                }
2304            }
2305        }
2306        for r in rec.recurring {
2307            let name = r.spec.name.clone();
2308            if let Some(&(id, at)) = latest.get(&name) {
2309                self.recurring_history.insert(name, (Some(id), Some(at)));
2310            }
2311            // A schedule the configuration no longer supports (removed pool or resource) is not
2312            // armed; it stays stored and comes back once the configuration supports it again.
2313            if self
2314                .add_recurring_inner(now, r.spec, r.next, r.last)
2315                .is_err()
2316            {
2317                self.totals.recurring_invalid += 1;
2318            }
2319        }
2320        // Timers already in the past (missed fixed starts, expired awaits) apply before
2321        // anything is dispatched.
2322        self.advance(now);
2323        self.pump();
2324    }
2325
2326    // -----------------------------------------------------------------------------------------
2327    // Timers
2328    // -----------------------------------------------------------------------------------------
2329
2330    fn schedule(&mut self, at: Time, t: Timer) {
2331        self.timer_seq += 1;
2332        self.timers.push(Reverse((at, self.timer_seq, t)));
2333    }
2334
2335    fn advance(&mut self, now: Time) {
2336        let now = now.max(self.now);
2337        self.now = now;
2338        if !self.started {
2339            // Engine time may be anything (e.g. Unix nanoseconds): start accounting here
2340            // rather than integrating capacity over the whole span since `Time::ZERO`.
2341            self.started = true;
2342            for p in &mut self.pools {
2343                p.last_acct = now;
2344                p.last_eval = now;
2345            }
2346            for r in &mut self.resources {
2347                r.last_refill = now;
2348            }
2349        }
2350        while let Some(Reverse((t, _, _))) = self.timers.peek() {
2351            if *t > now {
2352                break;
2353            }
2354            let Reverse((t, _, timer)) = self.timers.pop().unwrap();
2355            self.fire(t.max(Time::ZERO), now, timer);
2356        }
2357        self.evict_retained(now);
2358    }
2359
2360    fn fire(&mut self, _at: Time, now: Time, timer: Timer) {
2361        match timer {
2362            Timer::Retry(id) => {
2363                if let Some(k) = self.index.get(&id).copied() {
2364                    let e = self.entry(k);
2365                    if e.status == TaskStatus::Retrying && e.retry_at <= now {
2366                        let (pool, class) = (e.pool as usize, e.class as usize);
2367                        self.pools[pool].retrying[class] -= 1;
2368                        if self.entry(k).unmet == 0 {
2369                            self.make_eligible(k);
2370                        } else {
2371                            self.entry_mut(k).status = TaskStatus::Blocked;
2372                        }
2373                    }
2374                }
2375            }
2376            Timer::HoldAt(id) => {
2377                if let Some(k) = self.index.get(&id).copied() {
2378                    let e = self.entry(k);
2379                    let ok = matches!(
2380                        e.status,
2381                        TaskStatus::Blocked | TaskStatus::Scheduled | TaskStatus::Ready
2382                    ) && e.fixed.as_ref().is_some_and(|f| !f.held && !f.hold_pending);
2383                    if ok {
2384                        let pool = e.pool;
2385                        let f = e.fixed.as_ref().unwrap();
2386                        let (at, tolerance, seq) = (f.at, f.tolerance, e.seq);
2387                        // Recompute the lead from current statistics. If it is not time yet,
2388                        // check again halfway there, so a lead that grows meanwhile is caught.
2389                        let target = at - self.hold_lead(pool, tolerance);
2390                        if now < target {
2391                            let gap = target.saturating_sub(now);
2392                            let next = if gap > Duration::from_secs(1) {
2393                                now + gap / 2
2394                            } else {
2395                                target
2396                            };
2397                            self.schedule(next, Timer::HoldAt(id));
2398                            return;
2399                        }
2400                        self.entry_mut(k).fixed.as_mut().unwrap().hold_pending = true;
2401                        self.pools[pool as usize].pending_holds.insert((at, seq), k);
2402                        self.mark_dirty(pool);
2403                    }
2404                }
2405            }
2406            Timer::StartAt(id) => {
2407                if let Some(k) = self.index.get(&id).copied()
2408                    && self.entry(k).status == TaskStatus::Scheduled
2409                {
2410                    self.make_eligible(k);
2411                }
2412            }
2413            Timer::MissAt(id) => {
2414                if let Some(k) = self.index.get(&id).copied() {
2415                    self.on_miss(k);
2416                }
2417            }
2418            Timer::AwaitTimeout(id) => {
2419                if let Some(k) = self.index.get(&id).copied()
2420                    && self.entry(k).awaiting > 0
2421                    && self.entry(k).status == TaskStatus::Blocked
2422                {
2423                    self.finish(
2424                        k,
2425                        TaskStatus::Skipped,
2426                        Some("timed out awaiting unknown dependency".into()),
2427                    );
2428                }
2429            }
2430            Timer::AttemptTimeout(lease) => {
2431                if let Some(l) = self.leases.remove(&lease) {
2432                    self.totals.timeouts += 1;
2433                    self.end_attempt(&l);
2434                    self.actions.push_back(Action::Abort {
2435                        lease,
2436                        id: l.id,
2437                        reason: AbortReason::Timeout,
2438                    });
2439                    self.fail_attempt(now, l.key, true, None, "attempt timed out".into());
2440                }
2441            }
2442            Timer::LeaseExpiry(lease) => {
2443                let Some(l) = self.leases.get(&lease) else {
2444                    return;
2445                };
2446                if l.expires > now {
2447                    let at = l.expires;
2448                    self.schedule(at, Timer::LeaseExpiry(lease));
2449                    return;
2450                }
2451                let l = self.leases.remove(&lease).unwrap();
2452                self.totals.lease_expiries += 1;
2453                self.end_attempt(&l);
2454                self.actions.push_back(Action::Abort {
2455                    lease,
2456                    id: l.id,
2457                    reason: AbortReason::LeaseExpired,
2458                });
2459                self.fail_attempt(now, l.key, true, None, "lease expired".into());
2460            }
2461            Timer::Refill(r) => {
2462                let rs = &mut self.resources[r as usize];
2463                rs.refill_at = None;
2464                rs.refill(now);
2465                self.wake_waiters(r);
2466                // Fixed tasks blocked on resources live in `due`.
2467                self.dirty_due_pools();
2468            }
2469            Timer::Autoscale(pool) => self.autoscale(now, pool),
2470            Timer::Recurring(name, at) => self.fire_recurring(now, name, at),
2471        }
2472    }
2473
2474    fn on_miss(&mut self, k: Key) {
2475        let (status, pool, policy, attempt, late) = {
2476            let e = self.entry(k);
2477            let Some(f) = e.fixed.as_ref() else { return };
2478            (
2479                e.status,
2480                e.pool,
2481                f.on_miss,
2482                e.attempt,
2483                self.now > f.at + f.tolerance,
2484            )
2485        };
2486        // A task that already started once (now retrying or waiting for a slot to retry)
2487        // made its fixed start; only a start that never happened can be missed.
2488        if attempt > 0
2489            || !late
2490            || matches!(status, TaskStatus::Running)
2491            || status.is_terminal()
2492            || status == TaskStatus::Retrying
2493        {
2494            return;
2495        }
2496        self.entry_mut(k).fixed.as_mut().unwrap().missed = true;
2497        match policy {
2498            MissPolicy::RunLate => {
2499                if status == TaskStatus::Blocked {
2500                    self.release_hold(k);
2501                }
2502                self.mark_dirty(pool);
2503            }
2504            MissPolicy::Skip => {
2505                self.totals.missed += 1;
2506                self.finish(k, TaskStatus::Missed, Some("fixed start missed".into()));
2507            }
2508            MissPolicy::Fail => {
2509                self.finish(k, TaskStatus::Failed, Some("fixed start missed".into()))
2510            }
2511        }
2512    }
2513
2514    fn release_hold(&mut self, k: Key) {
2515        let pool = self.entry(k).pool as usize;
2516        let (seq, f) = {
2517            let e = self.entry_mut(k);
2518            (e.seq, e.fixed.as_mut().unwrap())
2519        };
2520        let at = f.at;
2521        let was_held = core::mem::replace(&mut f.held, false);
2522        let was_pending = core::mem::replace(&mut f.hold_pending, false);
2523        let p = &mut self.pools[pool];
2524        if was_held {
2525            p.held -= 1;
2526            p.held_tasks.remove(&(at, seq));
2527            p.dirty = true;
2528            self.dirty.push(pool as u16);
2529        }
2530        if was_pending {
2531            p.pending_holds.remove(&(at, seq));
2532        }
2533    }
2534
2535    // -----------------------------------------------------------------------------------------
2536    // State transitions
2537    // -----------------------------------------------------------------------------------------
2538
2539    /// Unmet dependencies are zero: queue the task (or wait for its fixed start).
2540    fn make_eligible(&mut self, k: Key) {
2541        let now = self.now;
2542        let (pool, class, seq, fixed_at, has_deadline) = {
2543            let e = self.entry(k);
2544            (
2545                e.pool,
2546                e.class as usize,
2547                e.seq,
2548                e.fixed.as_ref().map(|f| f.at),
2549                e.deadline != Time::MAX,
2550            )
2551        };
2552        match fixed_at {
2553            Some(at) if now < at => {
2554                self.set_status(k, TaskStatus::Scheduled);
2555            }
2556            Some(at) => {
2557                self.entry_mut(k).loc = Loc::Due;
2558                self.pools[pool as usize].due.insert((at, seq), k);
2559                self.set_status(k, TaskStatus::Ready);
2560                self.mark_dirty(pool);
2561            }
2562            None => {
2563                if has_deadline {
2564                    let lst = {
2565                        let e = self.entry(k);
2566                        e.deadline - self.estimate_raw(e.pool, Some(e.kind), e.hint_ns)
2567                    };
2568                    let e = self.entry_mut(k);
2569                    e.lst = lst;
2570                    e.loc = Loc::Edf;
2571                    self.pools[pool as usize].edf[class].insert((lst, seq), k);
2572                } else if self.pools[pool as usize].fair.is_some() {
2573                    let tenant = self.entry(k).tenant;
2574                    let tag = self.fair_tag(pool, class, tenant);
2575                    let e = self.entry_mut(k);
2576                    e.vtag = tag;
2577                    e.loc = Loc::Fair;
2578                    let f = self.pools[pool as usize].fair.as_mut().unwrap();
2579                    f.queues[class].insert((tag, seq), k);
2580                } else {
2581                    self.entry_mut(k).loc = Loc::Lane;
2582                    self.pools[pool as usize].lanes[class].insert(seq, k);
2583                }
2584                self.pools[pool as usize].arrivals += 1;
2585                self.set_status(k, TaskStatus::Ready);
2586                self.mark_dirty(pool);
2587            }
2588        }
2589    }
2590
2591    fn unqueue(&mut self, k: Key) {
2592        let (loc, pool, class, seq, lst, at, vtag) = {
2593            let e = self.entry(k);
2594            (
2595                e.loc,
2596                e.pool as usize,
2597                e.class as usize,
2598                e.seq,
2599                e.lst,
2600                e.fixed.as_ref().map(|f| f.at),
2601                e.vtag,
2602            )
2603        };
2604        let p = &mut self.pools[pool];
2605        match loc {
2606            // Waiter queues are cleaned lazily.
2607            Loc::None | Loc::Parked(_) | Loc::LabelWait => {}
2608            Loc::Lane => {
2609                p.lanes[class].remove(seq);
2610            }
2611            Loc::Edf => {
2612                p.edf[class].remove(&(lst, seq));
2613            }
2614            Loc::Due => {
2615                p.due.remove(&(at.unwrap(), seq));
2616            }
2617            Loc::Fair => {
2618                if let Some(f) = &mut p.fair {
2619                    f.queues[class].remove(&(vtag, seq));
2620                }
2621            }
2622        }
2623        self.entry_mut(k).loc = Loc::None;
2624    }
2625
2626    /// Terminal transition with dependency propagation.
2627    fn finish(&mut self, k: Key, status: TaskStatus, message: Option<Arc<str>>) {
2628        debug_assert!(status.is_terminal());
2629        let mut work = vec![(k, status, message)];
2630        while let Some((k, status, message)) = work.pop() {
2631            if self.slot_entry(k).is_none() {
2632                continue;
2633            }
2634            let parked_on = match self.entry(k).loc {
2635                Loc::Parked(r) => Some(r),
2636                _ => None,
2637            };
2638            if self.entry(k).status == TaskStatus::Retrying {
2639                let e = self.entry(k);
2640                let (pool, class) = (e.pool as usize, e.class as usize);
2641                self.pools[pool].retrying[class] -= 1;
2642            }
2643            self.unqueue(k);
2644            self.remove_pending(k);
2645            if self.entry(k).fixed.is_some() {
2646                self.release_hold(k);
2647                let e = self.entry(k);
2648                let (pool, id, rs) = (e.pool as usize, e.id, e.fixed.as_ref().unwrap().res_start);
2649                self.pools[pool].reservations.remove(id, rs);
2650                self.pools[pool].unstarted.remove(id, rs);
2651            }
2652            match status {
2653                TaskStatus::Succeeded => self.totals.succeeded += 1,
2654                TaskStatus::Failed => self.totals.failed += 1,
2655                TaskStatus::DeadLettered => self.totals.dead_lettered += 1,
2656                TaskStatus::Cancelled => self.totals.cancelled += 1,
2657                TaskStatus::Skipped => self.totals.skipped += 1,
2658                TaskStatus::Shed => self.totals.shed += 1,
2659                _ => {}
2660            }
2661            let e = self.slab_remove(k);
2662            if e.has_awaits
2663                && let Some(keys) = self.awaited_by.remove(&e.id)
2664            {
2665                for key in keys.iter() {
2666                    if let Some(list) = self.awaits.get_mut(key) {
2667                        list.retain(|(cid, _)| *cid != e.id);
2668                        if list.is_empty() {
2669                            self.awaits.remove(key);
2670                        }
2671                    }
2672                }
2673            }
2674            if let Some(r) = parked_on {
2675                // This task may have been the head waiter blocking others that now fit.
2676                self.wake_waiters(r.0);
2677            }
2678            self.index.remove(&e.id);
2679            if let Some(key) = &e.key
2680                && self.keys.get(key) == Some(&e.id)
2681            {
2682                self.keys.remove(key);
2683            }
2684            self.retain(e.id, e.key.clone(), status, self.now);
2685            self.push_status(
2686                e.id,
2687                e.pool,
2688                e.key.clone(),
2689                status,
2690                e.attempt,
2691                message,
2692                true,
2693            );
2694            for (cid, cond) in e.dependents {
2695                let Some(ck) = self.index.get(&cid).copied() else {
2696                    continue;
2697                };
2698                if edge_satisfied(cond, status) {
2699                    let c = self.entry_mut(ck);
2700                    c.unmet = c.unmet.saturating_sub(1);
2701                    if c.unmet == 0 && c.status == TaskStatus::Blocked {
2702                        self.make_eligible(ck);
2703                    }
2704                } else {
2705                    work.push((
2706                        ck,
2707                        TaskStatus::Skipped,
2708                        Some(format!("dependency {} ended as {status}", e.id).into()),
2709                    ));
2710                }
2711            }
2712        }
2713    }
2714
2715    fn retain(&mut self, id: TaskId, key: Option<Arc<str>>, status: TaskStatus, at: Time) {
2716        self.retained.insert(id, (status, at));
2717        if let Some(k) = &key {
2718            self.retained_keys.insert(k.clone(), id);
2719        }
2720        self.retention_q.push_back((at, id, key));
2721    }
2722
2723    fn evict_retained(&mut self, now: Time) {
2724        while let Some((t, _, _)) = self.retention_q.front() {
2725            if now.saturating_sub(*t) <= self.cfg.retention
2726                && self.retention_q.len() <= self.cfg.retention_max
2727            {
2728                break;
2729            }
2730            let (_, id, key) = self.retention_q.pop_front().unwrap();
2731            self.retained.remove(&id);
2732            if let Some(k) = key
2733                && self.retained_keys.get(&k) == Some(&id)
2734            {
2735                self.retained_keys.remove(&k);
2736            }
2737        }
2738    }
2739
2740    fn set_status(&mut self, k: Key, status: TaskStatus) {
2741        let emit = self.emit_transitions;
2742        let e = self.entry_mut(k);
2743        e.status = status;
2744        if emit {
2745            let (id, pool, key, at) = (e.id, e.pool, e.key.clone(), e.attempt);
2746            self.push_status(id, pool, key, status, at, None, false);
2747        }
2748    }
2749
2750    fn emit(&mut self, k: Key, status: TaskStatus, message: Option<Arc<str>>) {
2751        let emit = self.emit_transitions;
2752        let e = self.entry_mut(k);
2753        e.status = status;
2754        let (id, pool, key, at) = (e.id, e.pool, e.key.clone(), e.attempt);
2755        if emit {
2756            self.push_status(id, pool, key, status, at, message, false);
2757        }
2758    }
2759
2760    #[allow(clippy::too_many_arguments)]
2761    fn push_status(
2762        &mut self,
2763        id: TaskId,
2764        pool: u16,
2765        key: Option<Arc<str>>,
2766        status: TaskStatus,
2767        attempt: u32,
2768        message: Option<Arc<str>>,
2769        important: bool,
2770    ) {
2771        let persist = self.cfg.persist
2772            && (status.is_terminal()
2773                || matches!(status, TaskStatus::Running | TaskStatus::Retrying));
2774        if persist {
2775            self.actions
2776                .push_back(Action::Persist(Box::new(Mutation::Status {
2777                    id,
2778                    status,
2779                    attempt,
2780                    at: self.now,
2781                    message: message.clone(),
2782                })));
2783        }
2784        if important || self.emit_transitions {
2785            self.actions.push_back(Action::Status(StatusEvent {
2786                id,
2787                pool: PoolId(pool),
2788                key,
2789                status,
2790                attempt,
2791                at: self.now,
2792                message,
2793            }));
2794        }
2795    }
2796
2797    // -----------------------------------------------------------------------------------------
2798    // Dispatch
2799    // -----------------------------------------------------------------------------------------
2800
2801    fn mark_dirty(&mut self, pool: u16) {
2802        let p = &mut self.pools[pool as usize];
2803        if !p.dirty {
2804            p.dirty = true;
2805            self.dirty.push(pool);
2806        }
2807    }
2808
2809    fn pump(&mut self) {
2810        while let Some(pool) = self.dirty.pop() {
2811            if self.pools[pool as usize].dirty {
2812                self.pools[pool as usize].dirty = false;
2813                self.pump_pool(pool);
2814            }
2815        }
2816    }
2817
2818    fn pump_pool(&mut self, pool: u16) {
2819        let now = self.now;
2820        // 1. Holds for upcoming fixed starts take free slots first.
2821        loop {
2822            let p = &mut self.pools[pool as usize];
2823            if p.free() == 0 {
2824                break;
2825            }
2826            let Some((hk, k)) = p.pending_holds.pop_first() else {
2827                break;
2828            };
2829            p.held += 1;
2830            p.held_tasks.insert(hk, k);
2831            let f = self.entry_mut(k).fixed.as_mut().unwrap();
2832            f.hold_pending = false;
2833            f.held = true;
2834        }
2835        if self.pools[pool as usize].due.is_empty() {
2836            self.pump_regular(now, pool);
2837            return;
2838        }
2839        // 2a. Due fixed-start tasks that hold a slot start first (there are few of them).
2840        if !self.pools[pool as usize].held_tasks.is_empty() {
2841            let held: Vec<Key> = self.pools[pool as usize]
2842                .held_tasks
2843                .values()
2844                .copied()
2845                .filter(|k| self.entry(*k).loc == Loc::Due)
2846                .collect();
2847            for k in held {
2848                self.start_due(now, pool, k);
2849            }
2850        }
2851        // 2b. Other due fixed-start tasks, earliest first, while slots are free. Walking the
2852        // map in order (instead of copying it) keeps a large run-late backlog linear.
2853        let mut cursor: Option<(Time, u64)> = None;
2854        while self.pools[pool as usize].free() > 0 {
2855            let p = &self.pools[pool as usize];
2856            let next = match cursor {
2857                None => p.due.iter().next(),
2858                Some(c) => p
2859                    .due
2860                    .range((core::ops::Bound::Excluded(c), core::ops::Bound::Unbounded))
2861                    .next(),
2862            };
2863            let Some((&dk, &k)) = next else {
2864                break;
2865            };
2866            cursor = Some(dk);
2867            self.start_due(now, pool, k);
2868        }
2869        self.pump_regular(now, pool);
2870    }
2871
2872    /// 3. Regular work by effective score.
2873    fn pump_regular(&mut self, now: Time, pool: u16) {
2874        while self.pools[pool as usize].free() > 0 {
2875            let Some(k) = self.best_candidate(pool, now) else {
2876                break;
2877            };
2878            self.unqueue(k);
2879            // Unconstrained tasks always find a group here (free slots exist); a task that
2880            // needs labels no free group has waits without blocking the rest of the pool.
2881            let Some(group) = self.select_group(pool, self.entry(k).constraint) else {
2882                self.park_label(k);
2883                continue;
2884            };
2885            if !self.try_acquire(k) {
2886                self.park(k);
2887                continue;
2888            }
2889            self.dispatch(now, pool, k, group);
2890        }
2891    }
2892
2893    /// Try to start a due fixed-start task (in a held slot or a free one).
2894    fn start_due(&mut self, now: Time, pool: u16, k: Key) {
2895        let (held, skip_if, at, seq) = {
2896            let e = self.entry(k);
2897            let f = e.fixed.as_ref().unwrap();
2898            (f.held, f.skip_if_live, f.at, e.seq)
2899        };
2900        if let Some(prev) = skip_if
2901            && self.index.contains_key(&prev)
2902        {
2903            // The schedule's guard moves back to the occurrence that is still running, so
2904            // the next occurrence is checked against it rather than this skipped one.
2905            let e = self.entry(k);
2906            let id = e.id;
2907            let name = e
2908                .tags
2909                .as_ref()
2910                .and_then(|t| t.iter().find(|(k, _)| &**k == "cronoq.recurring"))
2911                .map(|(_, v)| v.clone());
2912            if let Some(r) = name.and_then(|n| self.recurring.get_mut(&n))
2913                && r.last_task == Some(id)
2914            {
2915                r.last_task = Some(prev);
2916            }
2917            self.finish(
2918                k,
2919                TaskStatus::Skipped,
2920                Some("previous occurrence still running".into()),
2921            );
2922            return;
2923        }
2924        if !held && self.pools[pool as usize].free() == 0 {
2925            return;
2926        }
2927        // A held slot is counted in a group's free slots; it stays due (and is retried when
2928        // slots change) while no group with the required labels is free.
2929        let Some(group) = self.select_group(pool, self.entry(k).constraint) else {
2930            return;
2931        };
2932        if !self.try_acquire(k) {
2933            self.schedule_refills(k);
2934            return;
2935        }
2936        self.pools[pool as usize].due.remove(&(at, seq));
2937        self.entry_mut(k).loc = Loc::None;
2938        // Frees the held slot for this dispatch and drops any hold still pending.
2939        self.release_hold(k);
2940        self.dispatch(now, pool, k, group);
2941    }
2942
2943    fn score(&self, e: &Entry<P>, now: Time) -> f64 {
2944        let pc = &self.cfg.priority;
2945        let c = e.class as usize;
2946        let wait = now.diff_secs(e.arrival).max(0.0);
2947        let mut s = pc.base[c] + (pc.aging_rate[c] * wait).min(pc.age_cap[c]);
2948        if e.deadline != Time::MAX {
2949            let u = &self.cfg.urgency;
2950            let d = e.deadline.diff_secs(e.lst).max(0.0);
2951            let h = d.max(secs(u.window)).max(1e-9);
2952            let slack = e.lst.diff_secs(now);
2953            s += if slack >= h {
2954                0.0
2955            } else if slack >= 0.0 {
2956                u.max * (1.0 - slack / h)
2957            } else {
2958                u.max + (u.overdue_rate * -slack).min(u.overdue_cap)
2959            };
2960        }
2961        s
2962    }
2963
2964    fn best_candidate(&self, pool: u16, now: Time) -> Option<Key> {
2965        let p = &self.pools[pool as usize];
2966        let mut best: Option<(f64, u8, u64, Key)> = None;
2967        for c in 0..Priority::COUNT {
2968            let heads = [
2969                p.lanes[c].first(),
2970                p.edf[c].first_key_value().map(|(_, k)| *k),
2971                p.fair
2972                    .as_ref()
2973                    .and_then(|f| f.queues[c].first_key_value().map(|(_, k)| *k)),
2974            ];
2975            for k in heads.into_iter().flatten() {
2976                let e = self.entry(k);
2977                let s = self.score(e, now);
2978                let better = match best {
2979                    None => true,
2980                    Some((bs, bc, bseq, _)) => s > bs || (s == bs && (e.class, e.seq) < (bc, bseq)),
2981                };
2982                if better {
2983                    best = Some((s, e.class, e.seq, k));
2984                }
2985            }
2986        }
2987        best.map(|b| b.3)
2988    }
2989
2990    fn try_acquire(&mut self, k: Key) -> bool {
2991        let Some(res) = self.entry(k).resources.clone() else {
2992            return true;
2993        };
2994        let now = self.now;
2995        for (r, amount) in res.iter() {
2996            let rs = &mut self.resources[r.0 as usize];
2997            rs.refill(now);
2998            if rs.available < *amount as f64 {
2999                return false;
3000            }
3001        }
3002        for (r, amount) in res.iter() {
3003            self.resources[r.0 as usize].available -= *amount as f64;
3004        }
3005        true
3006    }
3007
3008    fn park(&mut self, k: Key) {
3009        let res = self.entry(k).resources.clone().unwrap();
3010        let now = self.now;
3011        let (r, amount) = *res
3012            .iter()
3013            .find(|(r, a)| {
3014                let rs = &mut self.resources[r.0 as usize];
3015                rs.refill(now);
3016                rs.available < *a as f64
3017            })
3018            .unwrap_or(&res[0]);
3019        self.entry_mut(k).loc = Loc::Parked(r);
3020        self.resources[r.0 as usize].waiters.push_back(k);
3021        self.schedule_refill(r.0, amount);
3022    }
3023
3024    fn schedule_refills(&mut self, k: Key) {
3025        if let Some(res) = self.entry(k).resources.clone() {
3026            for (r, a) in res.iter() {
3027                self.schedule_refill(r.0, *a);
3028            }
3029        }
3030    }
3031
3032    fn schedule_refill(&mut self, r: u16, need: u32) {
3033        let now = self.now;
3034        let rs = &mut self.resources[r as usize];
3035        if let ResourceKind::TokenBucket { rate, .. } = rs.kind {
3036            if rate <= 0.0 {
3037                return;
3038            }
3039            let missing = (need as f64 - rs.available).max(0.0);
3040            let wait = Duration::try_from_secs_f64(missing / rate).unwrap_or(Duration::MAX);
3041            let at = now + wait.max(Duration::from_micros(1));
3042            if rs.refill_at.is_none_or(|t| at < t) {
3043                rs.refill_at = Some(at);
3044                self.schedule(at, Timer::Refill(r));
3045            }
3046        }
3047    }
3048
3049    /// Move parked tasks back to their queues, in FIFO order, while the resource can satisfy them.
3050    fn wake_waiters(&mut self, r: u16) {
3051        let now = self.now;
3052        self.resources[r as usize].refill(now);
3053        let mut budget = self.resources[r as usize].available;
3054        while let Some(&k) = self.resources[r as usize].waiters.front() {
3055            let need = match self.slot_entry(k) {
3056                Some(e) if e.loc == Loc::Parked(ResourceId(r)) => e
3057                    .resources
3058                    .as_ref()
3059                    .and_then(|v| v.iter().find(|(x, _)| x.0 == r))
3060                    .map(|x| x.1)
3061                    .unwrap_or(1),
3062                _ => {
3063                    self.resources[r as usize].waiters.pop_front();
3064                    continue;
3065                }
3066            };
3067            if (need as f64) > budget + 1e-9 {
3068                self.schedule_refill(r, need);
3069                break;
3070            }
3071            self.resources[r as usize].waiters.pop_front();
3072            budget -= need as f64;
3073            self.requeue_ready(k);
3074        }
3075    }
3076
3077    fn dispatch(&mut self, now: Time, pool: u16, k: Key, group: u16) {
3078        let lease = LeaseId(self.next_lease);
3079        self.next_lease += 1;
3080        self.remove_pending(k);
3081        let higher_waiting = {
3082            let p = &self.pools[pool as usize];
3083            let c = self.entry(k).class as usize;
3084            (0..c).any(|h| {
3085                !p.lanes[h].is_empty()
3086                    || !p.edf[h].is_empty()
3087                    || p.fair.as_ref().is_some_and(|f| !f.queues[h].is_empty())
3088            })
3089        };
3090        let (vtag, class) = {
3091            let e = self.entry(k);
3092            (e.vtag, e.class as usize)
3093        };
3094        if let Some(f) = &mut self.pools[pool as usize].fair {
3095            f.vtime[class] = f.vtime[class].max(vtag);
3096        }
3097        let (id, attempt, timeout_ns, first, arrival, fixed) = {
3098            let e = self.entry_mut(k);
3099            e.attempt += 1;
3100            e.status = TaskStatus::Running;
3101            e.lease = Some(lease);
3102            (
3103                e.id,
3104                e.attempt,
3105                e.timeout_ns,
3106                e.attempt == 1,
3107                e.arrival,
3108                e.fixed.as_ref().map(|f| (f.at, f.tolerance, f.res_start)),
3109            )
3110        };
3111        if let Some((_, _, res_start)) = fixed {
3112            self.pools[pool as usize].unstarted.remove(id, res_start);
3113        }
3114        let lease_ttl = {
3115            let p = &mut self.pools[pool as usize];
3116            p.account(now);
3117            p.busy += 1;
3118            p.groups[group as usize].busy += 1;
3119            let b = p.window.at(now);
3120            b.dispatched += 1;
3121            if higher_waiting {
3122                b.inversions += 1;
3123            }
3124            if first {
3125                b.wait.record(now.saturating_sub(arrival));
3126            }
3127            if let (Some((at, tol, _)), true) = (fixed, first) {
3128                // Only the first start counts: retries are not late fixed starts.
3129                b.fixed_total += 1;
3130                if now.diff_secs(at).abs() <= tol.as_secs_f64() {
3131                    b.fixed_hits += 1;
3132                }
3133            }
3134            p.cfg.lease_ttl
3135        };
3136        self.totals.dispatched += 1;
3137        let expires = lease_ttl.map(|t| now + t).unwrap_or(Time::MAX);
3138        self.leases.insert(
3139            lease,
3140            Lease {
3141                key: k,
3142                id,
3143                pool,
3144                group,
3145                started: now,
3146                expires,
3147            },
3148        );
3149        if timeout_ns > 0 {
3150            self.schedule(
3151                now + Duration::from_nanos(timeout_ns),
3152                Timer::AttemptTimeout(lease),
3153            );
3154        }
3155        if expires != Time::MAX {
3156            self.schedule(expires, Timer::LeaseExpiry(lease));
3157        }
3158        let e = self.entry(k);
3159        let d = Dispatch {
3160            lease,
3161            id,
3162            pool: PoolId(pool),
3163            attempt,
3164            payload: e.payload.clone(),
3165            kind: self.kinds[e.kind as usize].clone(),
3166            key: e.key.clone(),
3167            timeout: (timeout_ns > 0).then(|| Duration::from_nanos(timeout_ns)),
3168            deadline: (e.deadline != Time::MAX).then_some(e.deadline),
3169            tags: e.tags.clone().unwrap_or_else(|| self.empty_tags.clone()),
3170            group: self.pools[pool as usize].groups[group as usize]
3171                .labels
3172                .clone(),
3173        };
3174        let key = e.key.clone();
3175        self.actions.push_back(Action::Dispatch(d));
3176        self.push_status(id, pool, key, TaskStatus::Running, attempt, None, false);
3177    }
3178
3179    // -----------------------------------------------------------------------------------------
3180    // Autoscaling
3181    // -----------------------------------------------------------------------------------------
3182
3183    fn autoscale(&mut self, now: Time, pool: u16) {
3184        let Some(cfg) = self.pools[pool as usize].cfg.autoscale.clone() else {
3185            return;
3186        };
3187        self.schedule(now + cfg.interval, Timer::Autoscale(pool));
3188        let p = &mut self.pools[pool as usize];
3189        p.account(now);
3190        let dt = if p.last_eval == Time::ZERO {
3191            0.0
3192        } else {
3193            now.diff_secs(p.last_eval)
3194        };
3195        p.last_eval = now;
3196        let mut backlog = [0u64; Priority::COUNT];
3197        for (c, b) in backlog.iter_mut().enumerate() {
3198            let fair = p.fair.as_ref().map_or(0, |f| f.queues[c].len());
3199            *b = (p.lanes[c].len() + p.edf[c].len() + fair + p.retrying[c]) as u64;
3200        }
3201        backlog[0] += p.due.len() as u64;
3202        let signals = Signals {
3203            arrivals: core::mem::take(&mut p.arrivals),
3204            dt,
3205            service_secs: p
3206                .stats
3207                .mean
3208                .get()
3209                .unwrap_or(p.cfg.default_duration.as_secs_f64()),
3210            backlog,
3211            busy: p.busy,
3212            capacity: p.capacity,
3213            // Running fixed tasks are already counted in `busy`.
3214            reserved: p.unstarted.max_overlap(now, now + cfg.reservation_horizon),
3215            min: p.cfg.min_workers,
3216            max: p.cfg.max_workers,
3217        };
3218        if let Some(desired) = p.autoscale.as_mut().unwrap().evaluate(&cfg, &signals, now) {
3219            self.actions.push_back(Action::ScaleTo {
3220                pool: PoolId(pool),
3221                desired,
3222            });
3223        }
3224    }
3225
3226    // -----------------------------------------------------------------------------------------
3227    // Snapshot
3228    // -----------------------------------------------------------------------------------------
3229
3230    /// Advance to `now` (firing due timers) and return current state and metrics.
3231    pub fn snapshot(&mut self, now: Time) -> Snapshot {
3232        self.advance(now);
3233        // Timers fired by `advance` may have made work ready.
3234        self.pump();
3235        let mut pools = Vec::with_capacity(self.pools.len());
3236        for p in &mut self.pools {
3237            p.account(now);
3238            let w = p.window.total(now);
3239            let mut pending = [0usize; Priority::COUNT];
3240            pending.copy_from_slice(&p.pending_class);
3241            pools.push(PoolSnapshot {
3242                name: p.cfg.name.clone(),
3243                capacity: p.capacity,
3244                busy: p.busy,
3245                held: p.held,
3246                desired: p
3247                    .autoscale
3248                    .as_ref()
3249                    .and_then(|a| a.target)
3250                    .unwrap_or(p.capacity),
3251                pending_by_class: pending,
3252                ready: p.ready_len(),
3253                reservations: p.reservations.len(),
3254                window: p.window.span(),
3255                dispatched: w.dispatched,
3256                succeeded: w.succeeded,
3257                failed: w.failed,
3258                retried: w.retried,
3259                priority_adherence: if w.dispatched == 0 {
3260                    1.0
3261                } else {
3262                    1.0 - w.inversions as f64 / w.dispatched as f64
3263                },
3264                fixed_start_hit_rate: if w.fixed_total == 0 {
3265                    1.0
3266                } else {
3267                    w.fixed_hits as f64 / w.fixed_total as f64
3268                },
3269                utilization: if w.capacity_secs > 0.0 {
3270                    (w.busy_secs / w.capacity_secs).min(1.0)
3271                } else {
3272                    0.0
3273                },
3274                schedule_efficiency: if w.capacity_secs > 0.0 {
3275                    (w.work_secs / w.capacity_secs).min(1.0)
3276                } else {
3277                    0.0
3278                },
3279                wait_mean: w.wait.mean(),
3280                wait_p50: w.wait.quantile(0.5),
3281                wait_p90: w.wait.quantile(0.9),
3282                wait_p99: w.wait.quantile(0.99),
3283                run_p50: w.run.quantile(0.5),
3284                run_p99: w.run.quantile(0.99),
3285                worker_seconds: p.worker_secs,
3286                cost: p.cost,
3287                groups: p
3288                    .groups
3289                    .iter()
3290                    .map(|g| GroupSnapshot {
3291                        labels: g.labels.to_vec(),
3292                        capacity: g.capacity,
3293                        busy: g.busy,
3294                    })
3295                    .collect(),
3296            });
3297        }
3298        Snapshot {
3299            at: now,
3300            pools,
3301            live_tasks: self.index.len(),
3302            running: self.leases.len(),
3303            pending: self.total_pending,
3304            totals: self.totals.clone(),
3305        }
3306    }
3307
3308    // -----------------------------------------------------------------------------------------
3309    // Slab
3310    // -----------------------------------------------------------------------------------------
3311
3312    fn slab_insert(&mut self, e: Entry<P>) -> Key {
3313        if let Some(idx) = self.free_slots.pop() {
3314            let s = &mut self.slots[idx as usize];
3315            s.entry = Some(e);
3316            Key {
3317                idx,
3318                generation: s.generation,
3319            }
3320        } else {
3321            let idx = self.slots.len() as u32;
3322            self.slots.push(Slot {
3323                generation: 0,
3324                entry: Some(e),
3325            });
3326            Key { idx, generation: 0 }
3327        }
3328    }
3329
3330    fn slab_remove(&mut self, k: Key) -> Entry<P> {
3331        let s = &mut self.slots[k.idx as usize];
3332        debug_assert_eq!(s.generation, k.generation);
3333        let e = s.entry.take().expect("live entry");
3334        s.generation = s.generation.wrapping_add(1);
3335        self.free_slots.push(k.idx);
3336        e
3337    }
3338
3339    #[inline]
3340    fn slot_entry(&self, k: Key) -> Option<&Entry<P>> {
3341        let s = self.slots.get(k.idx as usize)?;
3342        if s.generation != k.generation {
3343            return None;
3344        }
3345        s.entry.as_ref()
3346    }
3347
3348    #[inline]
3349    fn entry(&self, k: Key) -> &Entry<P> {
3350        self.slot_entry(k).expect("stale task key")
3351    }
3352
3353    #[inline]
3354    fn entry_mut(&mut self, k: Key) -> &mut Entry<P> {
3355        let s = &mut self.slots[k.idx as usize];
3356        debug_assert_eq!(s.generation, k.generation);
3357        s.entry.as_mut().expect("stale task key")
3358    }
3359
3360    fn entry_by_id(&self, id: TaskId) -> Option<&Entry<P>> {
3361        self.index.get(&id).and_then(|k| self.slot_entry(*k))
3362    }
3363
3364    // -----------------------------------------------------------------------------------------
3365    // Invariants (for tests and simulation)
3366    // -----------------------------------------------------------------------------------------
3367
3368    /// Panics if internal invariants are violated. Intended for tests.
3369    #[doc(hidden)]
3370    pub fn check_invariants(&self) {
3371        let mut busy = vec![0u32; self.pools.len()];
3372        let mut group_busy: Vec<Vec<u32>> =
3373            self.pools.iter().map(|p| vec![0; p.groups.len()]).collect();
3374        for (lid, l) in &self.leases {
3375            let e = self.slot_entry(l.key).expect("lease points to live task");
3376            assert_eq!(e.status, TaskStatus::Running);
3377            assert_eq!(e.lease, Some(*lid));
3378            busy[l.pool as usize] += 1;
3379            group_busy[l.pool as usize][l.group as usize] += 1;
3380        }
3381        for (i, p) in self.pools.iter().enumerate() {
3382            assert_eq!(
3383                p.capacity,
3384                p.groups.iter().map(|g| g.capacity).sum::<u32>(),
3385                "pool capacity is the sum of its groups"
3386            );
3387            let gb: Vec<u32> = p.groups.iter().map(|g| g.busy).collect();
3388            assert_eq!(
3389                gb, group_busy[i],
3390                "group busy counts match running attempts"
3391            );
3392        }
3393        let mut held = vec![0u32; self.pools.len()];
3394        let mut pending = vec![0usize; self.pools.len()];
3395        let mut queued = vec![0usize; self.pools.len()];
3396        let mut pending_class = vec![[0usize; Priority::COUNT]; self.pools.len()];
3397        let mut retrying = vec![[0usize; Priority::COUNT]; self.pools.len()];
3398        let mut reservations = vec![0usize; self.pools.len()];
3399        let mut live_parents: FxHashMap<TaskId, usize> = FxHashMap::default();
3400        for k in self.index.values() {
3401            for (child, _) in &self.entry(*k).dependents {
3402                *live_parents.entry(*child).or_default() += 1;
3403            }
3404        }
3405        let mut awaiting: FxHashMap<TaskId, usize> = FxHashMap::default();
3406        for list in self.awaits.values() {
3407            for (id, _) in list {
3408                if self.index.contains_key(id) {
3409                    *awaiting.entry(*id).or_default() += 1;
3410                }
3411            }
3412        }
3413        for (id, k) in &self.index {
3414            let e = self.slot_entry(*k).expect("index points to live slot");
3415            assert_eq!(e.id, *id);
3416            assert!(!e.status.is_terminal());
3417            if e.status == TaskStatus::Running {
3418                assert!(e.lease.is_some() && !e.pending);
3419            } else {
3420                assert!(e.pending, "non-running task {id} not counted as pending");
3421                pending[e.pool as usize] += 1;
3422            }
3423            if let Some(f) = &e.fixed
3424                && f.held
3425            {
3426                held[e.pool as usize] += 1;
3427            }
3428            let p = &self.pools[e.pool as usize];
3429            match e.loc {
3430                Loc::Lane => {
3431                    assert_eq!(p.lanes[e.class as usize].get(e.seq), Some(*k));
3432                    queued[e.pool as usize] += 1;
3433                }
3434                Loc::Edf => {
3435                    assert_eq!(p.edf[e.class as usize].get(&(e.lst, e.seq)), Some(k));
3436                    queued[e.pool as usize] += 1;
3437                }
3438                Loc::Due => {
3439                    assert_eq!(p.due.get(&(e.fixed.as_ref().unwrap().at, e.seq)), Some(k));
3440                    queued[e.pool as usize] += 1;
3441                }
3442                Loc::Fair => {
3443                    let f = p.fair.as_ref().expect("fair queue in a fair pool");
3444                    assert_eq!(f.queues[e.class as usize].get(&(e.vtag, e.seq)), Some(k));
3445                    queued[e.pool as usize] += 1;
3446                }
3447                Loc::LabelWait => assert!(
3448                    p.label_waiters
3449                        .get(&e.constraint)
3450                        .is_some_and(|q| q.contains(k)),
3451                    "task {id} waiting for labels is missing from its waiter queue"
3452                ),
3453                Loc::Parked(_) | Loc::None => {}
3454            }
3455            if matches!(
3456                e.loc,
3457                Loc::Lane | Loc::Edf | Loc::Due | Loc::Parked(_) | Loc::LabelWait | Loc::Fair
3458            ) {
3459                assert_eq!(e.status, TaskStatus::Ready);
3460            }
3461            if e.status == TaskStatus::Blocked {
3462                assert!(
3463                    e.unmet > 0 || e.fixed.is_some(),
3464                    "blocked task {id} without unmet deps"
3465                );
3466            }
3467            if e.pending {
3468                pending_class[e.pool as usize][e.class as usize] += 1;
3469            }
3470            if e.status == TaskStatus::Retrying {
3471                retrying[e.pool as usize][e.class as usize] += 1;
3472            }
3473            if let Loc::Parked(r) = e.loc {
3474                assert!(
3475                    self.resources[r.0 as usize].waiters.contains(k),
3476                    "parked task {id} missing from its waiter queue"
3477                );
3478            }
3479            if let Some(f) = &e.fixed {
3480                assert!(
3481                    !(f.held && f.hold_pending),
3482                    "task {id} both held and pending a hold"
3483                );
3484                assert_eq!(
3485                    p.pending_holds.get(&(f.at, e.seq)) == Some(k),
3486                    f.hold_pending,
3487                    "task {id} pending-hold mismatch"
3488                );
3489                assert_eq!(
3490                    p.held_tasks.get(&(f.at, e.seq)) == Some(k),
3491                    f.held,
3492                    "task {id} held mismatch"
3493                );
3494                assert!(
3495                    p.reservations.contains(e.id, f.res_start),
3496                    "fixed task {id} lost its reservation"
3497                );
3498                assert_eq!(
3499                    p.unstarted.contains(e.id, f.res_start),
3500                    e.attempt == 0 && e.status != TaskStatus::Running,
3501                    "task {id} unstarted-reservation mismatch"
3502                );
3503                reservations[e.pool as usize] += 1;
3504            }
3505            // Every unmet dependency is a live parent or an awaited key.
3506            let parents = live_parents.get(id).copied().unwrap_or(0);
3507            let awaited = awaiting.get(id).copied().unwrap_or(0);
3508            assert_eq!(e.awaiting as usize, awaited, "task {id} awaiting mismatch");
3509            if e.status == TaskStatus::Blocked && e.fixed.is_none() {
3510                assert_eq!(
3511                    e.unmet as usize,
3512                    parents + awaited,
3513                    "task {id} unmet mismatch"
3514                );
3515            }
3516        }
3517        for (i, p) in self.pools.iter().enumerate() {
3518            assert_eq!(p.busy, busy[i], "pool {} busy mismatch", p.cfg.name);
3519            assert_eq!(p.held, held[i], "pool {} held mismatch", p.cfg.name);
3520            assert_eq!(
3521                p.pending, pending[i],
3522                "pool {} pending mismatch",
3523                p.cfg.name
3524            );
3525            assert_eq!(
3526                p.ready_len(),
3527                queued[i],
3528                "pool {} queue mismatch",
3529                p.cfg.name
3530            );
3531            assert_eq!(
3532                p.pending_class, pending_class[i],
3533                "pool {} per-class pending",
3534                p.cfg.name
3535            );
3536            assert_eq!(
3537                p.retrying, retrying[i],
3538                "pool {} retrying mismatch",
3539                p.cfg.name
3540            );
3541            assert_eq!(
3542                p.reservations.len(),
3543                reservations[i],
3544                "pool {} stray reservations",
3545                p.cfg.name
3546            );
3547            assert_eq!(
3548                p.held_tasks.len() as u32,
3549                p.held,
3550                "pool {} held_tasks mismatch",
3551                p.cfg.name
3552            );
3553        }
3554        assert_eq!(self.total_pending, pending.iter().sum::<usize>());
3555        for c in 0..Priority::COUNT {
3556            let sum: usize = pending_class.iter().map(|pc| pc[c]).sum();
3557            assert_eq!(
3558                self.pending_class[c], sum,
3559                "global per-class pending mismatch"
3560            );
3561        }
3562        // Semaphore permits in use equal what running attempts hold.
3563        let mut in_use = vec![0u64; self.resources.len()];
3564        for l in self.leases.values() {
3565            if let Some(res) = &self.entry(l.key).resources {
3566                for (r, a) in res.iter() {
3567                    in_use[r.0 as usize] += *a as u64;
3568                }
3569            }
3570        }
3571        for (i, r) in self.resources.iter().enumerate() {
3572            if let ResourceKind::Semaphore { permits } = r.kind {
3573                let used = permits as f64 - r.available;
3574                assert!(
3575                    (used - in_use[i] as f64).abs() < 1e-6,
3576                    "semaphore {} leaks permits: {used} in use, {} held",
3577                    r.name,
3578                    in_use[i]
3579                );
3580            }
3581        }
3582        for r in &self.resources {
3583            assert!(
3584                r.available <= r.capacity + 1e-9,
3585                "resource {} over capacity",
3586                r.name
3587            );
3588            assert!(r.available >= -1e-9, "resource {} negative", r.name);
3589        }
3590    }
3591}
3592
3593/// Duration in nanoseconds, at least 1 and saturating at `u64::MAX`.
3594#[inline]
3595fn nanos(d: Duration) -> u64 {
3596    d.as_nanos().clamp(1, u64::MAX as u128) as u64
3597}
3598
3599#[inline]
3600fn edge_satisfied(cond: EdgeCond, parent: TaskStatus) -> bool {
3601    match cond {
3602        EdgeCond::OnSuccess => parent == TaskStatus::Succeeded,
3603        EdgeCond::OnComplete => parent.is_terminal(),
3604        EdgeCond::OnFailure => matches!(parent, TaskStatus::Failed | TaskStatus::DeadLettered),
3605    }
3606}
3607
3608/// Key of a recurring occurrence: the template's key, `@`, and the start in UTC (RFC 3339).
3609fn occurrence_key(prefix: &str, epoch_unix_nanos: i64, at: Time) -> Arc<str> {
3610    let wall = epoch_unix_nanos as i128 + at.0 as i128;
3611    let secs = wall.div_euclid(1_000_000_000) as i64;
3612    match chrono::DateTime::from_timestamp(secs, 0) {
3613        Some(t) => format!("{prefix}@{}", t.format("%Y-%m-%dT%H:%M:%SZ")).into(),
3614        None => format!("{prefix}@{secs}").into(),
3615    }
3616}