mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-20 17:41:37 +00:00
Add exec job store
This commit is contained in:
parent
a30a8ecd0f
commit
3bd29a5d44
2 changed files with 762 additions and 0 deletions
761
src/exec.rs
Normal file
761
src/exec.rs
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant, SystemTime},
|
||||
};
|
||||
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const MAX_SOURCE_SIZE_BYTES: usize = 1024 * 1024;
|
||||
pub const MAX_PENDING_JOBS: usize = 16;
|
||||
pub const MAX_RETAINED_TERMINAL_JOBS: usize = 64;
|
||||
|
||||
pub const CLAIM_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
pub const EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
pub const TERMINAL_RETENTION: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExecJobState {
|
||||
Pending,
|
||||
Claimed,
|
||||
Succeeded,
|
||||
Failed,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
impl ExecJobState {
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::Succeeded | Self::Failed | Self::TimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ExecValue {
|
||||
Nil,
|
||||
String(String),
|
||||
Number(f64),
|
||||
Boolean(bool),
|
||||
Array(Vec<ExecValue>),
|
||||
Table(Vec<(String, ExecValue)>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExecLogLevel {
|
||||
Print,
|
||||
Warn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExecLog {
|
||||
pub level: ExecLogLevel,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ExecJob {
|
||||
pub id: Uuid,
|
||||
pub script_name: String,
|
||||
pub source: String,
|
||||
pub state: ExecJobState,
|
||||
pub created_at: SystemTime,
|
||||
pub claim_deadline: Instant,
|
||||
pub execution_deadline: Option<Instant>,
|
||||
pub result: Option<ExecValue>,
|
||||
pub logs: Option<Vec<ExecLog>>,
|
||||
pub runtime_error: Option<String>,
|
||||
pub traceback: Option<String>,
|
||||
|
||||
terminal_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ExecJob {
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
self.state.is_terminal()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum ExecJobStoreError {
|
||||
#[error("exec source is {size} bytes, exceeding the {limit}-byte limit")]
|
||||
SourceTooLarge { size: usize, limit: usize },
|
||||
|
||||
#[error("exec pending queue is full (limit: {limit})")]
|
||||
PendingQueueFull { limit: usize },
|
||||
|
||||
#[error("exec job {id} does not exist")]
|
||||
UnknownJob { id: Uuid },
|
||||
|
||||
#[error("exec job {id} cannot be completed from state {state:?}")]
|
||||
JobNotClaimed { id: Uuid, state: ExecJobState },
|
||||
|
||||
#[error("exec job {id} was already completed with state {state:?}")]
|
||||
DuplicateCompletion { id: Uuid, state: ExecJobState },
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExecCleanupStats {
|
||||
pub timed_out_pending: usize,
|
||||
pub timed_out_claimed: usize,
|
||||
pub removed_terminal: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExecJobStore {
|
||||
inner: Mutex<ExecJobStoreInner>,
|
||||
claim_timeout: Duration,
|
||||
execution_timeout: Duration,
|
||||
terminal_retention: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ExecJobStoreInner {
|
||||
jobs: HashMap<Uuid, ExecJob>,
|
||||
pending_jobs: VecDeque<Uuid>,
|
||||
claimed_job: Option<Uuid>,
|
||||
terminal_jobs: VecDeque<Uuid>,
|
||||
}
|
||||
|
||||
impl ExecJobStore {
|
||||
pub fn new() -> Self {
|
||||
Self::with_durations(CLAIM_TIMEOUT, EXECUTION_TIMEOUT, TERMINAL_RETENTION)
|
||||
}
|
||||
|
||||
pub fn submit(
|
||||
&self,
|
||||
script_name: impl Into<String>,
|
||||
source: impl Into<String>,
|
||||
) -> Result<ExecJob, ExecJobStoreError> {
|
||||
self.submit_at(
|
||||
script_name.into(),
|
||||
source.into(),
|
||||
SystemTime::now(),
|
||||
Instant::now(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn claim_next(&self) -> Option<ExecJob> {
|
||||
self.claim_next_at(Instant::now())
|
||||
}
|
||||
|
||||
pub fn complete_success(
|
||||
&self,
|
||||
id: Uuid,
|
||||
result: Option<ExecValue>,
|
||||
logs: Option<Vec<ExecLog>>,
|
||||
) -> Result<ExecJob, ExecJobStoreError> {
|
||||
self.complete_success_at(id, result, logs, Instant::now())
|
||||
}
|
||||
|
||||
pub fn complete_failure(
|
||||
&self,
|
||||
id: Uuid,
|
||||
runtime_error: Option<String>,
|
||||
traceback: Option<String>,
|
||||
logs: Option<Vec<ExecLog>>,
|
||||
) -> Result<ExecJob, ExecJobStoreError> {
|
||||
self.complete_failure_at(id, runtime_error, traceback, logs, Instant::now())
|
||||
}
|
||||
|
||||
pub fn get(&self, id: Uuid) -> Result<ExecJob, ExecJobStoreError> {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
|
||||
inner
|
||||
.jobs
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.ok_or(ExecJobStoreError::UnknownJob { id })
|
||||
}
|
||||
|
||||
pub fn cleanup_expired(&self) -> ExecCleanupStats {
|
||||
self.cleanup_expired_at(Instant::now())
|
||||
}
|
||||
|
||||
fn with_durations(
|
||||
claim_timeout: Duration,
|
||||
execution_timeout: Duration,
|
||||
terminal_retention: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(ExecJobStoreInner::default()),
|
||||
claim_timeout,
|
||||
execution_timeout,
|
||||
terminal_retention,
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_at(
|
||||
&self,
|
||||
script_name: String,
|
||||
source: String,
|
||||
created_at: SystemTime,
|
||||
now: Instant,
|
||||
) -> Result<ExecJob, ExecJobStoreError> {
|
||||
let source_size = source.len();
|
||||
if source_size > MAX_SOURCE_SIZE_BYTES {
|
||||
return Err(ExecJobStoreError::SourceTooLarge {
|
||||
size: source_size,
|
||||
limit: MAX_SOURCE_SIZE_BYTES,
|
||||
});
|
||||
}
|
||||
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
if inner.pending_jobs.len() >= MAX_PENDING_JOBS {
|
||||
return Err(ExecJobStoreError::PendingQueueFull {
|
||||
limit: MAX_PENDING_JOBS,
|
||||
});
|
||||
}
|
||||
|
||||
let id = loop {
|
||||
let candidate = Uuid::new_v4();
|
||||
if !inner.jobs.contains_key(&candidate) {
|
||||
break candidate;
|
||||
}
|
||||
};
|
||||
|
||||
let job = ExecJob {
|
||||
id,
|
||||
script_name,
|
||||
source,
|
||||
state: ExecJobState::Pending,
|
||||
created_at,
|
||||
claim_deadline: now + self.claim_timeout,
|
||||
execution_deadline: None,
|
||||
result: None,
|
||||
logs: None,
|
||||
runtime_error: None,
|
||||
traceback: None,
|
||||
terminal_at: None,
|
||||
};
|
||||
|
||||
inner.jobs.insert(id, job.clone());
|
||||
inner.pending_jobs.push_back(id);
|
||||
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
fn claim_next_at(&self, now: Instant) -> Option<ExecJob> {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
if inner.claimed_job.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
while let Some(id) = inner.pending_jobs.pop_front() {
|
||||
let claimed_job = {
|
||||
let Some(job) = inner.jobs.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if job.state != ExecJobState::Pending {
|
||||
continue;
|
||||
}
|
||||
|
||||
job.state = ExecJobState::Claimed;
|
||||
job.execution_deadline = Some(now + self.execution_timeout);
|
||||
job.clone()
|
||||
};
|
||||
|
||||
inner.claimed_job = Some(id);
|
||||
return Some(claimed_job);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn complete_success_at(
|
||||
&self,
|
||||
id: Uuid,
|
||||
result: Option<ExecValue>,
|
||||
logs: Option<Vec<ExecLog>>,
|
||||
now: Instant,
|
||||
) -> Result<ExecJob, ExecJobStoreError> {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
ensure_job_can_complete(&inner, id)?;
|
||||
|
||||
{
|
||||
let job = inner.jobs.get_mut(&id).unwrap();
|
||||
job.state = ExecJobState::Succeeded;
|
||||
job.source.clear();
|
||||
job.result = result;
|
||||
job.logs = logs;
|
||||
job.runtime_error = None;
|
||||
job.traceback = None;
|
||||
job.terminal_at = Some(now);
|
||||
}
|
||||
|
||||
inner.claimed_job = None;
|
||||
track_terminal_job(&mut inner, id);
|
||||
|
||||
Ok(inner.jobs.get(&id).unwrap().clone())
|
||||
}
|
||||
|
||||
fn complete_failure_at(
|
||||
&self,
|
||||
id: Uuid,
|
||||
runtime_error: Option<String>,
|
||||
traceback: Option<String>,
|
||||
logs: Option<Vec<ExecLog>>,
|
||||
now: Instant,
|
||||
) -> Result<ExecJob, ExecJobStoreError> {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
ensure_job_can_complete(&inner, id)?;
|
||||
|
||||
{
|
||||
let job = inner.jobs.get_mut(&id).unwrap();
|
||||
job.state = ExecJobState::Failed;
|
||||
job.source.clear();
|
||||
job.result = None;
|
||||
job.logs = logs;
|
||||
job.runtime_error = runtime_error;
|
||||
job.traceback = traceback;
|
||||
job.terminal_at = Some(now);
|
||||
}
|
||||
|
||||
inner.claimed_job = None;
|
||||
track_terminal_job(&mut inner, id);
|
||||
|
||||
Ok(inner.jobs.get(&id).unwrap().clone())
|
||||
}
|
||||
|
||||
fn cleanup_expired_at(&self, now: Instant) -> ExecCleanupStats {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
let mut stats = ExecCleanupStats::default();
|
||||
|
||||
let pending_count = inner.pending_jobs.len();
|
||||
for _ in 0..pending_count {
|
||||
let id = inner.pending_jobs.pop_front().unwrap();
|
||||
let is_expired = inner
|
||||
.jobs
|
||||
.get(&id)
|
||||
.is_some_and(|job| job.state == ExecJobState::Pending && now >= job.claim_deadline);
|
||||
|
||||
if is_expired {
|
||||
stats.removed_terminal += mark_job_timed_out(&mut inner, id, now);
|
||||
stats.timed_out_pending += 1;
|
||||
} else if inner.jobs.contains_key(&id) {
|
||||
inner.pending_jobs.push_back(id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = inner.claimed_job {
|
||||
let is_expired = inner.jobs.get(&id).is_some_and(|job| {
|
||||
job.state == ExecJobState::Claimed
|
||||
&& job
|
||||
.execution_deadline
|
||||
.is_some_and(|deadline| now >= deadline)
|
||||
});
|
||||
|
||||
if is_expired {
|
||||
stats.removed_terminal += mark_job_timed_out(&mut inner, id, now);
|
||||
inner.claimed_job = None;
|
||||
stats.timed_out_claimed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(id) = inner.terminal_jobs.front().copied() {
|
||||
let should_remove = match inner.jobs.get(&id) {
|
||||
Some(job) => job.terminal_at.is_some_and(|terminal_at| {
|
||||
now.saturating_duration_since(terminal_at) >= self.terminal_retention
|
||||
}),
|
||||
None => true,
|
||||
};
|
||||
|
||||
if !should_remove {
|
||||
break;
|
||||
}
|
||||
|
||||
inner.terminal_jobs.pop_front();
|
||||
if inner.jobs.remove(&id).is_some() {
|
||||
stats.removed_terminal += 1;
|
||||
}
|
||||
}
|
||||
|
||||
stats.removed_terminal += enforce_terminal_limit(&mut inner);
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExecJobStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_job_can_complete(inner: &ExecJobStoreInner, id: Uuid) -> Result<(), ExecJobStoreError> {
|
||||
let job = inner
|
||||
.jobs
|
||||
.get(&id)
|
||||
.ok_or(ExecJobStoreError::UnknownJob { id })?;
|
||||
|
||||
match job.state {
|
||||
ExecJobState::Claimed => Ok(()),
|
||||
state if state.is_terminal() => Err(ExecJobStoreError::DuplicateCompletion { id, state }),
|
||||
state => Err(ExecJobStoreError::JobNotClaimed { id, state }),
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_job_timed_out(inner: &mut ExecJobStoreInner, id: Uuid, now: Instant) -> usize {
|
||||
let job = inner
|
||||
.jobs
|
||||
.get_mut(&id)
|
||||
.expect("queued exec job should exist in the job map");
|
||||
|
||||
job.state = ExecJobState::TimedOut;
|
||||
job.source.clear();
|
||||
job.result = None;
|
||||
job.logs = None;
|
||||
job.runtime_error = None;
|
||||
job.traceback = None;
|
||||
job.terminal_at = Some(now);
|
||||
|
||||
track_terminal_job(inner, id)
|
||||
}
|
||||
|
||||
fn track_terminal_job(inner: &mut ExecJobStoreInner, id: Uuid) -> usize {
|
||||
inner.terminal_jobs.push_back(id);
|
||||
enforce_terminal_limit(inner)
|
||||
}
|
||||
|
||||
fn enforce_terminal_limit(inner: &mut ExecJobStoreInner) -> usize {
|
||||
let mut removed = 0;
|
||||
|
||||
while inner.terminal_jobs.len() > MAX_RETAINED_TERMINAL_JOBS {
|
||||
let id = inner.terminal_jobs.pop_front().unwrap();
|
||||
if inner.jobs.remove(&id).is_some() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
||||
use super::*;
|
||||
|
||||
const TEST_CLAIM_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const TEST_EXECUTION_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const TEST_TERMINAL_RETENTION: Duration = Duration::from_secs(30);
|
||||
|
||||
fn test_store() -> ExecJobStore {
|
||||
ExecJobStore::with_durations(
|
||||
TEST_CLAIM_TIMEOUT,
|
||||
TEST_EXECUTION_TIMEOUT,
|
||||
TEST_TERMINAL_RETENTION,
|
||||
)
|
||||
}
|
||||
|
||||
fn submit_at(store: &ExecJobStore, name: &str, source: &str, now: Instant) -> ExecJob {
|
||||
store
|
||||
.submit_at(
|
||||
name.to_owned(),
|
||||
source.to_owned(),
|
||||
SystemTime::UNIX_EPOCH,
|
||||
now,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_adds_a_pending_job() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
let job = submit_at(&store, "hello.lua", "return 'hello'", now);
|
||||
|
||||
assert_ne!(job.id, Uuid::nil());
|
||||
assert_eq!(job.script_name, "hello.lua");
|
||||
assert_eq!(job.source, "return 'hello'");
|
||||
assert_eq!(job.state, ExecJobState::Pending);
|
||||
assert_eq!(job.created_at, SystemTime::UNIX_EPOCH);
|
||||
assert_eq!(job.claim_deadline, now + TEST_CLAIM_TIMEOUT);
|
||||
assert_eq!(job.execution_deadline, None);
|
||||
assert_eq!(job.result, None);
|
||||
assert_eq!(job.logs, None);
|
||||
assert_eq!(job.runtime_error, None);
|
||||
assert_eq!(job.traceback, None);
|
||||
assert_eq!(store.get(job.id).unwrap(), job);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_returns_the_oldest_pending_job() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
let first = submit_at(&store, "first.lua", "return 1", now);
|
||||
submit_at(&store, "second.lua", "return 2", now);
|
||||
|
||||
let claimed = store.claim_next_at(now + Duration::from_secs(1)).unwrap();
|
||||
|
||||
assert_eq!(claimed.id, first.id);
|
||||
assert_eq!(claimed.state, ExecJobState::Claimed);
|
||||
assert_eq!(
|
||||
claimed.execution_deadline,
|
||||
Some(now + Duration::from_secs(1) + TEST_EXECUTION_TIMEOUT)
|
||||
);
|
||||
assert_eq!(store.get(first.id).unwrap(), claimed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_prevents_a_second_active_claim() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
let first = submit_at(&store, "first.lua", "return 1", now);
|
||||
let second = submit_at(&store, "second.lua", "return 2", now);
|
||||
|
||||
assert_eq!(store.claim_next_at(now).unwrap().id, first.id);
|
||||
assert_eq!(store.claim_next_at(now), None);
|
||||
assert_eq!(store.get(second.id).unwrap().state, ExecJobState::Pending);
|
||||
|
||||
store
|
||||
.complete_success_at(first.id, None, None, now)
|
||||
.unwrap();
|
||||
assert_eq!(store.claim_next_at(now).unwrap().id, second.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_claims_only_return_one_job() {
|
||||
let store = Arc::new(test_store());
|
||||
let now = Instant::now();
|
||||
let job = submit_at(&store, "atomic.lua", "return nil", now);
|
||||
let start = Arc::new(Barrier::new(9));
|
||||
let mut workers = Vec::new();
|
||||
|
||||
for _ in 0..8 {
|
||||
let store = Arc::clone(&store);
|
||||
let start = Arc::clone(&start);
|
||||
workers.push(std::thread::spawn(move || {
|
||||
start.wait();
|
||||
store.claim_next_at(now)
|
||||
}));
|
||||
}
|
||||
|
||||
start.wait();
|
||||
let claims: Vec<_> = workers
|
||||
.into_iter()
|
||||
.filter_map(|worker| worker.join().unwrap())
|
||||
.collect();
|
||||
|
||||
assert_eq!(claims.len(), 1);
|
||||
assert_eq!(claims[0].id, job.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_success_stores_result_and_logs() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
let job = submit_at(&store, "success.lua", "return true", now);
|
||||
store.claim_next_at(now).unwrap();
|
||||
let logs = vec![ExecLog {
|
||||
level: ExecLogLevel::Print,
|
||||
message: "done".to_owned(),
|
||||
}];
|
||||
|
||||
let completed = store
|
||||
.complete_success_at(
|
||||
job.id,
|
||||
Some(ExecValue::Boolean(true)),
|
||||
Some(logs.clone()),
|
||||
now,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(completed.state, ExecJobState::Succeeded);
|
||||
assert!(completed.source.is_empty());
|
||||
assert_eq!(completed.result, Some(ExecValue::Boolean(true)));
|
||||
assert_eq!(completed.logs, Some(logs));
|
||||
assert_eq!(completed.runtime_error, None);
|
||||
assert_eq!(completed.traceback, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_failure_stores_error_traceback_and_logs() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
let job = submit_at(&store, "failure.lua", "error('oh no')", now);
|
||||
store.claim_next_at(now).unwrap();
|
||||
let logs = vec![ExecLog {
|
||||
level: ExecLogLevel::Warn,
|
||||
message: "before failure".to_owned(),
|
||||
}];
|
||||
|
||||
let completed = store
|
||||
.complete_failure_at(
|
||||
job.id,
|
||||
Some("oh no".to_owned()),
|
||||
Some("traceback".to_owned()),
|
||||
Some(logs.clone()),
|
||||
now,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(completed.state, ExecJobState::Failed);
|
||||
assert!(completed.source.is_empty());
|
||||
assert_eq!(completed.result, None);
|
||||
assert_eq!(completed.logs, Some(logs));
|
||||
assert_eq!(completed.runtime_error.as_deref(), Some("oh no"));
|
||||
assert_eq!(completed.traceback.as_deref(), Some("traceback"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_completion_is_rejected() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
let job = submit_at(&store, "duplicate.lua", "return nil", now);
|
||||
store.claim_next_at(now).unwrap();
|
||||
store
|
||||
.complete_success_at(job.id, Some(ExecValue::Nil), None, now)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.complete_failure_at(job.id, None, None, None, now),
|
||||
Err(ExecJobStoreError::DuplicateCompletion {
|
||||
id: job.id,
|
||||
state: ExecJobState::Succeeded,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_job_is_rejected() {
|
||||
let store = test_store();
|
||||
let unknown_id = Uuid::new_v4();
|
||||
|
||||
assert_eq!(
|
||||
store.get(unknown_id),
|
||||
Err(ExecJobStoreError::UnknownJob { id: unknown_id })
|
||||
);
|
||||
assert_eq!(
|
||||
store.complete_success(unknown_id, None, None),
|
||||
Err(ExecJobStoreError::UnknownJob { id: unknown_id })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_times_out_pending_jobs() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
let job = submit_at(&store, "pending.lua", "return nil", now);
|
||||
|
||||
let stats = store.cleanup_expired_at(now + TEST_CLAIM_TIMEOUT);
|
||||
|
||||
assert_eq!(stats.timed_out_pending, 1);
|
||||
assert_eq!(stats.timed_out_claimed, 0);
|
||||
assert_eq!(store.get(job.id).unwrap().state, ExecJobState::TimedOut);
|
||||
assert_eq!(store.claim_next_at(now + TEST_CLAIM_TIMEOUT), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_times_out_claimed_jobs() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
let job = submit_at(&store, "claimed.lua", "return nil", now);
|
||||
let claim_time = now + Duration::from_secs(1);
|
||||
store.claim_next_at(claim_time).unwrap();
|
||||
|
||||
let stats = store.cleanup_expired_at(claim_time + TEST_EXECUTION_TIMEOUT);
|
||||
|
||||
assert_eq!(stats.timed_out_pending, 0);
|
||||
assert_eq!(stats.timed_out_claimed, 1);
|
||||
let timed_out = store.get(job.id).unwrap();
|
||||
assert_eq!(timed_out.state, ExecJobState::TimedOut);
|
||||
assert_eq!(
|
||||
timed_out.execution_deadline,
|
||||
Some(claim_time + TEST_EXECUTION_TIMEOUT)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_old_terminal_jobs() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
let job = submit_at(&store, "old.lua", "return nil", now);
|
||||
store.claim_next_at(now).unwrap();
|
||||
store.complete_success_at(job.id, None, None, now).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.cleanup_expired_at(now + TEST_TERMINAL_RETENTION - Duration::from_nanos(1)),
|
||||
ExecCleanupStats::default()
|
||||
);
|
||||
assert!(store.get(job.id).is_ok());
|
||||
|
||||
let stats = store.cleanup_expired_at(now + TEST_TERMINAL_RETENTION);
|
||||
assert_eq!(stats.removed_terminal, 1);
|
||||
assert_eq!(
|
||||
store.get(job.id),
|
||||
Err(ExecJobStoreError::UnknownJob { id: job.id })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_queue_limit_is_enforced() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
|
||||
for index in 0..MAX_PENDING_JOBS {
|
||||
submit_at(&store, &format!("{index}.lua"), "return nil", now);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
store.submit_at(
|
||||
"overflow.lua".to_owned(),
|
||||
"return nil".to_owned(),
|
||||
SystemTime::UNIX_EPOCH,
|
||||
now,
|
||||
),
|
||||
Err(ExecJobStoreError::PendingQueueFull {
|
||||
limit: MAX_PENDING_JOBS,
|
||||
})
|
||||
);
|
||||
|
||||
store.claim_next_at(now).unwrap();
|
||||
assert!(store
|
||||
.submit_at(
|
||||
"replacement.lua".to_owned(),
|
||||
"return nil".to_owned(),
|
||||
SystemTime::UNIX_EPOCH,
|
||||
now,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_size_limit_is_enforced() {
|
||||
let store = test_store();
|
||||
|
||||
assert!(store
|
||||
.submit("maximum.lua", "x".repeat(MAX_SOURCE_SIZE_BYTES))
|
||||
.is_ok());
|
||||
assert_eq!(
|
||||
store.submit("too-large.lua", "x".repeat(MAX_SOURCE_SIZE_BYTES + 1)),
|
||||
Err(ExecJobStoreError::SourceTooLarge {
|
||||
size: MAX_SOURCE_SIZE_BYTES + 1,
|
||||
limit: MAX_SOURCE_SIZE_BYTES,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_job_limit_evicts_the_oldest_job() {
|
||||
let store = test_store();
|
||||
let now = Instant::now();
|
||||
let mut completed_ids = Vec::new();
|
||||
|
||||
for index in 0..=MAX_RETAINED_TERMINAL_JOBS {
|
||||
let job = submit_at(&store, &format!("{index}.lua"), "return nil", now);
|
||||
store.claim_next_at(now).unwrap();
|
||||
store.complete_success_at(job.id, None, None, now).unwrap();
|
||||
completed_ids.push(job.id);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
store.get(completed_ids[0]),
|
||||
Err(ExecJobStoreError::UnknownJob {
|
||||
id: completed_ids[0],
|
||||
})
|
||||
);
|
||||
for id in &completed_ids[1..] {
|
||||
assert!(store.get(*id).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
#![recursion_limit = "1024"]
|
||||
|
||||
pub mod cli;
|
||||
pub mod exec;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tree_view;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue