use std::collections::{BTreeMap, HashMap, VecDeque};
use crate::scheduler::admission::Admission;
use crate::scheduler::budget::{BoundedJob, Step, WorkBudget};
use crate::scheduler::classes::{ClassAging, WorkClass};
pub type JobId = u64;
pub type ClientId = String;
pub type ProjectId = String;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct QueueKey {
pub client: ClientId,
pub project: ProjectId,
pub class: WorkClass,
}
impl QueueKey {
pub fn new(client: impl Into<String>, project: impl Into<String>, class: WorkClass) -> Self {
Self {
client: client.into(),
project: project.into(),
class,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TargetRef {
pub project: ProjectId,
pub source_fingerprint: String,
}
impl TargetRef {
pub fn new(project: impl Into<String>, source_fingerprint: impl Into<String>) -> Self {
Self {
project: project.into(),
source_fingerprint: source_fingerprint.into(),
}
}
}
pub struct DrrEntry<P> {
id: JobId,
key: QueueKey,
target: Option<TargetRef>,
job: Box<dyn BoundedJob<Progress = P>>,
waiters: usize,
last_progress: Option<P>,
consecutive_failures: usize,
retry_not_before_tick: u64,
}
const MAX_CONSECUTIVE_FAILURES: usize = 8;
const MAX_RETRY_BACKOFF_TICKS: u64 = 128;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TickOutcome<P> {
pub job_id: JobId,
pub class: WorkClass,
pub step: Step<P>,
}
struct ClassQueue<P> {
entries: VecDeque<DrrEntry<P>>,
deficit: u64,
}
impl<P> ClassQueue<P> {
fn has_ready(&self) -> bool {
!self.entries.is_empty()
}
}
pub struct DrrQueue<P> {
queues: BTreeMap<QueueKey, ClassQueue<P>>,
ager: ClassAging,
next_job_id: JobId,
results: HashMap<JobId, Option<P>>,
failures: HashMap<JobId, String>,
completed_by_target: HashMap<TargetRef, JobId>,
quantum: u64,
tick_count: u64,
admit: Box<dyn Fn(WorkClass, usize) -> Admission + Send + Sync>,
}
impl<P: Send + Clone> DrrQueue<P> {
pub fn new() -> Self {
Self::with_policy(ClassAging::default(), |_, _| Admission::Admit)
}
pub fn with_policy<F>(ager: ClassAging, admit: F) -> Self
where
F: Fn(WorkClass, usize) -> Admission + Send + Sync + 'static,
{
Self {
queues: BTreeMap::new(),
ager,
next_job_id: 0,
results: HashMap::new(),
failures: HashMap::new(),
completed_by_target: HashMap::new(),
quantum: 1,
tick_count: 0,
admit: Box::new(admit),
}
}
pub fn aging(&self) -> &ClassAging {
&self.ager
}
pub fn enqueue(
&mut self,
key: QueueKey,
coalesce: Option<TargetRef>,
job: Box<dyn BoundedJob<Progress = P>>,
) -> JobId {
if let Some(target) = &coalesce {
if let Some(id) = self.completed_by_target.get(target) {
return *id;
}
if let Some(id) = self.find_live_for_target(target) {
return id;
}
}
let id = self.next_job_id;
self.next_job_id += 1;
self.ager.note_eligible(key.class);
let queue = self
.queues
.entry(key.clone())
.or_insert_with(|| ClassQueue {
entries: VecDeque::new(),
deficit: 0,
});
queue.entries.push_back(DrrEntry {
id,
key,
target: coalesce,
job,
waiters: 1,
last_progress: None,
consecutive_failures: 0,
retry_not_before_tick: 0,
});
id
}
pub fn queue_len(&self, key: &QueueKey) -> usize {
self.queues.get(key).map_or(0, |q| q.entries.len())
}
pub fn is_done(&self, id: JobId) -> bool {
self.results.contains_key(&id) || self.failures.contains_key(&id)
}
pub fn failure(&self, id: JobId) -> Option<&str> {
self.failures.get(&id).map(String::as_str)
}
pub fn result(&self, id: JobId) -> Option<&P> {
self.results.get(&id).and_then(|p| p.as_ref())
}
pub fn waiters(&self, id: JobId) -> usize {
self.queues
.values()
.flat_map(|q| q.entries.iter())
.find(|e| e.id == id)
.map_or(0, |e| e.waiters)
}
fn find_live_for_target(&mut self, target: &TargetRef) -> Option<JobId> {
for (key, queue) in self.queues.iter_mut() {
if key.project != target.project {
continue;
}
for entry in queue.entries.iter_mut() {
if entry.target.as_ref() == Some(target) {
entry.waiters += 1;
return Some(entry.id);
}
}
}
None
}
pub fn tick(&mut self, budget: WorkBudget) -> Option<TickOutcome<P>> {
self.tick_count += 1;
self.ager.advance();
for (key, queue) in self.queues.iter_mut() {
let weight = self.ager.effective_weight(key.class);
queue.deficit = queue
.deficit
.saturating_add(self.quantum.saturating_mul(weight));
}
let mut candidates: Vec<(QueueKey, u64)> = self
.queues
.iter()
.filter(|(_, q)| q.has_ready())
.map(|(key, q)| (key.clone(), q.deficit))
.collect();
candidates.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
for (key, _deficit) in candidates {
let gated = matches!(key.class, WorkClass::IndexChunk | WorkClass::Maintenance);
let mut step_budget = budget;
if gated {
let estimate = self
.queues
.get(&key)
.and_then(|q| q.entries.front())
.map_or(0, |e| e.job.estimated_next_bytes());
match (self.admit)(key.class, estimate) {
Admission::Admit => {}
Admission::Reduce { batch_shape } => {
if batch_shape.max_items == 0 {
continue; }
step_budget.max_items = batch_shape.max_items;
step_budget.max_input_bytes =
step_budget.max_input_bytes.min(batch_shape.max_items);
step_budget.max_output_bytes =
step_budget.max_output_bytes.min(batch_shape.max_items);
}
Admission::Defer => continue, }
}
if let Some(outcome) = self.run_front(key, step_budget) {
return Some(outcome);
}
}
None
}
fn run_front(&mut self, key: QueueKey, budget: WorkBudget) -> Option<TickOutcome<P>> {
let queue = self.queues.get_mut(&key)?;
let mut entry = queue.entries.pop_front()?;
if self.tick_count < entry.retry_not_before_tick {
self.queues.get_mut(&key)?.entries.push_back(entry);
return None;
}
let job_id = entry.id;
let class = entry.key.class;
let step = match entry.job.step(budget) {
Ok(step) => step,
Err(error) => {
if self.defer_failed_step(&mut entry, error) {
self.queues.get_mut(&key)?.entries.push_back(entry);
}
return None;
}
};
match step {
Step::Yield(progress) => {
entry.last_progress = Some(progress.clone());
entry.consecutive_failures = 0;
entry.retry_not_before_tick = 0;
self.queues.get_mut(&key)?.entries.push_front(entry);
self.queues.get_mut(&key)?.deficit = self
.queues
.get_mut(&key)?
.deficit
.saturating_sub(self.quantum);
self.ager.note_served(class);
Some(TickOutcome {
job_id,
class,
step: Step::Yield(progress),
})
}
Step::Complete => {
let progress = entry.last_progress.take();
if let Some(target) = entry.target {
self.completed_by_target.insert(target, job_id);
}
self.results.insert(job_id, progress);
self.queues.get_mut(&key)?.deficit = self
.queues
.get_mut(&key)?
.deficit
.saturating_sub(self.quantum);
self.ager.note_served(class);
Some(TickOutcome {
job_id,
class,
step: Step::Complete,
})
}
}
}
fn defer_failed_step(&mut self, entry: &mut DrrEntry<P>, error: anyhow::Error) -> bool {
let job_id = entry.id;
let class = entry.key.class;
entry.consecutive_failures += 1;
let message = format!("{error:#}");
if entry.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
tracing::warn!(
job = job_id,
class = ?class,
attempts = entry.consecutive_failures,
"scheduler job failed terminally: {message}"
);
self.failures.insert(job_id, message);
if let Some(queue) = self.queues.get_mut(&entry.key) {
queue.deficit = queue.deficit.saturating_sub(self.quantum);
}
self.ager.note_served(class);
return false;
}
let backoff = MAX_RETRY_BACKOFF_TICKS.min(1u64 << entry.consecutive_failures.min(7));
entry.retry_not_before_tick = self.tick_count + backoff;
tracing::debug!(
job = job_id,
class = ?class,
attempt = entry.consecutive_failures,
retry_in_ticks = backoff,
"scheduler step failed; backing off: {message}"
);
true
}
}
#[cfg(test)]
mod test {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
struct CountingJob {
total: usize,
processed: usize,
steps: Arc<AtomicUsize>,
}
impl CountingJob {
fn new(total: usize, steps: Arc<AtomicUsize>) -> Self {
Self {
total,
processed: 0,
steps,
}
}
}
impl BoundedJob for CountingJob {
type Progress = usize;
fn step(&mut self, budget: WorkBudget) -> anyhow::Result<Step<Self::Progress>> {
self.steps.fetch_add(1, Ordering::SeqCst);
if self.processed >= self.total {
return Ok(Step::Complete);
}
let take = budget.max_items.max(1).min(self.total - self.processed);
self.processed += take;
if self.processed >= self.total {
Ok(Step::Complete)
} else {
Ok(Step::Yield(self.processed))
}
}
fn estimated_next_bytes(&self) -> usize {
if self.processed >= self.total {
0
} else {
(self.total - self.processed) * 8
}
}
}
fn one_item_budget() -> WorkBudget {
WorkBudget::new(usize::MAX, usize::MAX, 1, Duration::from_secs(3600))
}
fn index_key(project: &str) -> QueueKey {
QueueKey::new("client-a", project, WorkClass::IndexChunk)
}
#[test]
fn test_drr_fair_interleave() {
let mut queue = DrrQueue::<usize>::new();
let a_steps = Arc::new(AtomicUsize::new(0));
let b_steps = Arc::new(AtomicUsize::new(0));
let job_a: JobId = queue.enqueue(
index_key("project-a"),
None,
Box::new(CountingJob::new(10, a_steps.clone())),
);
let job_b: JobId = queue.enqueue(
index_key("project-b"),
None,
Box::new(CountingJob::new(10, b_steps.clone())),
);
let mut steps_a = 0usize;
let mut steps_b = 0usize;
let mut rounds = 0;
while !queue.is_done(job_a) || !queue.is_done(job_b) {
match queue.tick(one_item_budget()) {
Some(outcome) => {
rounds += 1;
if outcome.job_id == job_a {
steps_a += 1;
} else if outcome.job_id == job_b {
steps_b += 1;
}
}
None => panic!("queue reported no ready work with two live jobs"),
}
}
assert_eq!(
steps_a + steps_b,
20,
"both 10-step jobs complete in 20 steps"
);
assert!(
steps_a >= 7 && steps_b >= 7,
"fair interleave violated: a={steps_a}, b={steps_b}"
);
assert!(
!(steps_a == 0 && steps_b > 5 || steps_b == 0 && steps_a > 5),
"one project fully starved: a={steps_a}, b={steps_b}"
);
assert!(rounds <= 20);
}
#[test]
fn test_same_project_coalesce() {
let mut queue = DrrQueue::<usize>::new();
let steps = Arc::new(AtomicUsize::new(0));
let target = TargetRef {
project: "project-p".to_string(),
source_fingerprint: "gen-fp-001".to_string(),
};
let key = index_key("project-p");
let first = queue.enqueue(
key.clone(),
Some(target.clone()),
Box::new(CountingJob::new(10, steps.clone())),
);
let second = queue.enqueue(
key.clone(),
Some(target),
Box::new(CountingJob::new(10, steps.clone())),
);
assert_eq!(queue.queue_len(&key), 1);
assert_eq!(first, second);
let mut guard = 0;
while !queue.is_done(first) {
assert!(queue.tick(one_item_budget()).is_some());
guard += 1;
assert!(guard < 100, "job never completes");
}
assert_eq!(
steps.load(Ordering::SeqCst),
10,
"coalesced duplicate must not execute work"
);
assert_eq!(
queue.result(first),
queue.result(second),
"both callers see the shared result"
);
assert!(queue.is_done(first) && queue.is_done(second));
}
#[test]
fn test_same_project_coalesce_after_completion() {
let mut queue = DrrQueue::<usize>::new();
let steps = Arc::new(AtomicUsize::new(0));
let target = TargetRef {
project: "project-p".to_string(),
source_fingerprint: "gen-fp-001".to_string(),
};
let key = index_key("project-p");
let first = queue.enqueue(
key.clone(),
Some(target.clone()),
Box::new(CountingJob::new(10, steps.clone())),
);
while !queue.is_done(first) {
queue.tick(one_item_budget());
}
assert_eq!(steps.load(Ordering::SeqCst), 10);
let late = queue.enqueue(
key.clone(),
Some(target),
Box::new(CountingJob::new(10, steps.clone())),
);
assert_eq!(late, first, "late duplicate reuses completed job id");
assert!(queue.is_done(late));
assert_eq!(steps.load(Ordering::SeqCst), 10, "no new work executed");
assert_eq!(
queue.result(late),
queue.result(first),
"late caller sees the shared result"
);
}
#[test]
fn test_aging_boost_starved_class() {
let mut queue = DrrQueue::<usize>::with_policy(
crate::scheduler::classes::ClassAging::new(100, 5000),
|_, _| Admission::Admit,
);
let read_steps = Arc::new(AtomicUsize::new(0));
let read_key = QueueKey::new("client-a", "project-a", WorkClass::InteractiveRead);
queue.enqueue(
read_key,
None,
Box::new(CountingJob::new(10_000, read_steps.clone())),
);
let maint_key = QueueKey::new("client-a", "project-a", WorkClass::Maintenance);
let maint_steps = Arc::new(AtomicUsize::new(0));
let maint: JobId = queue.enqueue(
maint_key,
None,
Box::new(CountingJob::new(1, maint_steps.clone())),
);
let mut no_aging = DrrQueue::<usize>::with_policy(
crate::scheduler::classes::ClassAging::new(1_000_000, 0),
|_, _| Admission::Admit,
);
no_aging.enqueue(
QueueKey::new("client-a", "project-a", WorkClass::InteractiveRead),
None,
Box::new(CountingJob::new(10_000, Arc::new(AtomicUsize::new(0)))),
);
let maint_no_aging: JobId = no_aging.enqueue(
QueueKey::new("client-a", "project-a", WorkClass::Maintenance),
None,
Box::new(CountingJob::new(1, Arc::new(AtomicUsize::new(0)))),
);
for _ in 0..500 {
no_aging.tick(one_item_budget());
}
assert!(
!no_aging.is_done(maint_no_aging),
"without aging, maintenance must never be selected under read load"
);
let mut selected_at = None;
for t in 1..=250 {
match queue.tick(one_item_budget()) {
Some(outcome) if outcome.job_id == maint => {
selected_at = Some(t);
break;
}
_ => {}
}
}
let selected_at = selected_at.expect("aging must select the starved maintenance job");
assert!(
selected_at <= 200,
"aging boost must select starved class within 100 ticks of the 100-tick threshold (selected at {selected_at})"
);
assert!(
queue.is_done(maint),
"maintenance job completed once selected"
);
}
#[test]
fn test_failing_job_reaches_terminal_failure_after_bounded_retries() {
struct AlwaysFails {
attempts: Arc<AtomicUsize>,
}
impl BoundedJob for AlwaysFails {
type Progress = usize;
fn step(&mut self, _budget: WorkBudget) -> anyhow::Result<Step<usize>> {
self.attempts.fetch_add(1, Ordering::SeqCst);
Err(anyhow::anyhow!("deterministic failure"))
}
fn estimated_next_bytes(&self) -> usize {
8
}
}
let mut queue = DrrQueue::<usize>::new();
let attempts = Arc::new(AtomicUsize::new(0));
let id = queue.enqueue(
index_key("project-a"),
None,
Box::new(AlwaysFails {
attempts: attempts.clone(),
}),
);
let mut ticks = 0;
while !queue.is_done(id) {
ticks += 1;
assert!(ticks <= 1000, "job must terminate within 1000 ticks");
let _ = queue.tick(one_item_budget());
}
assert_eq!(
attempts.load(Ordering::SeqCst),
MAX_CONSECUTIVE_FAILURES,
"exactly the retry cap worth of attempts, not one per tick"
);
assert!(
queue
.failure(id)
.is_some_and(|m| m.contains("deterministic failure"))
);
assert!(
queue.queue_len(&index_key("project-a")) == 0,
"terminally failed entry leaves the queue"
);
}
#[test]
fn test_terminal_failure_drains_one_quantum_of_deficit() {
struct AlwaysFails;
impl BoundedJob for AlwaysFails {
type Progress = usize;
fn step(&mut self, _budget: WorkBudget) -> anyhow::Result<Step<usize>> {
Err(anyhow::anyhow!("deterministic failure"))
}
fn estimated_next_bytes(&self) -> usize {
8
}
}
let mut queue = DrrQueue::<usize>::new();
let key = index_key("project-a");
let class = key.class;
let id = queue.enqueue(key.clone(), None, Box::new(AlwaysFails));
let _ = queue.tick(one_item_budget());
let deficit_after_backoff = queue.queues.get(&key).expect("queue kept").deficit;
{
let entry = queue
.queues
.get_mut(&key)
.expect("queue kept")
.entries
.front_mut()
.expect("entry still queued");
entry.consecutive_failures = MAX_CONSECUTIVE_FAILURES - 1;
entry.retry_not_before_tick = 0;
}
let _ = queue.tick(one_item_budget());
assert!(queue.is_done(id), "job reached terminal failure");
let weight = queue.ager.effective_weight(class);
let expected = deficit_after_backoff + queue.quantum * (weight - 1);
assert_eq!(
queue.queues.get(&key).expect("queue kept").deficit,
expected,
"terminal failure must drain exactly one quantum of deficit"
);
assert_eq!(queue.queue_len(&key), 0, "terminally failed entry dropped");
}
}