Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,41 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn claim_orders_pending_and_unrecovered_expired_jobs_together() {
|
||||
let directory = Directory::new();
|
||||
Runtime::new(Limits::default()).unwrap().run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.schema(scope, job::schema::TABLE, job::schema::SQL).await.unwrap();
|
||||
executor.initialize(scope, "WITH RECURSIVE rows(number) AS (SELECT 0 UNION ALL SELECT number+1 FROM rows WHERE number<299) INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT printf('%06d',number),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',CAST(number AS TEXT),'running',1,2,50,CASE WHEN number=299 THEN 0 ELSE 100 END,0,'old',0,0 FROM rows").await.unwrap();
|
||||
let mut request = submission("pending");
|
||||
request.at = 50;
|
||||
let pending = job::enqueue(&mut executor, scope, request, 1000).await.unwrap().into_text();
|
||||
let first = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
|
||||
// 首轮只恢复 256 项;仍为 running 的过期任务也必须参与最早时间排序。
|
||||
assert_eq!(first.id, "000299");
|
||||
assert_eq!(first.attempt, 2);
|
||||
assert_ne!(first.token, "old");
|
||||
let second = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
|
||||
assert_eq!(second.id, pending);
|
||||
let third = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
|
||||
assert_eq!(third.id, "000000");
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_cleanup_is_bounded_without_hiding_eligible_work() {
|
||||
let directory = Directory::new();
|
||||
Runtime::new(Limits::default()).unwrap().run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.schema(scope, job::schema::TABLE, job::schema::SQL).await.unwrap();
|
||||
executor.initialize(scope, "WITH RECURSIVE rows(number) AS (SELECT 0 UNION ALL SELECT number+1 FROM rows WHERE number<300) INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT printf('%06d',number),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',CAST(number AS TEXT),'running',1,CASE WHEN number=300 THEN 2 ELSE 1 END,50,0,0,'old',0,0 FROM rows").await.unwrap();
|
||||
let claim = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
|
||||
assert_eq!(claim.id, "000300");
|
||||
assert_eq!(claim.attempt, 2);
|
||||
let counts = executor.query(scope, "SELECT (SELECT COUNT(*) FROM _dever_job WHERE state='dead'),(SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token='old')", vec![], 1, |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))).await.unwrap();
|
||||
assert_eq!(counts, vec![(256,44)]);
|
||||
job::complete(&mut executor, scope, &claim, 1001).await.unwrap();
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_execution_decode_preserves_strict_fields() {
|
||||
use dever_runtime::wire;
|
||||
for source in [
|
||||
r#"{"kind":"System","tenant":null,"tenant":1}"#,
|
||||
r#"{"kind":"System","kind":"System","tenant":null}"#,
|
||||
r#"{"kind":"System","tenant":null,"extra":0}"#,
|
||||
r#"{"kind":"System","tenant":"1"}"#,
|
||||
r#"{"kind":"User","provider":"local"}"#,
|
||||
] {
|
||||
assert!(wire::decode::<Execution>(source).is_err(), "{source}");
|
||||
assert!(wire::decode_fixed::<Execution>(source).is_err(), "{source}");
|
||||
}
|
||||
for source in [
|
||||
r#"{"kind":"System","tenant":null}"#,
|
||||
r#"{"kind":"System","tenant":42}"#,
|
||||
] {
|
||||
let original: Execution = wire::decode(source).unwrap();
|
||||
let fixed: Execution = wire::decode_fixed(source).unwrap();
|
||||
assert_eq!(original.tenant(), fixed.tenant());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_queue_keeps_execution_tenants_in_distinct_idempotency_domains() {
|
||||
let directory = Directory::new();
|
||||
Runtime::new(Limits::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ids = Vec::new();
|
||||
for tenant in [None, Some(1), Some(2)] {
|
||||
let identity = Execution::System { tenant };
|
||||
let mut request = submission("one-business-key");
|
||||
request.execution = &identity;
|
||||
let first = job::enqueue(&mut executor, scope, request, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
let mut request = submission("one-business-key");
|
||||
request.execution = &identity;
|
||||
let second = job::enqueue(&mut executor, scope, request, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
assert_eq!(first, second);
|
||||
assert!(!ids.contains(&first));
|
||||
ids.push(first);
|
||||
}
|
||||
let mut tenants = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let claim = job::claim(&mut executor, scope, 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let identity: Execution = dever_runtime::wire::decode(&claim.execution).unwrap();
|
||||
assert_eq!(identity.tenant().unwrap_or(0), claim.execution_tenant);
|
||||
tenants.push(claim.execution_tenant);
|
||||
job::complete(&mut executor, scope, &claim, 1001)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
tenants.sort_unstable();
|
||||
assert_eq!(tenants, [0, 1, 2]);
|
||||
let identity = Execution::System { tenant: Some(0) };
|
||||
let mut request = submission("invalid");
|
||||
request.execution = &identity;
|
||||
assert!(
|
||||
job::enqueue(&mut executor, scope, request, 1000)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use super::*;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
#[test]
|
||||
fn expired_ack_is_rejected_after_waiting_for_the_write_lock() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut owner = database.executor().await.unwrap();
|
||||
owner
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
job::enqueue(&mut owner, scope, submission("locked"), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = Arc::new(
|
||||
job::claim(&mut owner, scope, 1000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut waiting = database.executor().await.unwrap();
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
let (ready, started) = tokio::sync::oneshot::channel();
|
||||
let claim_copy = claim.clone();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
ready.send(()).unwrap();
|
||||
job::complete(&mut waiting, &child, &claim_copy, 1001).await
|
||||
})
|
||||
.unwrap();
|
||||
started.await.unwrap();
|
||||
// 租约必须包含 SQL 锁外的等待时间,不能复用等待前的时间快照。
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
assert!(
|
||||
task.wait().await.unwrap().is_err(),
|
||||
"expired lease acknowledged after lock wait"
|
||||
);
|
||||
let next = job::claim(&mut owner, scope, 1300, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next.id, claim.id);
|
||||
assert_ne!(next.token, claim.token);
|
||||
assert_eq!(next.attempt, 2);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_lease_starts_after_its_write_lock_wait() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut owner = database.executor().await.unwrap();
|
||||
owner
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
job::enqueue(&mut owner, scope, submission("claim"), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut waiting = database.executor().await.unwrap();
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
let (ready, started) = tokio::sync::oneshot::channel();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
ready.send(()).unwrap();
|
||||
job::claim(&mut waiting, &child, 1000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
started.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
let claim = task.wait().await.unwrap();
|
||||
assert!(
|
||||
claim.lease_until >= 1300,
|
||||
"lease lost its duration while waiting for the write lock: {}",
|
||||
claim.lease_until
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_waiting_ack_leaves_the_claim_recoverable() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut owner = database.executor().await.unwrap();
|
||||
owner
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
job::enqueue(&mut owner, scope, submission("cancelled"), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = Arc::new(
|
||||
job::claim(&mut owner, scope, 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut waiting = database.executor().await.unwrap();
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
let (ready, started) = tokio::sync::oneshot::channel();
|
||||
let claim_copy = claim.clone();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
ready.send(()).unwrap();
|
||||
job::complete(&mut waiting, &child, &claim_copy, 1001).await
|
||||
})
|
||||
.unwrap();
|
||||
started.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
task.cancel();
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
assert!(matches!(
|
||||
task.wait().await,
|
||||
Err(dever_runtime::task::Error::Cancelled)
|
||||
));
|
||||
let next = job::claim(&mut owner, scope, 3000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next.id, claim.id);
|
||||
assert_ne!(next.token, claim.token);
|
||||
assert_eq!(next.attempt, 2);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_ack_rolls_back_the_callers_business_transaction() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
.schema(
|
||||
scope,
|
||||
"business",
|
||||
"CREATE TABLE business(id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
job::enqueue(&mut executor, scope, submission("atomic"), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = job::claim(&mut executor, scope, 1000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
executor
|
||||
.execute(scope, "INSERT INTO business(id) VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::complete(&mut executor, scope, &claim, claim.lease_until)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(executor.finish(scope, owner, true).await.is_err());
|
||||
let rows = executor
|
||||
.query(scope, "SELECT count(*) FROM business", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![0]);
|
||||
let next = job::claim(&mut executor, scope, claim.lease_until, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next.id, claim.id);
|
||||
assert_eq!(next.attempt, 2);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use dever_runtime::{
|
||||
database::{Database, Options, Parameter},
|
||||
job::{self, Execution, Policy, Submission},
|
||||
task::{Limits, Runtime},
|
||||
wire::json::Json,
|
||||
};
|
||||
mod cleanup;
|
||||
mod identity;
|
||||
mod lease;
|
||||
mod performance;
|
||||
mod schedule;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
LazyLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dever-job-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
Options {
|
||||
driver: "sqlite".into(),
|
||||
path: "queue.db".into(),
|
||||
url: None,
|
||||
tls: None,
|
||||
tenant_directory: None,
|
||||
tenant_database_prefix: None,
|
||||
min_connections: 1,
|
||||
max_connections: 4,
|
||||
max_page_size: 100,
|
||||
wait_timeout_ms: 1000,
|
||||
io_timeout_ms: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
fn submission(key: &str) -> Submission<'_> {
|
||||
static PAYLOAD: LazyLock<Json> = LazyLock::new(|| Json::parse(r#"{"number":1}"#).unwrap());
|
||||
Submission {
|
||||
target: "demo.account.publish",
|
||||
payload: Some(&PAYLOAD),
|
||||
schema: "schema-1",
|
||||
execution: &Execution::System { tenant: None },
|
||||
key,
|
||||
policy: Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 50,
|
||||
},
|
||||
at: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_queue_keeps_idempotency_transactions_and_rejects_stale_leases() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
let id = job::enqueue(&mut executor, scope, submission("one"), 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
assert_eq!(
|
||||
job::enqueue(&mut executor, scope, submission("one"), 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text(),
|
||||
id
|
||||
);
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, 950, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let first = job::claim(&mut executor, scope, 1000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(first.id, id);
|
||||
assert_eq!(first.attempt, 1);
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, first.lease_until - 50, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let second = job::claim(&mut executor, scope, first.lease_until, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(second.id, id);
|
||||
assert_eq!(second.attempt, 2);
|
||||
assert_ne!(first.token, second.token);
|
||||
assert!(
|
||||
job::complete(&mut executor, scope, &first, 1101)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
job::complete(&mut executor, scope, &second, 1101)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::complete(&mut executor, scope, &second, 1102)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let next = job::enqueue(&mut executor, scope, submission("one"), 1102)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(next.into_text(), id);
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
job::enqueue(&mut executor, scope, submission("rolled back"), 1102)
|
||||
.await
|
||||
.unwrap();
|
||||
executor.finish(scope, owner, false).await.unwrap();
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE task_key=?1",
|
||||
vec![Parameter::Text("rolled back".into())],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![0]
|
||||
);
|
||||
drop(executor);
|
||||
database.close();
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let persisted = job::claim(&mut executor, scope, 1103, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(persisted.id, id);
|
||||
assert!(matches!(
|
||||
dever_runtime::wire::decode::<Execution>(&persisted.execution).unwrap(),
|
||||
Execution::System { tenant: None }
|
||||
));
|
||||
job::block(&mut executor, scope, &persisted, 1104)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, 1204, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_backoff_and_exhaustion_survive_a_lost_worker() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
job::enqueue(&mut executor, scope, submission("retry"), 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let first = job::claim(&mut executor, scope, 1000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let config = job::Config {
|
||||
retry_base_ms: 10,
|
||||
retry_max_ms: 40,
|
||||
..Default::default()
|
||||
};
|
||||
job::fail(&mut executor, scope, &first, 1001, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
let available = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT available_at FROM _dever_job",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap()[0];
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, available - 50, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let last = job::claim(&mut executor, scope, available, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(last.attempt, 2);
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, last.lease_until, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(scope, "SELECT state FROM _dever_job", vec![], 1, |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
vec!["dead"]
|
||||
);
|
||||
assert!(
|
||||
job::complete(&mut executor, scope, &last, 1112)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
for (attempt, delay) in [(1, 10), (2, 20), (3, 40), (100, 40)] {
|
||||
assert_eq!(config.delay(attempt), delay);
|
||||
}
|
||||
assert!(
|
||||
config
|
||||
.validate(
|
||||
&[Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 60000
|
||||
}],
|
||||
4,
|
||||
true,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.validate(
|
||||
&[Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 50
|
||||
}],
|
||||
2,
|
||||
true,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
config
|
||||
.validate(
|
||||
&[Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 50,
|
||||
}],
|
||||
4,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
config
|
||||
.validate(
|
||||
&[Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 60000,
|
||||
}],
|
||||
4,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
config
|
||||
.validate(
|
||||
&[Policy {
|
||||
retries: 0,
|
||||
timeout_ms: 60000
|
||||
}],
|
||||
4,
|
||||
false
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! 阶段计时使用预借出的单连接;独立 Rust 仅共享表结构和返回字段,不调用 Dever 运行库。
|
||||
//! 原有生成程序基准另外覆盖连接借还、业务事务和完整分派,不能用这里替代它。
|
||||
|
||||
mod native;
|
||||
mod sql;
|
||||
use super::*;
|
||||
use deadpool_sqlite::rusqlite::Connection;
|
||||
use dever_runtime::{database::Executor, job::Claim, task::Scope};
|
||||
use std::{
|
||||
hint::black_box,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Phase {
|
||||
Enqueue,
|
||||
Duplicate,
|
||||
Claim,
|
||||
Complete,
|
||||
Cycle,
|
||||
}
|
||||
|
||||
async fn clear(executor: &mut Executor, scope: &Scope<'_>) {
|
||||
executor
|
||||
.execute(scope, "DELETE FROM _dever_job", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn measure_dever(
|
||||
executor: &mut Executor,
|
||||
scope: &Scope<'_>,
|
||||
phase: Phase,
|
||||
keys: &[String],
|
||||
payload: &Json,
|
||||
) -> (u128, u64) {
|
||||
clear(executor, scope).await;
|
||||
if matches!(phase, Phase::Duplicate | Phase::Claim | Phase::Complete) {
|
||||
for key in keys {
|
||||
let mut value = submission(key);
|
||||
value.payload = Some(payload);
|
||||
job::enqueue(executor, scope, value, 1000).await.unwrap();
|
||||
}
|
||||
}
|
||||
let mut claims = Vec::new();
|
||||
if matches!(phase, Phase::Complete) {
|
||||
for _ in keys {
|
||||
claims.push(
|
||||
job::claim(executor, scope, 1000, 60000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let start = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
match phase {
|
||||
Phase::Enqueue | Phase::Duplicate => {
|
||||
let mut value = submission(key);
|
||||
value.payload = Some(payload);
|
||||
checksum += black_box(
|
||||
job::enqueue(executor, scope, value, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text(),
|
||||
)
|
||||
.len() as u64;
|
||||
}
|
||||
Phase::Claim => {
|
||||
let claim = job::claim(executor, scope, 1000, 60000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
checksum += black_box(claim).id.len() as u64;
|
||||
}
|
||||
Phase::Complete => {
|
||||
job::complete(executor, scope, &claims[index], 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
checksum += 1;
|
||||
}
|
||||
Phase::Cycle => {
|
||||
let mut value = submission(key);
|
||||
value.payload = Some(payload);
|
||||
let id = job::enqueue(executor, scope, value, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
let claim = job::claim(executor, scope, 1000, 60000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(id, claim.id);
|
||||
job::complete(executor, scope, &claim, 1000).await.unwrap();
|
||||
checksum += black_box(claim).id.len() as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
let elapsed = start.elapsed().as_nanos();
|
||||
let state = state(phase);
|
||||
let count = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state=?1",
|
||||
vec![Parameter::Text(state.into())],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap()[0];
|
||||
assert_eq!(count, keys.len() as i64);
|
||||
(elapsed, checksum)
|
||||
}
|
||||
|
||||
fn state(phase: Phase) -> &'static str {
|
||||
match phase {
|
||||
Phase::Enqueue | Phase::Duplicate => "pending",
|
||||
Phase::Claim => "running",
|
||||
Phase::Complete | Phase::Cycle => "completed",
|
||||
}
|
||||
}
|
||||
|
||||
async fn measure_native(
|
||||
native: &mut native::Native,
|
||||
phase: Phase,
|
||||
keys: &[String],
|
||||
payload: &str,
|
||||
) -> (u128, u64) {
|
||||
native
|
||||
.call(|connection, _| connection.execute("DELETE FROM _dever_job", []))
|
||||
.await
|
||||
.unwrap();
|
||||
if matches!(phase, Phase::Duplicate | Phase::Claim | Phase::Complete) {
|
||||
for key in keys {
|
||||
native.enqueue(key, payload).await;
|
||||
}
|
||||
}
|
||||
let mut claims: Vec<Claim> = Vec::new();
|
||||
if matches!(phase, Phase::Complete) {
|
||||
for _ in keys {
|
||||
claims.push(native.claim().await.unwrap());
|
||||
}
|
||||
}
|
||||
let start = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
match phase {
|
||||
Phase::Enqueue | Phase::Duplicate => {
|
||||
checksum += black_box(native.enqueue(key, payload).await).len() as u64
|
||||
}
|
||||
Phase::Claim => checksum += black_box(native.claim().await.unwrap()).id.len() as u64,
|
||||
Phase::Complete => {
|
||||
native.complete(&claims[index]).await;
|
||||
checksum += 1;
|
||||
}
|
||||
Phase::Cycle => {
|
||||
let id = native.enqueue(key, payload).await;
|
||||
let claim = native.claim().await.unwrap();
|
||||
assert_eq!(id, claim.id);
|
||||
native.complete(&claim).await;
|
||||
checksum += black_box(claim).id.len() as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
let elapsed = start.elapsed().as_nanos();
|
||||
let count: i64 = native
|
||||
.call(move |connection, _| {
|
||||
connection.query_row(
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state=?1",
|
||||
[state(phase)],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, keys.len() as i64);
|
||||
(elapsed, checksum)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release-only owned SQLite phase timing; invoke explicitly"]
|
||||
fn independent_sqlite_phases() {
|
||||
let directory = Directory::new();
|
||||
let native_runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.max_blocking_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let connection = Connection::open(directory.0.join("native.db")).unwrap();
|
||||
connection.busy_timeout(Duration::from_secs(10)).unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL;",
|
||||
)
|
||||
.unwrap();
|
||||
connection.execute_batch(job::schema::SQL.sqlite).unwrap();
|
||||
let mut native = native::Native::new(connection);
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap();
|
||||
let mut options = options();
|
||||
options.max_connections = 1;
|
||||
options.io_timeout_ms = 10000;
|
||||
let database = runtime
|
||||
.run(async |scope| Database::open(&directory.0, options, scope).await)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut executor = runtime
|
||||
.run(async |scope| {
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
})
|
||||
.unwrap();
|
||||
println!("case,sample,implementation,iterations,nanoseconds,checksum");
|
||||
for (name, phase, count, payload) in [
|
||||
(
|
||||
"enqueue",
|
||||
Phase::Enqueue,
|
||||
1000,
|
||||
Json::parse("{\"number\":1}").unwrap(),
|
||||
),
|
||||
(
|
||||
"enqueue_64k",
|
||||
Phase::Enqueue,
|
||||
64,
|
||||
Json::parse(&format!("\"{}\"", "x".repeat(65536))).unwrap(),
|
||||
),
|
||||
(
|
||||
"duplicate",
|
||||
Phase::Duplicate,
|
||||
1000,
|
||||
Json::parse("{\"number\":1}").unwrap(),
|
||||
),
|
||||
(
|
||||
"claim",
|
||||
Phase::Claim,
|
||||
256,
|
||||
Json::parse("{\"number\":1}").unwrap(),
|
||||
),
|
||||
(
|
||||
"complete",
|
||||
Phase::Complete,
|
||||
256,
|
||||
Json::parse("{\"number\":1}").unwrap(),
|
||||
),
|
||||
(
|
||||
"cycle",
|
||||
Phase::Cycle,
|
||||
256,
|
||||
Json::parse("{\"number\":1}").unwrap(),
|
||||
),
|
||||
] {
|
||||
let keys: Vec<_> = (0..count).map(|index| format!("key-{index:04}")).collect();
|
||||
for sample in 0..16 {
|
||||
let mut order = [("dever", true), ("rust", false), ("control", true)];
|
||||
order.rotate_left(sample % 3);
|
||||
let mut expected = None;
|
||||
for (label, generated) in order {
|
||||
let (elapsed, checksum) = if generated {
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
measure_dever(&mut executor, scope, phase, &keys, &payload).await
|
||||
})
|
||||
.unwrap()
|
||||
} else {
|
||||
native_runtime.block_on(measure_native(
|
||||
&mut native,
|
||||
phase,
|
||||
&keys,
|
||||
payload.as_str(),
|
||||
))
|
||||
};
|
||||
if let Some(expected) = expected {
|
||||
assert_eq!(checksum, expected);
|
||||
}
|
||||
expected = Some(checksum);
|
||||
if sample > 0 {
|
||||
println!("{name},{},{label},{count},{elapsed},{checksum}", sample - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
native_runtime.block_on(native.close());
|
||||
drop(executor);
|
||||
database.close();
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 独立 Rust 对照仅使用 Tokio/rusqlite;连接移交、取消、期限和退出等待不调用 Dever。
|
||||
|
||||
use deadpool_sqlite::rusqlite::{Connection, Result};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
struct Cancel(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for Cancel {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Native {
|
||||
connection: Option<Connection>,
|
||||
workers: Arc<Semaphore>,
|
||||
tracker: TaskTracker,
|
||||
}
|
||||
|
||||
impl Native {
|
||||
pub(super) fn new(connection: Connection) -> Self {
|
||||
Self {
|
||||
connection: Some(connection),
|
||||
workers: Arc::new(Semaphore::new(1)),
|
||||
tracker: TaskTracker::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn call<Value: Send + 'static>(
|
||||
&mut self,
|
||||
operation: impl FnOnce(&mut Connection, &dyn Fn() -> Result<()>) -> Result<Value>
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> Result<Value> {
|
||||
let mut connection = self.connection.take().unwrap();
|
||||
let cancelled = Cancel(Arc::new(AtomicBool::new(false)));
|
||||
let signal = cancelled.0.clone();
|
||||
let deadline = Instant::now() + TIMEOUT;
|
||||
let workers = self.workers.clone();
|
||||
let tracker = self.tracker.clone();
|
||||
let work = async move {
|
||||
let worker = workers.acquire_owned().await.unwrap();
|
||||
let tracking = tracker.token();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _worker = worker;
|
||||
let check = || {
|
||||
if signal.load(Ordering::Acquire) || Instant::now() >= deadline {
|
||||
Err(deadpool_sqlite::rusqlite::Error::InvalidQuery)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let outcome = (|| {
|
||||
check()?;
|
||||
let progress = signal.clone();
|
||||
connection.progress_handler(
|
||||
1000,
|
||||
Some(move || {
|
||||
progress.load(Ordering::Acquire) || Instant::now() >= deadline
|
||||
}),
|
||||
)?;
|
||||
let result = operation(&mut connection, &check);
|
||||
connection.progress_handler(0, None::<fn() -> bool>)?;
|
||||
check()?;
|
||||
result
|
||||
})();
|
||||
(connection, outcome, tracking)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
let (connection, result, _tracking) = tokio::time::timeout(TIMEOUT, work).await.unwrap();
|
||||
self.connection = Some(connection);
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) async fn transaction<Value: Send + 'static>(
|
||||
&mut self,
|
||||
operation: impl FnOnce(&Connection) -> Result<Value> + Send + 'static,
|
||||
) -> Result<Value> {
|
||||
self.call(move |connection, check| {
|
||||
let transaction = connection.transaction_with_behavior(
|
||||
deadpool_sqlite::rusqlite::TransactionBehavior::Immediate,
|
||||
)?;
|
||||
check()?;
|
||||
let result = operation(&transaction)?;
|
||||
check()?;
|
||||
transaction.commit()?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn close(self) {
|
||||
self.tracker.close();
|
||||
self.tracker.wait().await;
|
||||
drop(self.connection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! 同一表合同的独立 SQL 实现;不复用生产队列函数,也不新增生产解析或调度层。
|
||||
|
||||
use super::native::Native;
|
||||
use deadpool_sqlite::rusqlite::{OptionalExtension, params};
|
||||
use dever_runtime::job::{Claim, Policy};
|
||||
use std::{fmt::Write, time::Instant};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Execution {
|
||||
kind: &'static str,
|
||||
tenant: Option<i64>,
|
||||
}
|
||||
|
||||
pub(super) fn identifier() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
getrandom::getrandom(&mut bytes).unwrap();
|
||||
let mut value = String::with_capacity(32);
|
||||
write!(value, "{:032x}", u128::from_be_bytes(bytes)).unwrap();
|
||||
value
|
||||
}
|
||||
|
||||
impl Native {
|
||||
pub(super) async fn enqueue(&mut self, key: &str, payload: &str) -> String {
|
||||
assert!(!key.is_empty() && key.len() <= 1024);
|
||||
let id = identifier();
|
||||
let (key, payload, target, schema) = (
|
||||
key.to_owned(),
|
||||
payload.to_owned(),
|
||||
"demo.account.publish".to_owned(),
|
||||
"schema-1".to_owned(),
|
||||
);
|
||||
let execution = serde_json::to_string(&Execution {
|
||||
kind: "System",
|
||||
tenant: None,
|
||||
})
|
||||
.unwrap();
|
||||
self.transaction(move |connection| {
|
||||
let changed = connection.prepare_cached("INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,retries,timeout_ms,available_at,created_at,execution_tenant) VALUES (?1,?2,?3,?4,?5,?6,'pending',?7,?8,?9,?10,?11) ON CONFLICT(execution_tenant,target,task_key) WHERE state IN ('pending','running') DO NOTHING")?.execute(params![id,target,payload,schema,execution,key,2,50,1000,1000,0])?;
|
||||
if changed == 1 {
|
||||
Ok(id)
|
||||
} else {
|
||||
connection.prepare_cached("SELECT id FROM _dever_job WHERE execution_tenant=?1 AND target=?2 AND task_key=?3 AND state IN ('pending','running')")?.query_row(params![0,target,key], |row| row.get(0))
|
||||
}
|
||||
}).await.unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn claim(&mut self) -> Option<Claim> {
|
||||
let started = Instant::now();
|
||||
let token = identifier();
|
||||
self.transaction(move |connection| {
|
||||
let now = 1000i64.checked_add(started.elapsed().as_millis().try_into().unwrap()).unwrap();
|
||||
let lease_until = now.checked_add(60000).unwrap();
|
||||
connection.prepare_cached("UPDATE _dever_job SET state=CASE WHEN attempt >= retries THEN 'dead' ELSE 'pending' END, token=NULL,lease_until=NULL,error='lease expired' WHERE id IN (SELECT id FROM _dever_job WHERE state='running' AND lease_until <= ?1 ORDER BY lease_until,id LIMIT 256)")?.execute([now])?;
|
||||
let value = connection.prepare_cached(
|
||||
"SELECT id,target,substr(payload,1,16777217),schema,substr(execution,1,16777217),retries,timeout_ms,attempt,execution_tenant FROM _dever_job
|
||||
WHERE id=(SELECT id FROM (
|
||||
SELECT * FROM (SELECT id,available_at FROM _dever_job WHERE state='pending' AND attempt < retries AND available_at <= ?1 ORDER BY available_at,id LIMIT 1)
|
||||
UNION ALL
|
||||
SELECT * FROM (SELECT id,available_at FROM _dever_job WHERE state='running' AND attempt < retries AND lease_until <= ?1 AND available_at <= ?1 ORDER BY available_at,id LIMIT 1)
|
||||
) ORDER BY available_at,id LIMIT 1)"
|
||||
)?.query_row([now], |row| Ok(Claim {
|
||||
id: row.get(0)?, target: row.get(1)?, payload: row.get(2)?, schema: row.get(3)?, execution: row.get(4)?,
|
||||
policy: Policy { retries: row.get(5)?, timeout_ms: row.get(6)? }, attempt: row.get::<_,i64>(7)? + 1,
|
||||
token, lease_until, execution_tenant: row.get(8)?,
|
||||
})).optional()?;
|
||||
if let Some(value) = &value {
|
||||
connection.prepare_cached("UPDATE _dever_job SET state='running',attempt=?1,token=?2,lease_until=?3,error=CASE WHEN state='running' THEN 'lease expired' ELSE error END WHERE id=?4")?.execute(params![value.attempt,value.token,lease_until,value.id])?;
|
||||
}
|
||||
Ok(value)
|
||||
}).await.unwrap()
|
||||
}
|
||||
|
||||
pub(super) async fn complete(&mut self, claim: &Claim) {
|
||||
let (id, token) = (claim.id.clone(), claim.token.clone());
|
||||
let started = Instant::now();
|
||||
self.transaction(move |connection| {
|
||||
let now = 1000i64.checked_add(started.elapsed().as_millis().try_into().unwrap()).unwrap();
|
||||
let changed = connection.prepare_cached("UPDATE _dever_job SET state=?1,available_at=?2,error=?3,token=NULL,lease_until=NULL WHERE id=?4 AND state='running' AND token=?5 AND lease_until > ?6")?.execute(params!["completed",now,None::<&str>,id,token,now])?;
|
||||
assert_eq!(changed, 1);
|
||||
Ok(())
|
||||
}).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
use super::*;
|
||||
use crate::job::{Failure, heartbeat, queue};
|
||||
use std::{
|
||||
future::pending,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
struct Finished(Arc<AtomicBool>);
|
||||
impl Drop for Finished {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn committed_handler_wins_renewal_race_while_scope_drains_children() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
case.executor(scope)
|
||||
.await
|
||||
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = Arc::new(claimed(case, scope, "commit-race", 300).await);
|
||||
let value = claim.clone();
|
||||
let owned = case.clone();
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let handler = scope
|
||||
.spawn(move |child| async move {
|
||||
let mut executor = owned.executor(&child).await;
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
job::complete(
|
||||
&mut executor,
|
||||
&child,
|
||||
&value,
|
||||
crate::database::now().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (entered, ready) = tokio::sync::oneshot::channel();
|
||||
let descendant = child
|
||||
.spawn(move |grandchild| async move {
|
||||
grandchild
|
||||
.blocking(move || {
|
||||
let _finished = Finished(observed);
|
||||
entered.send(()).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(450));
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
drop(descendant);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let config = job::Config {
|
||||
lease_ms: 300,
|
||||
..Default::default()
|
||||
};
|
||||
heartbeat::run(scope, registry, (0, None), &config, &claim, handler)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(finished.load(Ordering::SeqCst));
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='completed' AND attempt=1"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
let mut executor = case.executor(scope).await;
|
||||
assert!(
|
||||
job::claim(
|
||||
&mut executor,
|
||||
scope,
|
||||
crate::database::now().unwrap() + 1000,
|
||||
300
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async fn claimed(case: &Case, scope: &Scope<'_>, key: &str, lease: i64) -> Claim {
|
||||
let mut executor = case.executor(scope).await;
|
||||
let now = crate::database::now().unwrap();
|
||||
let mut request = submission(key);
|
||||
request.policy.timeout_ms = 3000;
|
||||
job::enqueue(&mut executor, scope, request, now)
|
||||
.await
|
||||
.unwrap();
|
||||
job::claim(&mut executor, scope, now, lease)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn heartbeat_keeps_business_transaction_alive_across_multiple_leases() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor
|
||||
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = Arc::new(claimed(case, scope, "long-transaction", 200).await);
|
||||
let value = claim.clone();
|
||||
let owned = case.clone();
|
||||
let handler = scope
|
||||
.spawn(move |child| async move {
|
||||
let mut executor = owned.executor(&child).await;
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.query_one("SELECT pg_sleep(0.7)", &[])
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
job::complete(
|
||||
&mut executor,
|
||||
&child,
|
||||
&value,
|
||||
crate::database::now().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let config = job::Config {
|
||||
lease_ms: 200,
|
||||
..Default::default()
|
||||
};
|
||||
let (result, ()) = tokio::join!(
|
||||
heartbeat::run(scope, registry, (0, None), &config, &claim, handler),
|
||||
async {
|
||||
tokio::time::sleep(Duration::from_millis(450)).await;
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, crate::database::now().unwrap(), 200)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
},
|
||||
);
|
||||
result.unwrap();
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
|
||||
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='completed'"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn lost_fence_cancels_long_handler_and_joins_children_before_returning() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor
|
||||
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = Arc::new(claimed(case, scope, "lost-fence", 300).await);
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let (entered, ready) = tokio::sync::oneshot::channel();
|
||||
let owned = case.clone();
|
||||
let handler = scope
|
||||
.spawn(move |child| async move {
|
||||
let mut executor = owned.executor(&child).await;
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (2)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
let descendant = child
|
||||
.spawn(move |_| async move {
|
||||
let _finished = Finished(observed);
|
||||
entered.send(()).unwrap();
|
||||
pending::<()>().await;
|
||||
})
|
||||
.unwrap();
|
||||
descendant.wait().await.map_err(|_| Failure::Handler)?;
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let config = job::Config {
|
||||
lease_ms: 300,
|
||||
..Default::default()
|
||||
};
|
||||
let (result, ()) = tokio::join!(
|
||||
heartbeat::run(scope, registry, (0, None), &config, &claim, handler),
|
||||
async {
|
||||
ready.await.unwrap();
|
||||
executor
|
||||
.execute(scope, "UPDATE _dever_job SET token='other-worker'", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
},
|
||||
);
|
||||
assert!(matches!(result, Err(Failure::LeaseLost)));
|
||||
assert!(finished.load(Ordering::SeqCst));
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
|
||||
assert_eq!(case.count(scope, "SELECT id FROM business").await, 1);
|
||||
assert!(
|
||||
job::complete(
|
||||
&mut executor,
|
||||
scope,
|
||||
&claim,
|
||||
crate::database::now().unwrap()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE token='other-worker' AND state='running'"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn renewal_checks_expiry_after_waiting_for_row_lock() {
|
||||
fixture(async |case, scope| {
|
||||
let claim = claimed(case, scope, "row-wait", 100).await;
|
||||
let mut owner = case.executor(scope).await;
|
||||
let mut waiting = case.executor(scope).await;
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
owner
|
||||
.query(
|
||||
scope,
|
||||
"SELECT id FROM _dever_job WHERE id=$1 FOR UPDATE",
|
||||
vec![Parameter::Text(claim.id.clone())],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = crate::database::now().unwrap();
|
||||
let (result, ()) = tokio::join!(
|
||||
queue::renew(&mut waiting, scope, &claim, before, 1000),
|
||||
async {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT lease_until FROM _dever_job")
|
||||
.await,
|
||||
claim.lease_until
|
||||
);
|
||||
let replacement = job::claim(&mut waiting, scope, crate::database::now().unwrap(), 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(replacement.token, claim.token);
|
||||
assert!(
|
||||
queue::renew(
|
||||
&mut waiting,
|
||||
scope,
|
||||
&claim,
|
||||
crate::database::now().unwrap(),
|
||||
1000
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
job::complete(
|
||||
&mut waiting,
|
||||
scope,
|
||||
&replacement,
|
||||
crate::database::now().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn locked_claim(case: &Case, scope: &Scope<'_>) -> (Executor, Executor, Claim, bool) {
|
||||
let mut owner = case.executor(scope).await;
|
||||
let waiting = case.executor(scope).await;
|
||||
job::enqueue(&mut owner, scope, submission("locked"), NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = job::claim(&mut owner, scope, NOW, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
owner
|
||||
.query(
|
||||
scope,
|
||||
"SELECT id FROM _dever_job WHERE id=$1 FOR UPDATE",
|
||||
vec![Parameter::Text(claim.id.clone())],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(owner, waiting, claim, transaction)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn lease_expiration_after_row_lock_wait_rolls_back_business_writes() {
|
||||
fixture(async |case, scope| {
|
||||
let (mut owner, mut waiting, claim, transaction) = locked_claim(case, scope).await;
|
||||
waiting
|
||||
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
|
||||
.await
|
||||
.unwrap();
|
||||
let business = waiting.begin(scope).await.unwrap();
|
||||
waiting
|
||||
.execute(scope, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
let (result, ()) =
|
||||
tokio::join!(job::complete(&mut waiting, scope, &claim, NOW + 1), async {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
},);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"lease checked before the row lock was acquired"
|
||||
);
|
||||
assert!(waiting.finish(scope, business, true).await.is_err());
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 0);
|
||||
let retry = job::claim(&mut waiting, scope, NOW + 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(retry.token, claim.token);
|
||||
assert!(
|
||||
job::complete(&mut waiting, scope, &claim, NOW + 1001)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
job::complete(&mut waiting, scope, &retry, NOW + 1001)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn claim_clock_includes_table_lock_wait() {
|
||||
fixture(async |case, scope| {
|
||||
let mut owner = case.executor(scope).await;
|
||||
let mut waiting = case.executor(scope).await;
|
||||
job::enqueue(&mut owner, scope, submission("wait"), NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
owner
|
||||
.initialize(scope, "LOCK TABLE _dever_job IN ACCESS EXCLUSIVE MODE")
|
||||
.await
|
||||
.unwrap();
|
||||
let (result, ()) = tokio::join!(job::claim(&mut waiting, scope, NOW, 100), async {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
},);
|
||||
assert!(result.unwrap().unwrap().lease_until >= NOW + 300);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn cancellation_discards_waiting_transaction_and_preserves_recovery() {
|
||||
fixture(async |case, scope| {
|
||||
let (mut owner, mut waiting, claim, transaction) = locked_claim(case, scope).await;
|
||||
let claim = Arc::new(claim);
|
||||
let copy = claim.clone();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
started.send(()).unwrap();
|
||||
job::complete(&mut waiting, &child, ©, NOW + 1).await
|
||||
})
|
||||
.unwrap();
|
||||
ready.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(task.stop().await.unwrap().is_none());
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
let mut replacement = case.executor(scope).await;
|
||||
let next = job::claim(&mut replacement, scope, NOW + 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next.id, claim.id);
|
||||
assert_ne!(next.token, claim.token);
|
||||
job::complete(&mut replacement, scope, &next, NOW + 1001)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use crate::{
|
||||
adapter::Document,
|
||||
database::{Binding, Database, Error, Executor, Options, Parameter, Registry},
|
||||
job::{self, Claim, Execution, Policy, Submission},
|
||||
task::{Runtime, Scope},
|
||||
wire::json::Json,
|
||||
};
|
||||
use futures_util::FutureExt;
|
||||
use std::{
|
||||
panic::AssertUnwindSafe,
|
||||
path::PathBuf,
|
||||
sync::{Arc, LazyLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
mod heartbeat;
|
||||
mod lease;
|
||||
mod queue;
|
||||
mod schedule;
|
||||
mod workers;
|
||||
|
||||
const NOW: i64 = 1_700_000_000_000;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Case {
|
||||
database: Database,
|
||||
schema: String,
|
||||
directory: PathBuf,
|
||||
}
|
||||
|
||||
impl Case {
|
||||
async fn registry(&self, scope: &Scope<'_>) -> Arc<Registry> {
|
||||
Registry::load(
|
||||
&self.directory.join("setting.json"),
|
||||
&[Binding {
|
||||
storage: crate::database::Storage::Global,
|
||||
component: "demo",
|
||||
database: Some("default"),
|
||||
table: job::schema::TABLE,
|
||||
schema: job::schema::SQL,
|
||||
plan: None,
|
||||
references: &[],
|
||||
}],
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn executor(&self, scope: &Scope<'_>) -> Executor {
|
||||
let mut executor = self.database.executor().await.unwrap();
|
||||
executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT set_config('search_path',$1,false)",
|
||||
vec![Parameter::Text(self.schema.clone())],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
}
|
||||
|
||||
async fn count(&self, scope: &Scope<'_>, sql: &'static str) -> i64 {
|
||||
self.executor(scope)
|
||||
.await
|
||||
.query(scope, sql, vec![], 1, |row| row.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap()[0]
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(check: impl AsyncFnOnce(&Case, &Scope<'_>)) {
|
||||
let path =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json");
|
||||
let document = Document::load(&path).expect("cannot read PostgreSQL test settings");
|
||||
let root: serde_json::Value = document.decode().expect("invalid PostgreSQL test settings");
|
||||
let value = root
|
||||
.get("database")
|
||||
.and_then(|database| database.get("postgres_test"))
|
||||
.expect("database.postgres_test must select an isolated test database");
|
||||
let mut options: Options = serde_json::from_value(value.clone())
|
||||
.unwrap_or_else(|_| panic!("invalid PostgreSQL test options"));
|
||||
assert_eq!(options.driver, "postgres");
|
||||
options.max_connections = 8;
|
||||
options.wait_timeout_ms = 1000;
|
||||
options.io_timeout_ms = 5000;
|
||||
Runtime::new(Default::default())
|
||||
.unwrap()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(std::path::Path::new("."), options, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = format!("dever_job_test_{}", job::identifier().unwrap());
|
||||
let directory = std::env::temp_dir().join(&schema);
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let case = Case {
|
||||
database,
|
||||
schema,
|
||||
directory,
|
||||
};
|
||||
let mut owner = case.database.executor().await.unwrap();
|
||||
owner
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.batch_execute(&format!("CREATE SCHEMA {}", case.schema))
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let outcome = AssertUnwindSafe(async {
|
||||
use std::{
|
||||
io::Write,
|
||||
os::unix::fs::{OpenOptionsExt, PermissionsExt},
|
||||
};
|
||||
std::fs::set_permissions(&case.directory, std::fs::Permissions::from_mode(0o700))
|
||||
.unwrap();
|
||||
let mut connection = value.clone();
|
||||
connection["max_connections"] = 8.into();
|
||||
let url = connection["url"].as_str().unwrap();
|
||||
assert!(
|
||||
url.parse::<tokio_postgres::Config>()
|
||||
.unwrap()
|
||||
.get_options()
|
||||
.is_none()
|
||||
);
|
||||
let separator = if url.contains('?') { '&' } else { '?' };
|
||||
connection["url"] =
|
||||
format!("{url}{separator}options=-csearch_path%3D{}", case.schema).into();
|
||||
let mut settings = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(case.directory.join("setting.json"))
|
||||
.unwrap();
|
||||
settings
|
||||
.write_all(
|
||||
&serde_json::to_vec(
|
||||
&serde_json::json!({"database":{"default":connection}}),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(executor);
|
||||
tokio::time::timeout(Duration::from_secs(30), check(&case, scope))
|
||||
.await
|
||||
.expect("PostgreSQL Job test deadline exceeded");
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
let cleanup = owner
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.batch_execute(&format!("DROP SCHEMA {} CASCADE", case.schema))
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await;
|
||||
case.database.close();
|
||||
std::fs::remove_dir_all(&case.directory).unwrap();
|
||||
cleanup.expect("PostgreSQL Job test schema cleanup failed");
|
||||
if let Err(panic) = outcome {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn submission(key: &str) -> Submission<'_> {
|
||||
static PAYLOAD: LazyLock<Json> = LazyLock::new(|| Json::parse(r#"{"number":1}"#).unwrap());
|
||||
Submission {
|
||||
target: "demo.account.publish",
|
||||
payload: Some(&PAYLOAD),
|
||||
schema: "schema-1",
|
||||
execution: &Execution::System { tenant: None },
|
||||
key,
|
||||
policy: Policy {
|
||||
retries: 2,
|
||||
timeout_ms: 50,
|
||||
},
|
||||
at: NOW,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn identity_idempotency_retry_and_terminal_keys() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
let identity = Execution::User {
|
||||
provider: "session".into(),
|
||||
subject: "user:7".into(),
|
||||
session: "test-session".into(),
|
||||
site: "admin".into(),
|
||||
tenant: None,
|
||||
tenant_id: None,
|
||||
permission: "demo.account.admin.publish".into(),
|
||||
};
|
||||
let mut first = submission("same");
|
||||
first.execution = &identity;
|
||||
let id = job::enqueue(&mut executor, scope, first, NOW)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
let mut duplicate = submission("same");
|
||||
let payload = Json::parse(r#"{"number":999}"#).unwrap();
|
||||
duplicate.payload = Some(&payload);
|
||||
assert_eq!(
|
||||
job::enqueue(&mut executor, scope, duplicate, NOW)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text(),
|
||||
id
|
||||
);
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, NOW - 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let claim = job::claim(&mut executor, scope, NOW, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(claim.id, id);
|
||||
assert_eq!(claim.payload, r#"{"number":1}"#);
|
||||
assert_eq!(claim.schema, "schema-1");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&claim.execution).unwrap(),
|
||||
serde_json::to_value(&identity).unwrap()
|
||||
);
|
||||
let config = job::Config::default();
|
||||
job::fail(&mut executor, scope, &claim, NOW + 1, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, NOW + 2, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let retry = job::claim(&mut executor, scope, NOW + 1 + config.delay(1) + 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(retry.id, id);
|
||||
assert_eq!(retry.attempt, 2);
|
||||
assert_ne!(retry.token, claim.token);
|
||||
job::fail(
|
||||
&mut executor,
|
||||
scope,
|
||||
&retry,
|
||||
retry.lease_until - 500,
|
||||
&config,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, NOW + 1_000_000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job WHERE state='dead'")
|
||||
.await,
|
||||
1
|
||||
);
|
||||
let replacement = job::enqueue(&mut executor, scope, submission("same"), NOW)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
assert_ne!(replacement, id);
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
job::enqueue(&mut executor, scope, submission("rollback"), NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
executor.finish(scope, owner, false).await.unwrap();
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE task_key='rollback'"
|
||||
)
|
||||
.await,
|
||||
0
|
||||
);
|
||||
assert!(Json::parse("{").is_err());
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE task_key='invalid'"
|
||||
)
|
||||
.await,
|
||||
0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn concurrent_enqueue_claim_and_skip_locked_keep_single_ownership() {
|
||||
fixture(async |case, scope| {
|
||||
let mut left = case.executor(scope).await;
|
||||
let mut right = case.executor(scope).await;
|
||||
let (left_id, right_id) = tokio::join!(
|
||||
job::enqueue(&mut left, scope, submission("same"), NOW),
|
||||
job::enqueue(&mut right, scope, submission("same"), NOW),
|
||||
);
|
||||
assert_eq!(left_id.unwrap().into_text(), right_id.unwrap().into_text());
|
||||
let (first, second) = tokio::join!(
|
||||
job::claim(&mut left, scope, NOW, 10_000),
|
||||
job::claim(&mut right, scope, NOW, 10_000),
|
||||
);
|
||||
assert_eq!(
|
||||
usize::from(first.unwrap().is_some()) + usize::from(second.unwrap().is_some()),
|
||||
1
|
||||
);
|
||||
let locked = job::enqueue(&mut left, scope, submission("locked"), NOW)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
let free = job::enqueue(&mut right, scope, submission("free"), NOW)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_text();
|
||||
let owner = left.begin(scope).await.unwrap();
|
||||
left.query(
|
||||
scope,
|
||||
"SELECT id FROM _dever_job WHERE id=$1 FOR UPDATE",
|
||||
vec![Parameter::Text(locked)],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let claim = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
job::claim(&mut right, scope, NOW, 10_000),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(claim.id, free);
|
||||
left.finish(scope, owner, true).await.unwrap();
|
||||
job::block(&mut right, scope, &claim, NOW + 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='blocked'"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn expired_claims_recover_or_exhaust_and_keep_token_fencing() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
for key in ["first", "second", "third"] {
|
||||
job::enqueue(&mut executor, scope, submission(key), NOW)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let mut originals = Vec::new();
|
||||
for _ in 0..3 {
|
||||
originals.push(
|
||||
job::claim(&mut executor, scope, NOW, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
let recovered = job::claim(&mut executor, scope, NOW + 1000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovered.attempt, 2);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='pending'"
|
||||
)
|
||||
.await,
|
||||
2
|
||||
);
|
||||
for original in &originals {
|
||||
assert!(
|
||||
job::complete(&mut executor, scope, original, NOW + 1001)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
job::complete(&mut executor, scope, &recovered, NOW + 1001)
|
||||
.await
|
||||
.unwrap();
|
||||
let exhausted = job::claim(&mut executor, scope, NOW + 1001, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let next = job::claim(&mut executor, scope, NOW + 2000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(next.id, exhausted.id);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job WHERE state='dead'")
|
||||
.await,
|
||||
1
|
||||
);
|
||||
job::complete(&mut executor, scope, &next, NOW + 2001)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
job::claim(&mut executor, scope, NOW + 10_000, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn expired_cleanup_is_bounded_and_workers_skip_each_others_locks() {
|
||||
fixture(async |case, scope| {
|
||||
let mut left = case.executor(scope).await;
|
||||
left.initialize(scope, "INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT lpad(number::text,6,'0'),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',number::text,'running',1,2,50,0,0,'old',0,0 FROM generate_series(0,599) AS number").await.unwrap();
|
||||
let mut right = case.executor(scope).await;
|
||||
let owner = left.begin(scope).await.unwrap();
|
||||
let first = job::claim(&mut left, scope, NOW, 10_000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let second = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
job::claim(&mut right, scope, NOW, 10_000),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(first.id, "000000");
|
||||
assert_eq!(second.id, "000257");
|
||||
left.finish(scope, owner, true).await.unwrap();
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='pending'"
|
||||
)
|
||||
.await,
|
||||
512
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token='old'"
|
||||
)
|
||||
.await,
|
||||
86
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token<>'old'"
|
||||
)
|
||||
.await,
|
||||
2
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn exhausted_cleanup_does_not_hide_eligible_expired_work() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor.initialize(scope, "INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT lpad(number::text,6,'0'),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',number::text,'running',1,CASE WHEN number=300 THEN 2 ELSE 1 END,50,0,0,'old',0,0 FROM generate_series(0,300) AS number").await.unwrap();
|
||||
let claim = job::claim(&mut executor, scope, NOW, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(claim.id, "000300");
|
||||
assert_eq!(claim.attempt, 2);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job WHERE state='dead'")
|
||||
.await,
|
||||
256
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token='old'"
|
||||
)
|
||||
.await,
|
||||
44
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn concurrent_cron_deduplicates_and_bounds_catchup() {
|
||||
fixture(async |case, scope| {
|
||||
let mut left = case.executor(scope).await;
|
||||
let mut right = case.executor(scope).await;
|
||||
let entry = job::Schedule {
|
||||
binding: 0,
|
||||
target: "demo.account.hourly",
|
||||
schema: "zero",
|
||||
cron: "* * * * *",
|
||||
policy: Policy {
|
||||
retries: 1,
|
||||
timeout_ms: 50,
|
||||
},
|
||||
};
|
||||
let (first, second) = tokio::join!(
|
||||
job::tick(&mut left, scope, &entry, None, NOW),
|
||||
job::tick(&mut right, scope, &entry, None, NOW)
|
||||
);
|
||||
assert_eq!(first.unwrap() + second.unwrap(), 1);
|
||||
let claim = job::claim(&mut left, scope, NOW, 1000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(claim.payload, "null");
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<Execution>(&claim.execution).unwrap(),
|
||||
Execution::System { tenant: None }
|
||||
));
|
||||
job::complete(&mut left, scope, &claim, NOW + 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
job::tick(&mut right, scope, &entry, None, NOW)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut left, scope, &entry, None, NOW + 60000 * 300)
|
||||
.await
|
||||
.unwrap(),
|
||||
256
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut right, scope, &entry, None, NOW + 60000 * 300)
|
||||
.await
|
||||
.unwrap(),
|
||||
44
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut left, scope, &entry, None, 0).await.unwrap(),
|
||||
0
|
||||
);
|
||||
let changed = job::Schedule {
|
||||
cron: "*/2 * * * *",
|
||||
..entry
|
||||
};
|
||||
assert!(
|
||||
job::tick(&mut right, scope, &changed, None, NOW)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job_occurrence")
|
||||
.await,
|
||||
301
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn failed_scheduled_enqueue_rolls_back_cursor_and_occurrence() {
|
||||
fixture(async |case, scope| {
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor.initialize(scope, "CREATE FUNCTION reject_scheduled_job() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''test-only failure''; END'; CREATE TRIGGER reject_scheduled_job BEFORE INSERT ON _dever_job FOR EACH ROW EXECUTE FUNCTION reject_scheduled_job();").await.unwrap();
|
||||
let entry = job::Schedule {
|
||||
binding: 0,
|
||||
target: "demo.account.hourly",
|
||||
schema: "zero",
|
||||
cron: "* * * * *",
|
||||
policy: Policy {
|
||||
retries: 1,
|
||||
timeout_ms: 50,
|
||||
},
|
||||
};
|
||||
assert!(
|
||||
job::tick(&mut executor, scope, &entry, None, NOW)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job_schedule")
|
||||
.await,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
case.count(scope, "SELECT COUNT(*) FROM _dever_job_occurrence")
|
||||
.await,
|
||||
0
|
||||
);
|
||||
executor
|
||||
.initialize(scope, "DROP TRIGGER reject_scheduled_job ON _dever_job")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, NOW)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use super::*;
|
||||
use std::{
|
||||
future::pending,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
struct Finished(Arc<AtomicBool>);
|
||||
impl Drop for Finished {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn config() -> job::Config {
|
||||
job::Config {
|
||||
workers: 2,
|
||||
poll_ms: 10,
|
||||
lease_ms: 300,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn enqueue(case: &Case, scope: &Scope<'_>) -> Policy {
|
||||
let mut executor = case.executor(scope).await;
|
||||
executor
|
||||
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut value = submission("worker");
|
||||
value.policy.timeout_ms = 3000;
|
||||
let policy = value.policy;
|
||||
job::enqueue(&mut executor, scope, value, crate::database::now().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
policy
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn short_lease_workers_renew_and_complete_once_without_replaying_handler() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
let policy = enqueue(case, scope).await;
|
||||
let completed = Arc::new(tokio::sync::Notify::new());
|
||||
let signal = completed.clone();
|
||||
let owned = case.clone();
|
||||
let workers = job::start(
|
||||
scope,
|
||||
registry,
|
||||
config(),
|
||||
&[job::Entry { binding: 0, policy }],
|
||||
vec![],
|
||||
move |_, _, claim, child| {
|
||||
let (case, signal) = (owned.clone(), signal.clone());
|
||||
async move {
|
||||
let mut executor = case.executor(&child).await;
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
executor
|
||||
.postgres_operation(async |client| {
|
||||
client
|
||||
.query_one("SELECT pg_sleep(0.8)", &[])
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
job::complete(
|
||||
&mut executor,
|
||||
&child,
|
||||
&claim,
|
||||
crate::database::now().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
signal.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(3), completed.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
workers.stop.cancel();
|
||||
workers.task.wait().await.unwrap().unwrap();
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
|
||||
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='completed'"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn failed_renewal_cancels_transaction_and_preserves_claim_for_expiry() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
let policy = enqueue(case, scope).await;
|
||||
case.executor(scope).await.initialize(scope, "CREATE FUNCTION reject_renewal() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN IF OLD.state=''running'' AND NEW.state=''running'' AND OLD.token=NEW.token AND OLD.lease_until<>NEW.lease_until THEN RAISE EXCEPTION ''test renewal failure''; END IF; RETURN NEW; END'; CREATE TRIGGER reject_renewal BEFORE UPDATE ON _dever_job FOR EACH ROW EXECUTE FUNCTION reject_renewal()").await.unwrap();
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let owned = case.clone();
|
||||
let workers = job::start(
|
||||
scope,
|
||||
registry,
|
||||
config(),
|
||||
&[job::Entry { binding: 0, policy }],
|
||||
vec![],
|
||||
move |_, _, _, child| {
|
||||
let (case, observed) = (owned.clone(), observed.clone());
|
||||
async move {
|
||||
let _finished = Finished(observed);
|
||||
let mut executor = case.executor(&child).await;
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
pending::<()>().await;
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !finished.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
workers.stop.cancel();
|
||||
workers.task.wait().await.unwrap().unwrap();
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 0);
|
||||
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
|
||||
assert_eq!(
|
||||
case.count(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND error IS NULL"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated database.postgres_test"]
|
||||
fn cancelling_worker_drains_handler_and_pending_renewal_before_reclaim() {
|
||||
fixture(async |case, scope| {
|
||||
let registry = case.registry(scope).await;
|
||||
let policy = enqueue(case, scope).await;
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let ready = entered.clone();
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let observed = finished.clone();
|
||||
let owned = case.clone();
|
||||
let workers = job::start(
|
||||
scope,
|
||||
registry,
|
||||
config(),
|
||||
&[job::Entry { binding: 0, policy }],
|
||||
vec![],
|
||||
move |_, _, _, child| {
|
||||
let (case, observed, entered) = (owned.clone(), observed.clone(), entered.clone());
|
||||
async move {
|
||||
let _finished = Finished(observed);
|
||||
let mut executor = case.executor(&child).await;
|
||||
let owner = executor.begin(&child).await.unwrap();
|
||||
executor
|
||||
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
entered.notify_one();
|
||||
pending::<()>().await;
|
||||
executor.finish(&child, owner, true).await.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
ready.notified().await;
|
||||
let mut owner = case.executor(scope).await;
|
||||
let transaction = owner.begin(scope).await.unwrap();
|
||||
let token = owner
|
||||
.query(
|
||||
scope,
|
||||
"SELECT token FROM _dever_job FOR UPDATE",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
assert!(!finished.load(Ordering::SeqCst));
|
||||
assert!(!matches!(workers.task.stop().await.unwrap(), Some(Ok(()))));
|
||||
assert!(finished.load(Ordering::SeqCst));
|
||||
owner.finish(scope, transaction, true).await.unwrap();
|
||||
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 0);
|
||||
let next = job::claim(
|
||||
&mut owner,
|
||||
scope,
|
||||
crate::database::now().unwrap() + 1000,
|
||||
300,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(next.token, token);
|
||||
assert_eq!(next.attempt, 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use super::*;
|
||||
use dever_runtime::time::cron::Cron;
|
||||
|
||||
#[test]
|
||||
fn utc_cron_validates_ranges_steps_and_calendar_or() {
|
||||
let cron = Cron::parse("*/15 9-17/2 1,15 * 1-5").unwrap();
|
||||
assert!(cron.matches(30, 11, 8, 10, 1));
|
||||
assert!(cron.matches(45, 9, 15, 10, 0));
|
||||
assert!(!cron.matches(45, 9, 8, 10, 0));
|
||||
assert!(!cron.matches(10, 9, 15, 10, 0));
|
||||
for source in [
|
||||
"* * * *",
|
||||
"60 * * * *",
|
||||
"* 24 * * *",
|
||||
"* * 0 * *",
|
||||
"* * * 13 *",
|
||||
"* * * * 7",
|
||||
"*/0 * * * *",
|
||||
"* * * * 5-1",
|
||||
"* * * * -1",
|
||||
] {
|
||||
assert!(Cron::parse(source).is_err(), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_cursor_and_completed_occurrences_are_durable() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits::default()).unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.schema(scope, job::schema::TABLE, job::schema::SQL)
|
||||
.await
|
||||
.unwrap();
|
||||
let entry = job::Schedule {
|
||||
binding: 0,
|
||||
target: "demo.account.job.hourly",
|
||||
schema: "zero",
|
||||
cron: "* * * * *",
|
||||
policy: Policy {
|
||||
retries: 1,
|
||||
timeout_ms: 50,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 600000)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let claim = job::claim(&mut executor, scope, 600000, 100)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
job::complete(&mut executor, scope, &claim, 600001)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 600020)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 600000 + 60000 * 300)
|
||||
.await
|
||||
.unwrap(),
|
||||
256
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 600000 + 60000 * 300)
|
||||
.await
|
||||
.unwrap(),
|
||||
44
|
||||
);
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
let changed = job::Schedule {
|
||||
cron: "*/2 * * * *",
|
||||
..entry
|
||||
};
|
||||
assert!(
|
||||
job::tick(&mut executor, scope, &changed, None, 600000)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
drop(executor);
|
||||
drop(database);
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
assert_eq!(
|
||||
job::tick(&mut executor, scope, &entry, None, 600000 + 60000 * 300)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user