use std::collections::VecDeque;
use serde::Serialize;
use super::error::ServeError;
use super::limits::{
DEFAULT_MAX_CONSECUTIVE_FORMULA, DEFAULT_MAX_CONSECUTIVE_TEXT, DEFAULT_MAX_QUEUE_FORMULA,
DEFAULT_MAX_QUEUE_TEXT, ServeConfigError, ServeLimits, require_positive_usize,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum QueueClass {
Text,
Formula,
}
impl QueueClass {
pub fn name(self) -> &'static str {
match self {
Self::Text => "text",
Self::Formula => "formula",
}
}
pub fn other(self) -> Self {
match self {
Self::Text => Self::Formula,
Self::Formula => Self::Text,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SchedulerConfig {
pub max_queue_text: usize,
pub max_queue_formula: usize,
pub max_consecutive_text: usize,
pub max_consecutive_formula: usize,
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
max_queue_text: DEFAULT_MAX_QUEUE_TEXT,
max_queue_formula: DEFAULT_MAX_QUEUE_FORMULA,
max_consecutive_text: DEFAULT_MAX_CONSECUTIVE_TEXT,
max_consecutive_formula: DEFAULT_MAX_CONSECUTIVE_FORMULA,
}
}
}
impl SchedulerConfig {
pub fn new(
max_queue_text: usize,
max_queue_formula: usize,
max_consecutive_text: usize,
max_consecutive_formula: usize,
) -> Result<Self, ServeConfigError> {
Ok(Self {
max_queue_text: require_positive_usize("--max-queue-text", max_queue_text)?,
max_queue_formula: require_positive_usize("--max-queue-formula", max_queue_formula)?,
max_consecutive_text: require_positive_usize(
"--max-consecutive-text",
max_consecutive_text,
)?,
max_consecutive_formula: require_positive_usize(
"--max-consecutive-formula",
max_consecutive_formula,
)?,
})
}
pub fn from_limits(limits: &ServeLimits) -> Self {
Self::new(
limits.max_queue_text,
limits.max_queue_formula,
limits.max_consecutive_text,
limits.max_consecutive_formula,
)
.expect("ServeLimits validates the same four bounds")
}
pub fn round_len(&self) -> usize {
self.max_consecutive_text + self.max_consecutive_formula
}
pub fn capacity(&self, class: QueueClass) -> usize {
match class {
QueueClass::Text => self.max_queue_text,
QueueClass::Formula => self.max_queue_formula,
}
}
pub fn consecutive_quota(&self, class: QueueClass) -> usize {
match class {
QueueClass::Text => self.max_consecutive_text,
QueueClass::Formula => self.max_consecutive_formula,
}
}
pub fn wait_bound(&self, class: QueueClass) -> usize {
self.capacity(class) * self.consecutive_quota(class.other())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduledJob {
pub class: QueueClass,
pub id: String,
}
#[derive(Debug, Clone)]
pub struct DualQueueScheduler {
config: SchedulerConfig,
text: VecDeque<String>,
formula: VecDeque<String>,
served_text: usize,
served_formula: usize,
}
impl DualQueueScheduler {
pub fn new(config: SchedulerConfig) -> Self {
Self {
config,
text: VecDeque::new(),
formula: VecDeque::new(),
served_text: 0,
served_formula: 0,
}
}
pub fn config(&self) -> SchedulerConfig {
self.config
}
pub fn enqueue(
&mut self,
class: QueueClass,
id: impl Into<String>,
) -> Result<usize, ServeError> {
if self.queued_len(class) >= self.config.capacity(class) {
return Err(ServeError::Busy);
}
self.queue_mut(class).push_back(id.into());
Ok(self.queued_len(class) - 1)
}
pub fn take_next(&mut self) -> Option<ScheduledJob> {
let class = self.peek_class()?;
let id = match class {
QueueClass::Text => self.text.pop_front(),
QueueClass::Formula => self.formula.pop_front(),
}?;
self.record_service(class);
Some(ScheduledJob { class, id })
}
pub fn remove(&mut self, class: QueueClass, id: &str) -> bool {
let queue = self.queue_mut(class);
let Some(position) = queue.iter().position(|queued| queued == id) else {
return false;
};
queue.remove(position);
true
}
pub fn position_of(&self, class: QueueClass, id: &str) -> Option<usize> {
self.queue(class).iter().position(|queued| queued == id)
}
pub fn queued_len(&self, class: QueueClass) -> usize {
self.queue(class).len()
}
pub fn is_empty(&self) -> bool {
self.text.is_empty() && self.formula.is_empty()
}
pub fn served_in_round(&self, class: QueueClass) -> usize {
match class {
QueueClass::Text => self.served_text,
QueueClass::Formula => self.served_formula,
}
}
fn queue(&self, class: QueueClass) -> &VecDeque<String> {
match class {
QueueClass::Text => &self.text,
QueueClass::Formula => &self.formula,
}
}
fn queue_mut(&mut self, class: QueueClass) -> &mut VecDeque<String> {
match class {
QueueClass::Text => &mut self.text,
QueueClass::Formula => &mut self.formula,
}
}
fn peek_class(&self) -> Option<QueueClass> {
match (self.text.is_empty(), self.formula.is_empty()) {
(true, true) => None,
(true, false) => Some(QueueClass::Formula),
(false, true) => Some(QueueClass::Text),
(false, false) => {
if self.served_text < self.config.max_consecutive_text {
Some(QueueClass::Text)
} else {
Some(QueueClass::Formula)
}
}
}
}
fn record_service(&mut self, class: QueueClass) {
match class {
QueueClass::Text => self.served_text += 1,
QueueClass::Formula => self.served_formula += 1,
}
if self.served_text >= self.config.max_consecutive_text
&& self.served_formula >= self.config.max_consecutive_formula
{
self.served_text = 0;
self.served_formula = 0;
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{DualQueueScheduler, QueueClass, SchedulerConfig};
use crate::serve::error::ServeError;
fn default_scheduler() -> DualQueueScheduler {
DualQueueScheduler::new(SchedulerConfig::default())
}
fn take(scheduler: &mut DualQueueScheduler, expected: &[QueueClass]) -> Vec<String> {
let mut classes = Vec::new();
let mut ids = Vec::new();
for _ in 0..expected.len() {
let job = scheduler.take_next().expect("a job must be available");
classes.push(job.class);
ids.push(job.id);
}
assert_eq!(classes, expected, "scheduler order");
ids
}
#[test]
fn documented_defaults_are_the_documented_values() {
let config = SchedulerConfig::default();
assert_eq!(config.max_queue_text, 4);
assert_eq!(config.max_queue_formula, 2);
assert_eq!(config.max_consecutive_text, 4);
assert_eq!(config.max_consecutive_formula, 1);
assert_eq!(config.round_len(), 5);
assert_eq!(config.capacity(QueueClass::Text), 4);
assert_eq!(config.capacity(QueueClass::Formula), 2);
assert_eq!(config.consecutive_quota(QueueClass::Text), 4);
assert_eq!(config.consecutive_quota(QueueClass::Formula), 1);
assert_eq!(config.wait_bound(QueueClass::Formula), 8);
assert_eq!(config.wait_bound(QueueClass::Text), 4);
}
#[test]
fn zero_capacities_and_quotas_are_rejected_with_the_flag_name() {
let cases: [(&str, SchedulerConfig); 4] = [
(
"--max-queue-text",
SchedulerConfig {
max_queue_text: 0,
..SchedulerConfig::default()
},
),
(
"--max-queue-formula",
SchedulerConfig {
max_queue_formula: 0,
..SchedulerConfig::default()
},
),
(
"--max-consecutive-text",
SchedulerConfig {
max_consecutive_text: 0,
..SchedulerConfig::default()
},
),
(
"--max-consecutive-formula",
SchedulerConfig {
max_consecutive_formula: 0,
..SchedulerConfig::default()
},
),
];
for (field, config) in cases {
let error = SchedulerConfig::new(
config.max_queue_text,
config.max_queue_formula,
config.max_consecutive_text,
config.max_consecutive_formula,
)
.expect_err(&format!("{field}=0 must be rejected"));
assert_eq!(error.field(), field, "error: {error}");
}
assert!(
SchedulerConfig::new(1, 1, 1, 1).is_ok(),
"minimal positive values must be accepted"
);
}
#[test]
fn each_queue_has_its_own_capacity_and_reports_busy_immediately() {
let mut scheduler = default_scheduler();
for index in 0..4 {
scheduler
.enqueue(QueueClass::Text, format!("t{index}"))
.expect("up to --max-queue-text");
}
let error = scheduler
.enqueue(QueueClass::Text, "t4")
.expect_err("a full text queue must be Busy");
assert!(matches!(error, ServeError::Busy), "error: {error}");
assert_eq!(error.status_code(), 503);
assert_eq!(scheduler.queued_len(QueueClass::Formula), 0);
for index in 0..2 {
scheduler
.enqueue(QueueClass::Formula, format!("f{index}"))
.expect("up to --max-queue-formula");
}
let error = scheduler
.enqueue(QueueClass::Formula, "f2")
.expect_err("a full formula queue must be Busy");
assert!(matches!(error, ServeError::Busy), "error: {error}");
}
#[test]
fn enqueue_reports_the_zero_based_position() {
let mut scheduler = default_scheduler();
assert_eq!(scheduler.enqueue(QueueClass::Text, "a").expect("fits"), 0);
assert_eq!(scheduler.enqueue(QueueClass::Text, "b").expect("fits"), 1);
assert_eq!(scheduler.position_of(QueueClass::Text, "b"), Some(1));
assert_eq!(scheduler.position_of(QueueClass::Text, "missing"), None);
scheduler.enqueue(QueueClass::Formula, "f").expect("fits");
assert_eq!(scheduler.position_of(QueueClass::Formula, "f"), Some(0));
}
#[test]
fn a_round_serves_text_first_then_formula() {
let config = SchedulerConfig::new(8, 2, 4, 1).expect("valid");
let mut scheduler = DualQueueScheduler::new(config);
for index in 0..8 {
scheduler
.enqueue(QueueClass::Text, format!("t{index}"))
.expect("8 <= max_queue_text");
}
for index in 0..2 {
scheduler
.enqueue(QueueClass::Formula, format!("f{index}"))
.expect("2 <= max_queue_formula");
}
let ids = take(
&mut scheduler,
&[
QueueClass::Text,
QueueClass::Text,
QueueClass::Text,
QueueClass::Text,
QueueClass::Formula,
QueueClass::Text,
QueueClass::Text,
QueueClass::Text,
QueueClass::Text,
QueueClass::Formula,
],
);
assert_eq!(ids[4], "f0", "the formula slot comes after 4 text jobs");
assert_eq!(ids[9], "f1", "then a new round starts");
assert!(scheduler.is_empty());
}
#[test]
fn an_empty_queue_lets_the_other_drain_freely() {
let config = SchedulerConfig::new(4, 5, 4, 1).expect("valid");
let mut scheduler = DualQueueScheduler::new(config);
for index in 0..5 {
scheduler
.enqueue(QueueClass::Formula, format!("f{index}"))
.expect("5 <= max_queue_formula");
}
take(
&mut scheduler,
&[
QueueClass::Formula,
QueueClass::Formula,
QueueClass::Formula,
QueueClass::Formula,
QueueClass::Formula,
],
);
assert!(scheduler.is_empty());
}
#[test]
fn a_refilled_queue_gets_service_without_waiting_for_a_full_quota() {
let mut scheduler = default_scheduler();
for index in 0..4 {
scheduler
.enqueue(QueueClass::Text, format!("t{index}"))
.expect("fits");
}
take(
&mut scheduler,
&[
QueueClass::Text,
QueueClass::Text,
QueueClass::Text,
QueueClass::Text,
],
);
scheduler.enqueue(QueueClass::Formula, "f0").expect("fits");
take(&mut scheduler, &[QueueClass::Formula]);
scheduler.enqueue(QueueClass::Text, "t4").expect("fits");
take(&mut scheduler, &[QueueClass::Text]);
}
#[test]
fn cancelling_a_queued_job_removes_it_and_keeps_order() {
let mut scheduler = default_scheduler();
scheduler.enqueue(QueueClass::Text, "a").expect("fits");
scheduler.enqueue(QueueClass::Text, "b").expect("fits");
scheduler.enqueue(QueueClass::Text, "c").expect("fits");
assert!(scheduler.remove(QueueClass::Text, "b"));
assert!(!scheduler.remove(QueueClass::Text, "b"), "already removed");
assert!(!scheduler.remove(QueueClass::Formula, "a"), "wrong queue");
assert_eq!(scheduler.queued_len(QueueClass::Text), 2);
let ids = take(&mut scheduler, &[QueueClass::Text, QueueClass::Text]);
assert_eq!(ids, vec!["a".to_string(), "c".to_string()]);
}
#[derive(Debug, Default, PartialEq, Eq)]
struct FairnessRun {
max_wait_text: usize,
max_wait_formula: usize,
served_text: usize,
served_formula: usize,
pending_text: usize,
pending_formula: usize,
}
fn other_services(class: QueueClass, text: usize, formula: usize) -> usize {
match class {
QueueClass::Text => text,
QueueClass::Formula => formula,
}
}
fn run_flood(config: SchedulerConfig, flood: QueueClass, steps: usize) -> FairnessRun {
let mut scheduler = DualQueueScheduler::new(config);
let mut enqueued_at: HashMap<String, (QueueClass, usize)> = HashMap::new();
let mut text_services = 0usize;
let mut formula_services = 0usize;
let mut report = FairnessRun::default();
let mut seq = 0usize;
for _ in 0..steps {
while scheduler.queued_len(flood) < config.capacity(flood) {
let id = format!("{}-flood-{seq}", flood.name());
seq += 1;
scheduler
.enqueue(flood, id.clone())
.expect("the flood queue was just topped up, so it cannot be full");
enqueued_at.insert(
id,
(
flood,
other_services(flood, text_services, formula_services),
),
);
}
let trickle = flood.other();
let id = format!("{}-trickle-{seq}", trickle.name());
seq += 1;
if scheduler.enqueue(trickle, id.clone()).is_ok() {
enqueued_at.insert(
id,
(
trickle,
other_services(trickle, text_services, formula_services),
),
);
}
if let Some(job) = scheduler.take_next() {
let (class, at) = enqueued_at
.remove(&job.id)
.expect("every served job was enqueued by this driver");
assert_eq!(class, job.class);
let waited = other_services(job.class, text_services, formula_services) - at;
match job.class {
QueueClass::Text => {
text_services += 1;
report.served_text += 1;
report.max_wait_text = report.max_wait_text.max(waited);
}
QueueClass::Formula => {
formula_services += 1;
report.served_formula += 1;
report.max_wait_formula = report.max_wait_formula.max(waited);
}
}
}
}
for (_, (class, at)) in enqueued_at {
let waited = other_services(class, text_services, formula_services) - at;
match class {
QueueClass::Text => {
report.pending_text += 1;
report.max_wait_text = report.max_wait_text.max(waited);
}
QueueClass::Formula => {
report.pending_formula += 1;
report.max_wait_formula = report.max_wait_formula.max(waited);
}
}
}
report
}
fn assert_fair(config: &SchedulerConfig, run: &FairnessRun) {
for class in [QueueClass::Text, QueueClass::Formula] {
let bound = config.wait_bound(class);
let (waited, served, pending) = match class {
QueueClass::Text => (run.max_wait_text, run.served_text, run.pending_text),
QueueClass::Formula => (
run.max_wait_formula,
run.served_formula,
run.pending_formula,
),
};
assert!(
waited <= bound,
"{} job waited {waited} other-class services, bound is {bound}: {run:?}",
class.name()
);
assert!(
pending <= config.capacity(class),
"{} backlog {pending} exceeds the queue capacity {}: {run:?}",
class.name(),
config.capacity(class)
);
assert!(served > 0, "{} never ran at all: {run:?}", class.name());
}
}
#[test]
fn a_text_flood_does_not_starve_formula_jobs() {
let config = SchedulerConfig::default();
let run = run_flood(config, QueueClass::Text, 400);
eprintln!("text flood: {run:?}");
assert!(
run.served_formula > 50,
"formula work must actually run: {run:?}"
);
assert_fair(&config, &run);
}
#[test]
fn a_formula_flood_does_not_starve_text_jobs() {
let config = SchedulerConfig::default();
let run = run_flood(config, QueueClass::Formula, 400);
eprintln!("formula flood: {run:?}");
assert!(
run.served_text > 200,
"text work must actually run: {run:?}"
);
assert_fair(&config, &run);
}
#[test]
fn a_single_formula_job_waits_at_most_the_text_quota() {
let config = SchedulerConfig::default();
let mut scheduler = DualQueueScheduler::new(config);
let mut waited = None;
let mut text_served_since_arrival = 0usize;
for step in 0..64 {
while scheduler.queued_len(QueueClass::Text) < config.max_queue_text {
let next = scheduler.queued_len(QueueClass::Text);
scheduler
.enqueue(QueueClass::Text, format!("t{step}-{next}"))
.expect("the text queue is below capacity");
}
if step == 3 {
scheduler
.enqueue(QueueClass::Formula, "the-only-formula-job")
.expect("the formula queue is empty");
text_served_since_arrival = 0;
}
match scheduler
.take_next()
.expect("text is always available")
.class
{
QueueClass::Text => {
if waited.is_none() {
text_served_since_arrival += 1;
}
}
QueueClass::Formula => {
waited = Some(text_served_since_arrival);
break;
}
}
}
let waited = waited.expect(
"the formula job must be served even while the text queue is never empty (§8.3)",
);
assert!(
waited <= config.max_consecutive_text,
"the formula job waited {waited} text services, quota is {}",
config.max_consecutive_text
);
}
#[test]
fn a_single_text_job_waits_at_most_the_formula_quota() {
let config = SchedulerConfig::default();
let mut scheduler = DualQueueScheduler::new(config);
let mut waited = None;
let mut formula_served_since_arrival = 0usize;
for step in 0..64 {
while scheduler.queued_len(QueueClass::Formula) < config.max_queue_formula {
let next = scheduler.queued_len(QueueClass::Formula);
scheduler
.enqueue(QueueClass::Formula, format!("f{step}-{next}"))
.expect("the formula queue is below capacity");
}
if step == 2 {
scheduler
.enqueue(QueueClass::Text, "the-only-text-job")
.expect("the text queue is empty");
formula_served_since_arrival = 0;
}
match scheduler
.take_next()
.expect("formula is always available")
.class
{
QueueClass::Formula => {
if waited.is_none() {
formula_served_since_arrival += 1;
}
}
QueueClass::Text => {
waited = Some(formula_served_since_arrival);
break;
}
}
}
let waited = waited.expect(
"the text job must be served even while the formula queue is never empty (§8.3)",
);
assert!(
waited <= config.max_consecutive_formula,
"the text job waited {waited} formula services, quota is {}",
config.max_consecutive_formula
);
}
#[test]
fn fairness_holds_for_non_default_quotas() {
let config = SchedulerConfig::new(4, 2, 2, 3).expect("valid");
let text_flood = run_flood(config, QueueClass::Text, 200);
eprintln!("text flood (2/3): {text_flood:?}");
assert_fair(&config, &text_flood);
let formula_flood = run_flood(config, QueueClass::Formula, 200);
eprintln!("formula flood (2/3): {formula_flood:?}");
assert_fair(&config, &formula_flood);
}
}