Initial Dever 0.0.1

This commit is contained in:
Codex committed 2026-11-03 03:32:07 +00:00
commit 81233d098c
1479 files changed
+163782

No files matched your search

+41
View File
@@ -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();
}
+84
View File
@@ -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();
}
+206
View File
@@ -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();
}
+315
View File
@@ -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();
}
+296
View File
@@ -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();
}
+109
View File
@@ -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);
}
}
+83
View File
@@ -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();
}
}
+320
View File
@@ -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();
});
}
+120
View File
@@ -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, &copy, 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();
});
}
+194
View File
@@ -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,
}
}
+322
View File
@@ -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
);
});
}
+117
View File
@@ -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
);
});
}
+229
View File
@@ -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);
});
}
+110
View File
@@ -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();
}