use std::collections::{BTreeMap, VecDeque};
use serde::Serialize;
use super::error::{ErrorBody, ServeError};
use super::limits::{ServeConfigError, ServeLimits, require_positive_u64, require_positive_usize};
use super::queue::QueueClass;
pub type Millis = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum JobKind {
Ocr,
ModelDownload,
}
impl JobKind {
pub fn name(self) -> &'static str {
match self {
Self::Ocr => "ocr",
Self::ModelDownload => "model_download",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum JobQueue {
Text,
Formula,
Download,
}
impl JobQueue {
pub fn name(self) -> &'static str {
match self {
Self::Text => "text",
Self::Formula => "formula",
Self::Download => "download",
}
}
pub fn class(self) -> Option<QueueClass> {
match self {
Self::Text => Some(QueueClass::Text),
Self::Formula => Some(QueueClass::Formula),
Self::Download => None,
}
}
pub fn from_class(class: QueueClass) -> Self {
match class {
QueueClass::Text => Self::Text,
QueueClass::Formula => Self::Formula,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum JobState {
Queued,
Running,
Succeeded,
Failed,
Cancelled,
}
impl JobState {
pub fn name(self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JobStoreLimits {
pub max_retained: usize,
pub max_retained_bytes: u64,
pub max_tombstones: usize,
pub ttl_ms: Millis,
}
impl JobStoreLimits {
pub fn new(
max_retained: usize,
max_retained_bytes: u64,
max_tombstones: usize,
ttl_ms: Millis,
) -> Result<Self, ServeConfigError> {
Ok(Self {
max_retained: require_positive_usize("--max-retained", max_retained)?,
max_retained_bytes: require_positive_u64("--max-retained-mb", max_retained_bytes)?,
max_tombstones: require_positive_usize("--max-tombstones", max_tombstones)?,
ttl_ms: require_positive_u64("--job-ttl-secs", ttl_ms)?,
})
}
pub fn from_limits(limits: &ServeLimits) -> Self {
Self::new(
limits.max_retained,
limits.max_retained_bytes,
limits.max_tombstones,
limits.job_ttl_ms,
)
.expect("ServeLimits validates the same four bounds")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DownloadProgress {
pub files_done: usize,
pub files_total: usize,
pub bytes_done: u64,
pub bytes_total: Option<u64>,
pub current_file: Option<String>,
}
impl DownloadProgress {
pub fn planned(files_total: usize, bytes_total: Option<u64>) -> Self {
Self {
files_done: 0,
files_total,
bytes_done: 0,
bytes_total,
current_file: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct JobFailure {
pub status: u16,
pub code: &'static str,
pub message: String,
pub detail: serde_json::Value,
}
impl JobFailure {
pub fn new(
status: u16,
code: &'static str,
message: impl Into<String>,
detail: serde_json::Value,
) -> Self {
Self {
status,
code,
message: message.into(),
detail,
}
}
pub fn from_body(status: u16, body: &ErrorBody) -> Self {
Self {
status,
code: body.code,
message: body.message.clone(),
detail: body.detail.clone(),
}
}
}
impl From<&ServeError> for JobFailure {
fn from(error: &ServeError) -> Self {
Self {
status: error.status_code(),
code: error.code(),
message: error.message(),
detail: error.detail(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct JobRecord {
pub id: String,
pub kind: JobKind,
pub queue: JobQueue,
pub state: JobState,
pub position: Option<usize>,
pub queued_ms: Millis,
pub started_ms: Option<Millis>,
pub finished_ms: Option<Millis>,
pub original_bytes: u64,
pub original_retained: bool,
pub result_bytes: u64,
pub failure: Option<JobFailure>,
pub download: Option<DownloadProgress>,
pub cancel_requested: bool,
pub seq: u64,
pub request_id: u64,
}
impl JobRecord {
pub fn total_bytes(&self) -> u64 {
let original = if self.original_retained {
self.original_bytes
} else {
0
};
original.saturating_add(self.result_bytes)
}
pub fn elapsed_ms(&self, now: Millis) -> Millis {
let end = self.finished_ms.unwrap_or(now);
end.saturating_sub(self.started_ms.unwrap_or(self.queued_ms))
}
pub fn error(&self) -> Option<String> {
self.failure.as_ref().map(|failure| failure.message.clone())
}
pub fn view(&self, now: Millis) -> JobView {
JobView {
id: self.id.clone(),
kind: self.kind,
queue: self.queue,
state: self.state,
position: self.position,
queued_ms: self.queued_ms,
started_ms: self.started_ms,
elapsed_ms: self.elapsed_ms(now),
error: self.error(),
failure: self.failure.clone(),
download: self.download.clone(),
cancel_requested: self.cancel_requested,
original_retained: self.original_retained,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct JobView {
pub id: String,
pub kind: JobKind,
pub queue: JobQueue,
pub state: JobState,
pub position: Option<usize>,
pub queued_ms: Millis,
pub started_ms: Option<Millis>,
pub elapsed_ms: Millis,
pub error: Option<String>,
pub failure: Option<JobFailure>,
pub download: Option<DownloadProgress>,
pub cancel_requested: bool,
pub original_retained: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CancelOutcome {
Cancelled,
CancelRequested,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TickReport {
pub expired_jobs: usize,
pub evicted_for_count: usize,
pub evicted_for_bytes: usize,
pub expired_tombstones: usize,
pub released_originals: usize,
}
#[derive(Debug, Default)]
pub struct JobIdGenerator {
next: u64,
}
impl JobIdGenerator {
pub fn generate(&mut self) -> String {
let seq = self.next;
self.next += 1;
format!("job-{seq:016x}")
}
}
#[derive(Debug)]
pub struct JobStore {
limits: JobStoreLimits,
jobs: BTreeMap<String, JobRecord>,
tombstones: VecDeque<(String, Millis)>,
retained_bytes: u64,
next_seq: u64,
released_originals: Vec<String>,
}
impl JobStore {
pub fn new(limits: JobStoreLimits) -> Self {
Self {
limits,
jobs: BTreeMap::new(),
tombstones: VecDeque::new(),
retained_bytes: 0,
next_seq: 0,
released_originals: Vec::new(),
}
}
pub fn limits(&self) -> JobStoreLimits {
self.limits
}
pub fn insert(
&mut self,
id: impl Into<String>,
kind: JobKind,
queue: JobQueue,
original_bytes: u64,
now: Millis,
request_id: u64,
) -> Result<(), ServeError> {
let id = id.into();
if self.jobs.contains_key(&id) {
return Err(ServeError::Internal);
}
let seq = self.next_seq;
self.next_seq += 1;
let record = JobRecord {
id: id.clone(),
kind,
queue,
state: JobState::Queued,
position: None,
queued_ms: now,
started_ms: None,
finished_ms: None,
original_bytes,
original_retained: original_bytes > 0,
result_bytes: 0,
failure: None,
download: None,
cancel_requested: false,
seq,
request_id,
};
self.retained_bytes = self.retained_bytes.saturating_add(record.total_bytes());
self.jobs.insert(id, record);
self.enforce_retention(now);
Ok(())
}
pub fn set_position(&mut self, id: &str, position: Option<usize>) -> Result<(), ServeError> {
self.record_mut(id)?.position = position;
Ok(())
}
pub fn set_download_progress(
&mut self,
id: &str,
progress: DownloadProgress,
) -> Result<(), ServeError> {
let record = self.record_mut(id)?;
if record.kind != JobKind::ModelDownload {
return Err(ServeError::Internal);
}
record.download = Some(progress);
Ok(())
}
pub fn is_cancel_requested(&self, id: &str) -> bool {
self.jobs
.get(id)
.is_some_and(|record| record.cancel_requested)
}
pub fn start(&mut self, id: &str, now: Millis) -> Result<(), ServeError> {
let record = self.record_mut(id)?;
if record.state != JobState::Queued {
return Err(ServeError::Internal);
}
record.state = JobState::Running;
record.started_ms = Some(now);
record.position = None;
Ok(())
}
pub fn succeed(&mut self, id: &str, result_bytes: u64, now: Millis) -> Result<(), ServeError> {
{
let record = self.record_mut(id)?;
if record.state != JobState::Running {
return Err(ServeError::Internal);
}
let added = result_bytes.saturating_sub(record.result_bytes);
record.result_bytes = result_bytes;
record.state = JobState::Succeeded;
record.finished_ms = Some(now);
record.position = None;
self.retained_bytes = self.retained_bytes.saturating_add(added);
}
self.enforce_retention(now);
Ok(())
}
pub fn fail(
&mut self,
id: &str,
reason: impl Into<String>,
now: Millis,
) -> Result<(), ServeError> {
self.fail_classified(
id,
JobFailure::new(500, "internal", reason, serde_json::Value::Null),
now,
)
}
pub fn fail_classified(
&mut self,
id: &str,
failure: JobFailure,
now: Millis,
) -> Result<(), ServeError> {
{
let record = self.record_mut(id)?;
if record.state != JobState::Running {
return Err(ServeError::Internal);
}
record.state = JobState::Failed;
record.failure = Some(failure);
record.finished_ms = Some(now);
record.position = None;
}
self.release_original(id);
self.enforce_retention(now);
Ok(())
}
pub fn cancel(&mut self, id: &str, now: Millis) -> Result<CancelOutcome, ServeError> {
let (state, kind) = {
let record = self.record_mut(id)?;
(record.state, record.kind)
};
match (state, kind) {
(JobState::Queued, _) => {
{
let record = self.record_mut(id)?;
record.state = JobState::Cancelled;
record.finished_ms = Some(now);
}
self.release_original(id);
self.enforce_retention(now);
Ok(CancelOutcome::Cancelled)
}
(JobState::Running, JobKind::ModelDownload) => {
self.record_mut(id)?.cancel_requested = true;
Ok(CancelOutcome::CancelRequested)
}
(JobState::Running, _) => Err(ServeError::NotCancellable),
(JobState::Succeeded | JobState::Failed | JobState::Cancelled, _) => {
Err(ServeError::NotCancellable)
}
}
}
pub fn finish_cancelled(&mut self, id: &str, now: Millis) -> Result<(), ServeError> {
{
let record = self.record_mut(id)?;
if record.state != JobState::Running || !record.cancel_requested {
return Err(ServeError::Internal);
}
record.state = JobState::Cancelled;
record.finished_ms = Some(now);
record.position = None;
}
self.release_original(id);
self.enforce_retention(now);
Ok(())
}
pub fn record(&self, id: &str) -> Result<&JobRecord, ServeError> {
if let Some(record) = self.jobs.get(id) {
return Ok(record);
}
if self.is_tombstoned(id) {
return Err(ServeError::JobEvicted);
}
Err(ServeError::JobNotFound)
}
pub fn view(&self, id: &str, now: Millis) -> Result<JobView, ServeError> {
Ok(self.record(id)?.view(now))
}
pub fn evicted_at(&self, id: &str) -> Option<Millis> {
self.tombstones
.iter()
.find(|(tombstoned, _)| tombstoned == id)
.map(|(_, at)| *at)
}
pub fn is_tombstoned(&self, id: &str) -> bool {
self.evicted_at(id).is_some()
}
pub fn tick(&mut self, now: Millis) -> TickReport {
let mut report = TickReport::default();
let expired: Vec<String> = self
.jobs
.values()
.filter(|record| record.state.is_terminal())
.filter(|record| {
record
.finished_ms
.is_some_and(|finished| finished.saturating_add(self.limits.ttl_ms) <= now)
})
.map(|record| record.id.clone())
.collect();
for id in expired {
self.evict(&id, now);
report.expired_jobs += 1;
}
while self
.tombstones
.front()
.is_some_and(|(_, at)| at.saturating_add(self.limits.ttl_ms) <= now)
{
self.tombstones.pop_front();
report.expired_tombstones += 1;
}
let retention = self.enforce_retention(now);
report.evicted_for_count += retention.evicted_for_count;
report.evicted_for_bytes += retention.evicted_for_bytes;
report.released_originals += retention.released_originals;
report
}
pub fn retained_bytes(&self) -> u64 {
self.retained_bytes
}
pub fn len(&self) -> usize {
self.jobs.len()
}
pub fn is_empty(&self) -> bool {
self.jobs.is_empty()
}
pub fn terminal_count(&self) -> usize {
self.jobs
.values()
.filter(|record| record.state.is_terminal())
.count()
}
pub fn tombstone_len(&self) -> usize {
self.tombstones.len()
}
pub fn views(&self, now: Millis) -> Vec<JobView> {
self.jobs.values().map(|record| record.view(now)).collect()
}
fn record_mut(&mut self, id: &str) -> Result<&mut JobRecord, ServeError> {
if self.jobs.contains_key(id) {
return Ok(self
.jobs
.get_mut(id)
.expect("checked with contains_key just above"));
}
if self.is_tombstoned(id) {
return Err(ServeError::JobEvicted);
}
Err(ServeError::JobNotFound)
}
pub fn release_original(&mut self, id: &str) -> bool {
let Some(record) = self.jobs.get_mut(id) else {
return false;
};
if !record.original_retained || record.original_bytes == 0 {
return false;
}
record.original_retained = false;
let released = record.original_bytes;
self.retained_bytes = self.retained_bytes.saturating_sub(released);
self.released_originals.push(id.to_string());
true
}
pub fn take_released_originals(&mut self) -> Vec<String> {
std::mem::take(&mut self.released_originals)
}
fn evict(&mut self, id: &str, now: Millis) {
if let Some(record) = self.jobs.remove(id) {
self.retained_bytes = self.retained_bytes.saturating_sub(record.total_bytes());
self.push_tombstone(id.to_string(), now);
}
}
fn push_tombstone(&mut self, id: String, now: Millis) {
self.tombstones.push_back((id, now));
while self.tombstones.len() > self.limits.max_tombstones {
self.tombstones.pop_front();
}
}
fn enforce_retention(&mut self, now: Millis) -> RetentionReport {
let mut report = RetentionReport::default();
while self.terminal_count() > self.limits.max_retained {
let Some(oldest) = self.oldest_terminal() else {
break;
};
self.evict(&oldest, now);
report.evicted_for_count += 1;
}
while self.retained_bytes > self.limits.max_retained_bytes {
if let Some(id) = self.oldest_terminal_with_original() {
self.release_original(&id);
report.released_originals += 1;
continue;
}
let Some(oldest) = self.oldest_terminal() else {
break;
};
self.evict(&oldest, now);
report.evicted_for_bytes += 1;
}
report
}
fn oldest_terminal(&self) -> Option<String> {
self.jobs
.values()
.filter(|record| record.state.is_terminal())
.min_by_key(|record| (record.finished_ms.unwrap_or(record.queued_ms), record.seq))
.map(|record| record.id.clone())
}
fn oldest_terminal_with_original(&self) -> Option<String> {
self.jobs
.values()
.filter(|record| record.state.is_terminal())
.filter(|record| record.original_retained && record.original_bytes > 0)
.min_by_key(|record| (record.finished_ms.unwrap_or(record.queued_ms), record.seq))
.map(|record| record.id.clone())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct RetentionReport {
evicted_for_count: usize,
evicted_for_bytes: usize,
released_originals: usize,
}
#[cfg(test)]
mod tests {
use super::{
CancelOutcome, DownloadProgress, JobFailure, JobIdGenerator, JobKind, JobQueue, JobState,
JobStore, JobStoreLimits, Millis, TickReport,
};
use crate::serve::error::ServeError;
const TEST_REQUEST_ID: u64 = 1;
fn limits(
max_retained: usize,
bytes: u64,
tombstones: usize,
ttl_ms: Millis,
) -> JobStoreLimits {
JobStoreLimits::new(max_retained, bytes, tombstones, ttl_ms).expect("valid test limits")
}
fn store() -> JobStore {
JobStore::new(limits(32, 64 * 1024 * 1024, 256, 600_000))
}
fn insert_ocr(store: &mut JobStore, id: &str, bytes: u64, now: Millis) {
store
.insert(
id,
JobKind::Ocr,
JobQueue::Text,
bytes,
now,
TEST_REQUEST_ID,
)
.expect("fresh ids");
}
fn finish(store: &mut JobStore, id: &str, now: Millis) {
store.start(id, now).expect("queued -> running");
store.succeed(id, 0, now).expect("running -> succeeded");
}
#[test]
fn job_ids_are_monotonic_and_readable() {
let mut generator = JobIdGenerator::default();
let first = generator.generate();
let second = generator.generate();
assert_eq!(first, "job-0000000000000000");
assert_eq!(second, "job-0000000000000001");
assert_ne!(first, second);
}
#[test]
fn job_store_limits_reject_zero() {
assert!(JobStoreLimits::new(0, 1024, 256, 600_000).is_err());
assert!(JobStoreLimits::new(32, 0, 256, 600_000).is_err());
assert!(JobStoreLimits::new(32, 1024, 0, 600_000).is_err());
assert!(JobStoreLimits::new(32, 1024, 256, 0).is_err());
let error = JobStoreLimits::new(0, 1024, 256, 600_000).expect_err("zero is invalid");
assert_eq!(error.field(), "--max-retained");
}
#[test]
fn queued_to_running_to_succeeded_carries_every_documented_field() {
let mut store = store();
insert_ocr(&mut store, "job-0", 2048, 100);
store.set_position("job-0", Some(0)).expect("queued");
let view = store.view("job-0", 100).expect("present");
assert_eq!(view.id, "job-0");
assert_eq!(view.kind, JobKind::Ocr);
assert_eq!(view.queue, JobQueue::Text);
assert_eq!(view.state, JobState::Queued);
assert_eq!(view.position, Some(0));
assert_eq!(view.queued_ms, 100);
assert_eq!(view.started_ms, None);
assert_eq!(view.elapsed_ms, 0);
assert_eq!(view.error, None);
assert_eq!(view.failure, None);
assert_eq!(view.download, None, "an OCR job has no download progress");
assert!(!view.cancel_requested);
store.start("job-0", 150).expect("queued -> running");
let view = store.view("job-0", 400).expect("present");
assert_eq!(view.state, JobState::Running);
assert_eq!(view.position, None, "a running job leaves the queue");
assert_eq!(view.started_ms, Some(150));
assert_eq!(view.elapsed_ms, 250, "elapsed counts from the start");
store
.succeed("job-0", 512, 300)
.expect("running -> succeeded");
let view = store.view("job-0", 9_999).expect("present");
assert_eq!(view.state, JobState::Succeeded);
assert_eq!(view.elapsed_ms, 150, "elapsed freezes at the finish time");
}
#[test]
fn a_failed_job_records_its_reason() {
let mut store = store();
insert_ocr(&mut store, "job-0", 16, 0);
store.start("job-0", 1).expect("-> running");
store
.fail("job-0", "the image cannot be decoded", 2)
.expect("-> failed");
let view = store.view("job-0", 3).expect("present");
assert_eq!(view.state, JobState::Failed);
assert_eq!(view.error.as_deref(), Some("the image cannot be decoded"));
}
#[test]
fn illegal_store_transitions_are_internal_errors() {
let mut store = store();
insert_ocr(&mut store, "job-0", 16, 0);
assert!(matches!(
store.succeed("job-0", 0, 1).expect_err("must reject"),
ServeError::Internal
));
assert!(matches!(
store
.insert("job-0", JobKind::Ocr, JobQueue::Text, 0, 1, TEST_REQUEST_ID)
.expect_err("duplicate id"),
ServeError::Internal
));
store.start("job-0", 1).expect("-> running");
assert!(matches!(
store.start("job-0", 2).expect_err("must reject"),
ServeError::Internal
));
}
#[test]
fn cancelling_a_queued_job_is_reliable() {
let mut store = store();
insert_ocr(&mut store, "job-0", 32, 10);
let outcome = store.cancel("job-0", 20).expect("queued cancel must work");
assert_eq!(outcome, CancelOutcome::Cancelled);
let view = store.view("job-0", 30).expect("present");
assert_eq!(view.state, JobState::Cancelled);
assert_eq!(view.elapsed_ms, 10, "cancelling freezes the elapsed time");
let error = store.cancel("job-0", 40).expect_err("terminal cancel");
assert_eq!(error.status_code(), 409);
assert_eq!(error.code(), "not_cancellable");
}
#[test]
fn cancelling_a_running_job_returns_not_cancellable_and_keeps_running() {
let mut store = store();
insert_ocr(&mut store, "job-0", 32, 0);
store.start("job-0", 1).expect("-> running");
let error = store
.cancel("job-0", 2)
.expect_err("running cancel must fail");
assert_eq!(error.status_code(), 409);
assert_eq!(error.code(), "not_cancellable");
assert_eq!(
store.record("job-0").expect("still present").state,
JobState::Running,
"the state must not change"
);
}
#[test]
fn cancelling_a_finished_job_is_also_not_cancellable() {
let mut store = store();
insert_ocr(&mut store, "job-0", 32, 0);
finish(&mut store, "job-0", 5);
let error = store.cancel("job-0", 6).expect_err("finished cancel");
assert_eq!(error.status_code(), 409);
assert_eq!(error.code(), "not_cancellable");
assert_eq!(
store.record("job-0").expect("present").state,
JobState::Succeeded
);
}
#[test]
fn eviction_by_count_removes_the_oldest_terminal_job_first() {
let mut store = JobStore::new(limits(2, u64::MAX, 16, 1_000_000));
insert_ocr(&mut store, "old", 0, 0);
finish(&mut store, "old", 100);
insert_ocr(&mut store, "middle", 0, 0);
finish(&mut store, "middle", 200);
assert_eq!(store.terminal_count(), 2);
assert_eq!(store.tombstone_len(), 0, "nothing evicted yet");
insert_ocr(&mut store, "new", 0, 0);
finish(&mut store, "new", 300);
assert_eq!(store.terminal_count(), 2);
assert_eq!(store.tombstone_len(), 1);
assert!(matches!(
store.record("old").expect_err("oldest must be evicted"),
ServeError::JobEvicted
));
assert!(store.record("middle").is_ok());
assert!(store.record("new").is_ok());
assert_eq!(
store.evicted_at("old"),
Some(300),
"tombstone keeps evicted_at"
);
}
#[test]
fn eviction_by_bytes_uses_the_original_plus_result_budget() {
let mut store = JobStore::new(limits(32, 100, 16, 1_000_000));
insert_ocr(&mut store, "a", 60, 0);
store.start("a", 0).expect("-> running");
store.succeed("a", 20, 0).expect("-> succeeded");
assert_eq!(store.retained_bytes(), 80);
insert_ocr(&mut store, "b", 60, 0);
assert_eq!(
store.retained_bytes(),
80,
"a's original is what paid for it"
);
assert_eq!(
store.record("a").expect("a survives").result_bytes,
20,
"the result is still retained and readable"
);
assert!(!store.record("a").expect("present").original_retained);
assert!(store.record("b").is_ok(), "the active job is untouched");
assert_eq!(store.take_released_originals(), vec!["a".to_string()]);
}
#[test]
fn active_jobs_are_never_evicted() {
let mut store = JobStore::new(limits(32, 100, 16, 1_000_000));
insert_ocr(&mut store, "a", 60, 0);
store.start("a", 0).expect("-> running");
insert_ocr(&mut store, "b", 60, 0);
assert_eq!(
store.retained_bytes(),
120,
"over budget but both are active"
);
assert_eq!(store.len(), 2);
assert_eq!(store.tombstone_len(), 0);
assert!(store.record("a").is_ok());
assert!(store.record("b").is_ok());
}
#[test]
fn ttl_expiry_is_driven_by_tick_with_injected_time() {
let mut store = JobStore::new(limits(32, u64::MAX, 16, 1_000));
insert_ocr(&mut store, "job-0", 0, 0);
finish(&mut store, "job-0", 5_000);
let report = store.tick(5_999);
assert_eq!(report, TickReport::default());
assert!(store.record("job-0").is_ok());
let report = store.tick(6_000);
assert_eq!(report.expired_jobs, 1);
assert_eq!(store.tombstone_len(), 1);
assert_eq!(store.evicted_at("job-0"), Some(6_000));
assert!(matches!(
store.record("job-0").expect_err("expired"),
ServeError::JobEvicted
));
}
#[test]
fn ttl_never_expires_a_queued_or_running_job() {
let mut store = JobStore::new(limits(32, u64::MAX, 16, 10));
insert_ocr(&mut store, "queued", 0, 0);
insert_ocr(&mut store, "running", 0, 0);
store.start("running", 0).expect("-> running");
let report = store.tick(1_000_000);
assert_eq!(report.expired_jobs, 0);
assert!(store.record("queued").is_ok());
assert!(store.record("running").is_ok());
}
#[test]
fn tombstone_capacity_eviction_keeps_only_the_newest_entries() {
let mut store = JobStore::new(limits(1, u64::MAX, 2, 1_000_000));
for (index, id) in ["a", "b", "c", "d"].iter().enumerate() {
insert_ocr(&mut store, id, 0, 0);
finish(&mut store, id, 100 * (index as Millis + 1));
}
assert_eq!(store.tombstone_len(), 2, "capacity 2");
assert_eq!(store.evicted_at("a"), None, "the oldest tombstone is gone");
assert_eq!(store.evicted_at("b"), Some(300));
assert_eq!(store.evicted_at("c"), Some(400));
assert!(store.record("d").is_ok(), "the newest terminal job is kept");
assert!(matches!(
store.record("a").expect_err("unknown again"),
ServeError::JobNotFound
));
}
#[test]
fn tombstone_ttl_expiry_turns_410_back_into_404() {
let mut store = JobStore::new(limits(1, u64::MAX, 16, 1_000));
insert_ocr(&mut store, "a", 0, 0);
finish(&mut store, "a", 100);
insert_ocr(&mut store, "b", 0, 0);
finish(&mut store, "b", 200);
assert!(store.record("a").is_err(), "a was evicted for count");
let report = store.tick(1_199);
assert_eq!(report.expired_tombstones, 0);
assert!(matches!(
store.record("a").expect_err("still tombstoned"),
ServeError::JobEvicted
));
let report = store.tick(1_200);
assert_eq!(report.expired_tombstones, 1);
assert!(matches!(
store.record("a").expect_err("tombstone expired"),
ServeError::JobNotFound
));
assert_eq!(store.evicted_at("a"), None);
assert_eq!(store.evicted_at("b"), Some(1_200));
assert_eq!(store.tombstone_len(), 1);
}
#[test]
fn unknown_jobs_are_404_while_evicted_jobs_are_410() {
let mut store = JobStore::new(limits(1, u64::MAX, 16, 1_000_000));
let never = store.view("never-existed", 0).expect_err("404");
assert_eq!(never.status_code(), 404);
assert_eq!(never.code(), "job_not_found");
insert_ocr(&mut store, "gone", 0, 0);
finish(&mut store, "gone", 10);
insert_ocr(&mut store, "keeper", 0, 0);
finish(&mut store, "keeper", 20);
let evicted = store.view("gone", 30).expect_err("410");
assert_eq!(evicted.status_code(), 410);
assert_eq!(evicted.code(), "job_evicted");
assert!(store.view("keeper", 30).is_ok());
assert!(matches!(
store.start("gone", 40).expect_err("410"),
ServeError::JobEvicted
));
assert!(matches!(
store.cancel("gone", 40).expect_err("410"),
ServeError::JobEvicted
));
}
#[test]
fn byte_accounting_tracks_originals_and_results_across_eviction() {
let mut store = store();
insert_ocr(&mut store, "a", 1_000, 0);
assert_eq!(store.retained_bytes(), 1_000);
store.start("a", 0).expect("-> running");
store.succeed("a", 250, 0).expect("-> succeeded");
assert_eq!(store.retained_bytes(), 1_250);
assert_eq!(store.record("a").expect("present").result_bytes, 250);
assert!(
store.record("a").expect("present").original_retained,
"a 64 MB budget holds both the original and the result"
);
insert_ocr(&mut store, "b", 500, 0);
assert_eq!(store.retained_bytes(), 1_750);
}
#[test]
fn an_over_budget_store_releases_originals_before_evicting_jobs() {
let mut store = JobStore::new(limits(32, 1_200, 16, 1_000_000));
insert_ocr(&mut store, "a", 1_000, 0);
store.start("a", 0).expect("-> running");
store.succeed("a", 300, 0).expect("-> succeeded");
assert_eq!(store.retained_bytes(), 300);
assert!(store.record("a").is_ok(), "the job itself must survive");
assert_eq!(store.record("a").expect("present").result_bytes, 300);
assert!(!store.record("a").expect("present").original_retained);
assert_eq!(store.take_released_originals(), vec!["a".to_string()]);
insert_ocr(&mut store, "b", 600, 0);
store.start("b", 0).expect("-> running");
store.succeed("b", 100, 0).expect("-> succeeded");
assert_eq!(store.retained_bytes(), 1_000);
assert!(store.record("b").expect("present").original_retained);
insert_ocr(&mut store, "c", 600, 0);
assert_eq!(store.retained_bytes(), 1_000);
assert!(!store.record("b").expect("present").original_retained);
assert!(store.record("c").is_ok(), "the active job is never touched");
assert_eq!(store.take_released_originals(), vec!["b".to_string()]);
}
#[test]
fn when_only_results_remain_the_oldest_terminal_job_is_evicted() {
let mut store = JobStore::new(limits(32, 500, 16, 1_000_000));
insert_ocr(&mut store, "a", 1_000, 0);
store.start("a", 0).expect("-> running");
store.succeed("a", 250, 0).expect("-> succeeded");
assert_eq!(store.retained_bytes(), 250);
assert_eq!(store.take_released_originals(), vec!["a".to_string()]);
insert_ocr(&mut store, "b", 0, 0);
store.start("b", 1).expect("-> running");
store.succeed("b", 400, 1).expect("-> succeeded");
assert_eq!(store.retained_bytes(), 400);
assert!(matches!(
store.record("a").expect_err("evicted"),
ServeError::JobEvicted
));
assert!(store.record("b").is_ok());
assert!(store.take_released_originals().is_empty());
}
#[test]
fn active_jobs_keep_their_original_even_over_the_byte_budget() {
let mut store = JobStore::new(limits(32, 100, 16, 1_000_000));
insert_ocr(&mut store, "queued", 1_000, 0);
assert_eq!(store.retained_bytes(), 1_000);
assert!(
store.record("queued").expect("present").original_retained,
"the worker still needs that original to decode the image"
);
store.start("queued", 1).expect("-> running");
assert!(store.record("queued").expect("present").original_retained);
assert!(store.take_released_originals().is_empty());
}
#[test]
fn failed_and_cancelled_jobs_release_their_original() {
let mut failed = store();
insert_ocr(&mut failed, "boom", 900, 0);
failed.start("boom", 0).expect("-> running");
failed
.fail_classified(
"boom",
JobFailure::new(422, "unsupported_input", "x", serde_json::Value::Null),
0,
)
.expect("-> failed");
assert!(!failed.record("boom").expect("present").original_retained);
assert_eq!(failed.retained_bytes(), 0);
assert_eq!(failed.take_released_originals(), vec!["boom".to_string()]);
let mut cancelled = store();
insert_ocr(&mut cancelled, "gone", 700, 0);
assert_eq!(
cancelled.cancel("gone", 0).expect("queued cancel"),
CancelOutcome::Cancelled
);
assert!(!cancelled.record("gone").expect("present").original_retained);
assert_eq!(cancelled.retained_bytes(), 0);
assert_eq!(
cancelled.take_released_originals(),
vec!["gone".to_string()]
);
assert!(!cancelled.release_original("gone"));
assert!(!cancelled.release_original("nobody"));
}
#[test]
fn model_download_jobs_are_stored_with_their_own_kind_and_queue() {
let mut store = store();
store
.insert(
"dl-0",
JobKind::ModelDownload,
JobQueue::Download,
0,
0,
TEST_REQUEST_ID,
)
.expect("fresh id");
store
.set_download_progress("dl-0", DownloadProgress::planned(2, Some(300)))
.expect("a download job carries progress");
let view = store.view("dl-0", 0).expect("present");
assert_eq!(view.kind, JobKind::ModelDownload);
assert_eq!(
view.queue,
JobQueue::Download,
"a download job is not in the text queue"
);
assert_eq!(
view.queue.class(),
None,
"only the dual queue has a scheduler class"
);
let json = serde_json::to_value(&view).expect("JobView must serialize");
assert_eq!(json["kind"], "model_download");
assert_eq!(json["queue"], "download");
assert_eq!(json["state"], "queued");
assert_eq!(json["position"], serde_json::Value::Null);
assert_eq!(json["download"]["files_done"], 0);
assert_eq!(json["download"]["files_total"], 2);
assert_eq!(json["download"]["bytes_done"], 0);
assert_eq!(json["download"]["bytes_total"], 300);
assert_eq!(json["download"]["current_file"], serde_json::Value::Null);
assert_eq!(json["cancel_requested"], false);
assert_eq!(
json["original_retained"], false,
"a download job has no original image at all"
);
}
#[test]
fn a_running_download_can_be_cancelled_at_a_file_boundary_but_ocr_cannot() {
let mut downloads = store();
downloads
.insert(
"dl-0",
JobKind::ModelDownload,
JobQueue::Download,
0,
0,
TEST_REQUEST_ID,
)
.expect("fresh id");
downloads.start("dl-0", 1).expect("-> running");
assert_eq!(
downloads
.cancel("dl-0", 2)
.expect("a running download accepts a request"),
CancelOutcome::CancelRequested
);
let view = downloads.view("dl-0", 3).expect("present");
assert_eq!(
view.state,
JobState::Running,
"the state must not pretend the download already stopped"
);
assert!(view.cancel_requested);
assert!(downloads.is_cancel_requested("dl-0"));
downloads.finish_cancelled("dl-0", 4).expect("-> cancelled");
let view = downloads.view("dl-0", 5).expect("present");
assert_eq!(view.state, JobState::Cancelled);
assert_eq!(view.elapsed_ms, 3, "elapsed freezes at the cancel time");
assert!(view.cancel_requested, "the audit flag stays visible");
downloads
.insert(
"dl-1",
JobKind::ModelDownload,
JobQueue::Download,
0,
0,
TEST_REQUEST_ID,
)
.expect("fresh id");
downloads.start("dl-1", 1).expect("-> running");
assert!(matches!(
downloads
.finish_cancelled("dl-1", 2)
.expect_err("only a requested cancel may be finished"),
ServeError::Internal
));
assert_eq!(
downloads.record("dl-1").expect("present").state,
JobState::Running
);
downloads
.insert(
"dl-2",
JobKind::ModelDownload,
JobQueue::Download,
0,
0,
TEST_REQUEST_ID,
)
.expect("fresh id");
assert_eq!(
downloads.cancel("dl-2", 3).expect("queued cancel"),
CancelOutcome::Cancelled
);
assert!(
downloads.start("dl-2", 4).is_err(),
"the worker must not start a job that was cancelled while queued"
);
let mut ocr_store = store();
insert_ocr(&mut ocr_store, "job-0", 8, 0);
ocr_store.start("job-0", 1).expect("-> running");
let error = ocr_store
.cancel("job-0", 2)
.expect_err("a running OCR job is not cancellable");
assert_eq!(error.code(), "not_cancellable");
assert_eq!(
ocr_store.record("job-0").expect("present").state,
JobState::Running
);
assert!(!ocr_store.is_cancel_requested("job-0"));
}
#[test]
fn a_classified_failure_carries_its_code_and_detail() {
let mut store = store();
store
.insert(
"dl-0",
JobKind::ModelDownload,
JobQueue::Download,
0,
0,
TEST_REQUEST_ID,
)
.expect("fresh id");
store.start("dl-0", 1).expect("-> running");
store
.fail_classified(
"dl-0",
JobFailure::new(
507,
"insufficient_disk_space",
"insufficient disk space: 1000 bytes required, 10 bytes available",
serde_json::json!({ "required_bytes": 1000, "available_bytes": 10 }),
),
2,
)
.expect("-> failed");
let json = serde_json::to_value(store.view("dl-0", 3).expect("present"))
.expect("JobView must serialize");
assert_eq!(json["state"], "failed");
assert_eq!(json["failure"]["status"], 507);
assert_eq!(json["failure"]["code"], "insufficient_disk_space");
assert_eq!(json["failure"]["detail"]["required_bytes"], 1000);
assert_eq!(json["failure"]["detail"]["available_bytes"], 10);
assert_eq!(
json["error"], json["failure"]["message"],
"the frozen `error` field stays the same text"
);
}
#[test]
fn job_view_serializes_the_documented_field_names() {
let mut store = store();
insert_ocr(&mut store, "job-0", 8, 0);
store.set_position("job-0", Some(1)).expect("queued");
let json = serde_json::to_value(store.view("job-0", 4).expect("present"))
.expect("JobView must serialize");
for key in [
"id",
"kind",
"queue",
"state",
"position",
"queued_ms",
"started_ms",
"elapsed_ms",
"error",
"failure",
"download",
"cancel_requested",
"original_retained",
] {
assert!(json.get(key).is_some(), "missing {key}: {json}");
}
assert_eq!(json["position"], 1);
assert_eq!(json["started_ms"], serde_json::Value::Null);
assert_eq!(json["elapsed_ms"], 4);
assert_eq!(json["failure"], serde_json::Value::Null);
assert_eq!(json["original_retained"], true, "{json}");
}
#[test]
fn a_single_tick_reports_every_cleanup_class() {
let mut store = JobStore::new(limits(1, u64::MAX, 1, 500));
insert_ocr(&mut store, "expired", 0, 0);
finish(&mut store, "expired", 10);
insert_ocr(&mut store, "fresh", 0, 0);
finish(&mut store, "fresh", 6_000);
let report = store.tick(6_500);
assert_eq!(report.expired_jobs, 1, "`fresh` is inside its TTL window");
assert_eq!(report.evicted_for_count, 0);
assert_eq!(store.tombstone_len(), 1);
}
}