Files
core/test/dever-tests/tests/durable_jobs.rs
T

1589 lines
67 KiB
Rust

use dever_core::source::SourceMap;
#[path = "support/temp.rs"]
mod temp;
fn sources(job: &str, app: &str) -> SourceMap {
let mut sources = SourceMap::default();
sources.add("content/article/job.dever", job);
sources.add("content/article/app.dever", app);
sources
}
fn checked(sources: &SourceMap) -> dever_core::hir::Program {
dever_core::check(sources).unwrap_or_else(|errors| {
panic!(
"{}",
errors
.iter()
.map(|error| error.render(sources))
.collect::<Vec<_>>()
.join("\n")
)
})
}
fn rejected(sources: &SourceMap, message: &str) {
let errors = dever_core::check(sources).expect_err("invalid Job must be rejected");
assert!(
errors.iter().any(|error| error.message.contains(message)),
"{errors:?}"
);
}
#[cfg(feature = "sqlite")]
fn clock_at(millis: i64) -> dever_runtime::time::Clock {
let clock = dever_runtime::time::Clock::for_test();
clock.advance(millis).unwrap();
clock
}
const JOB: &str = "database default\njob publish(input: app.Publish) () retry(5) timeout(30000) { app.apply(input) }";
const APP: &str = "type Publish { title: Text }\napply(input: Publish) () {}\ntransaction submit(input: Publish) (id: Id) { id = dever.job.enqueue(job.publish, input, input.title) }";
#[test]
fn static_jobs_and_enqueue_only_transactions_check_and_format() {
let sources = sources(JOB, APP);
checked(&sources);
let source = &sources.files()[0];
let formatted = dever_core::format::format(source).unwrap();
let mut round_trip = SourceMap::default();
let id = round_trip.add("content/article/job.dever", &formatted);
assert_eq!(
formatted,
dever_core::format::format(round_trip.get(id)).unwrap()
);
}
#[test]
fn jobs_enforce_private_entry_payload_and_policy_boundaries() {
rejected(
&sources(
JOB,
&APP.replace(
"dever.job.enqueue(job.publish, input, input.title)",
"job.publish(input)",
),
),
"not callable",
);
rejected(
&sources(&JOB.replace("retry(5)", "retry(0)"), APP),
"retry must",
);
rejected(
&sources(&JOB.replace("app.Publish", "Text"), APP),
"payload must",
);
rejected(
&sources(&JOB.replace("app.apply(input)", "model.count()"), APP),
"unknown",
);
rejected(
&sources(JOB, &APP.replace("title: Text", "title: Secret")),
"Secret",
);
rejected(
&sources(&JOB.replace("database default\n", ""), APP),
"database binding",
);
rejected(
&sources(
&JOB.replace("app.apply(input)", "dever.system.time_now()"),
APP,
),
"same-domain App",
);
rejected(
&sources(
&JOB.replace(
"app.apply(input)",
"task = run(app.apply(input))\nwait(task)",
),
APP,
),
"same-domain App",
);
let mut public_enqueue = sources(JOB, APP);
public_enqueue.add(
"content/article/api.dever",
"public post submit = app.submit",
);
rejected(&public_enqueue, "trusted user identity");
}
#[test]
fn queue_boundary_does_not_add_synchronous_recursion() {
checked(&sources(
"database default\njob retry_later(input: app.Work) () retry(2) timeout(1000) { id = app.apply(input) }",
"type Work { key: Text }\napply(input: Work) (id: Id) { id = dever.job.enqueue(job.retry_later, input, input.key) }",
));
}
#[test]
fn schedules_validate_utc_fields_and_zero_input_targets() {
checked(&sources(
"database default\njob cleanup() () retry(3) timeout(1000) { app.clean() }\nschedule cleanup = \"0 * * * *\"",
"clean() () {}",
));
rejected(
&sources(&format!("{JOB}\nschedule publish = \"0 * * * *\""), APP),
"zero-input",
);
rejected(
&sources(
"database default\njob cleanup() () retry(3) timeout(1000) {}\nschedule cleanup = \"60 * * * *\"",
"clean() () {}",
),
"cron field",
);
}
#[test]
fn nominal_model_id_wire_identity_is_preserved() {
let mut sources = sources(
JOB,
&APP.replace("title: Text", "title: Text\narticle_id: model.id"),
);
sources.add(
"content/article/model.dever",
"type Article { title: Text }",
);
let program = checked(&sources);
let schema = program
.wire_schema("content.article.Publish", dever_core::wire::Policy::Job)
.unwrap();
assert!(
schema
.fingerprint()
.contains("ModelId(\"content.article.model.id\")")
);
}
#[test]
fn markdown_job_and_schedule_share_the_source_contract() {
let markdown = "# 文章清理任务\n\n清理已失效文章。\n\n- 包:`content.article.job`\n- 公开类型:无\n- 公开方法:无\n- 使用:无\n\n## 队列连接\n\n使用默认连接。\n\n- 声明:`database default`\n\n```dever\ndatabase default\n```\n\n## 清理入口\n\n调用领域能力。\n\n- 函数:`cleanup`\n- 输入:无\n- 输出:无\n\n```dever\njob cleanup() () retry(3) timeout(1000) { app.clean() }\n```\n\n## 每小时清理\n\n按 UTC 整点运行。\n\n- 声明:`schedule cleanup`\n\n```dever\nschedule cleanup = \"0 * * * *\"\n```\n";
let mut sources = SourceMap::default();
let id = sources.add("content/article/job.dever.md", markdown);
sources.add("content/article/app.dever", "clean() () {}");
checked(&sources);
let formatted = dever_core::format::format(sources.get(id)).unwrap();
let mut round_trip = SourceMap::default();
round_trip.add("content/article/job.dever.md", &formatted);
round_trip.add("content/article/app.dever", "clean() () {}");
checked(&round_trip);
}
#[test]
fn transaction_rejects_model_and_job_on_different_explicit_connections() {
let mut sources = sources(
JOB,
"type Publish { title: Text }\napply(input: Publish) () {}\ntransaction submit(input: Publish) (id: Id) {\n article = model.create({ title = input.title })\n id = dever.job.enqueue(job.publish, input, input.title)\n}",
);
sources.add(
"content/article/model.dever",
"database report\ntype Article { title: Text }",
);
rejected(&sources, "multiple explicit database connections");
}
#[test]
fn tenant_model_jobs_generate_scoped_storage_and_private_schema_migration() {
let mut sources = sources(
"database default\njob publish(input: app.Publish) () retry(5) timeout(30000) { app.apply(input) }\njob cleanup() () retry(2) timeout(1000) { app.clean() }\nschedule cleanup = \"0 * * * *\"",
"type Publish { title: Text }\napply(input: Publish) () { article = model.create({ title = input.title }) }\nclean() () {}\ntransaction submit(input: Publish) (id: Id) { article = model.create({ title = input.title })\nid = dever.job.enqueue(job.publish, input, input.title) }",
);
sources.add(
"content/article/model.dever",
"type Article { title: Text }",
);
let program = checked(&sources);
let generated = dever_core::native::emit_project(
&program,
&sources,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
assert!(generated.contains("JOB_STORAGE_"));
assert!(generated.contains("ModelScope::Tenant"));
assert!(generated.contains("job_migrate_tenant(tenant_id"));
assert!(generated.contains("dever_runtime::job::store::initialize"));
assert!(!generated.contains("JOB_DATABASE_"));
let storage_initializers = generated
.lines()
.filter(|line| line.contains("JOB_STORAGE_") && line.contains(".set("))
.collect::<Vec<_>>();
assert_eq!(storage_initializers.len(), 2);
assert!(
storage_initializers
.iter()
.all(|line| line.contains("ModelScope::Tenant"))
);
dever_core::native::compile_project(
&program,
&sources,
std::ffi::OsStr::new("rustc"),
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
}
#[test]
fn runtime_modes_and_job_limits_are_strict_and_validate_permissions() {
let project = temp::TemporaryDirectory::new();
std::fs::create_dir(project.path().join("config")).unwrap();
let file = project.path().join("config/setting.json");
let valid = r#"{"runtime":{"mode":"worker","shutdown_ms":30000},"job":{"workers":2,"poll_ms":250,"lease_ms":60000,"retry_base_ms":1000,"retry_max_ms":300000}}"#;
std::fs::write(&file, valid).unwrap();
let settings = dever_runtime::config::Settings::load_project(project.path()).unwrap();
settings.services(false, true).unwrap();
assert!(settings.services(true, false).is_err());
let inferred_worker = valid.replace(r#""runtime":{"mode":"worker","shutdown_ms":30000},"#, "");
std::fs::write(&file, inferred_worker).unwrap();
let inferred = dever_runtime::config::Settings::load_project(project.path()).unwrap();
assert_eq!(
inferred.services(false, true).unwrap().mode,
dever_runtime::config::RuntimeMode::Worker
);
for mode in ["api", "all"] {
let document = valid.replace("\"worker\"", &format!("\"{mode}\""));
std::fs::write(&file, &document).unwrap();
assert!(
dever_runtime::config::Settings::load_project(project.path())
.unwrap()
.services(true, true)
.is_err()
);
let document =
document.replacen('{', "{\"http\":{\"host\":\"127.0.0.1\",\"port\":8181},", 1);
std::fs::write(&file, document).unwrap();
dever_runtime::config::Settings::load_project(project.path())
.unwrap()
.services(true, true)
.unwrap();
}
for invalid in [
valid.replace("\"worker\"", "\"unknown\""),
valid.replace("\"workers\":2", "\"workers\":0"),
valid.replace("\"lease_ms\":60000", "\"lease_ms\":0"),
valid.replace("\"shutdown_ms\":30000", "\"shutdown_ms\":-1"),
"{\"runtime\":null}".into(),
"{\"job\":null}".into(),
] {
std::fs::write(&file, invalid).unwrap();
assert!(dever_runtime::config::Settings::load_project(project.path()).is_err());
}
let document = valid.replacen('{', "{\"database\":{\"default\":{\"type\":\"sqlite\",\"path\":\"data/db/jobs.db\",\"max_connections\":3}},", 1);
std::fs::write(&file, document).unwrap();
let settings = dever_runtime::config::Settings::load_project(project.path()).unwrap();
settings
.validate_job_connection(Some("default"), "content", 59_999)
.unwrap();
for timeout in [60_000, 60_001] {
assert!(
settings
.validate_job_connection(Some("default"), "content", timeout)
.unwrap_err()
.contains("must exceed Job timeout_ms")
);
}
std::fs::write(
&file,
valid.replace("\"lease_ms\":60000", "\"lease_ms\":7200000"),
)
.unwrap();
dever_runtime::config::Settings::load_project(project.path()).unwrap();
std::fs::write(
&file,
valid.replace("\"lease_ms\":60000", "\"lease_ms\":7200001"),
)
.unwrap();
assert!(dever_runtime::config::Settings::load_project(project.path()).is_err());
}
#[test]
fn utc_cron_handles_calendar_ranges_steps_and_day_or_semantics() {
let minute = |text| dever_runtime::time::parse_datetime(text).unwrap() / 60000;
let cron = dever_runtime::cron::Cron::parse("*/15 3 20 9 1").unwrap();
assert!(cron.matches_minute(minute("2026-09-20T03:30:00Z")));
assert!(cron.matches_minute(minute("2026-09-21T03:30:00Z")));
assert!(!cron.matches_minute(minute("2026-09-22T03:30:00Z")));
assert!(!cron.matches_minute(minute("2026-09-20T03:31:00Z")));
for invalid in [
"* * * *",
"0 24 * * *",
"0 0 0 * *",
"0 0 * 13 *",
"0 0 * * 7",
"*/0 * * * *",
"5-2 * * * *",
"0 0 31 2 *",
] {
assert!(dever_runtime::cron::Cron::parse(invalid).is_err());
}
}
#[test]
fn application_jobs_use_case_local_sqlite_clock_and_port_fakes() {
let project = temp::TemporaryDirectory::new();
let long_job = JOB.replace("timeout(30000)", "timeout(3600000)");
let files = [
("module/main.dever", "main() () {}"),
(
"module/content/article/model.dever",
"type Article { title: Text }",
),
(
"module/content/article/app.dever",
"type Publish { title: Text\narticle_id: model.id }\ntype DeliveryError { error Failed(message: Text) }\ntransaction submit(title: Text) (id: Id) {\n article = model.create({ title = title })\n id = dever.job.enqueue(job.publish, Publish { title = title\narticle_id = article.id }, title)\n}\napply(input: Publish) () {\n original = model.get(input.article_id)\n article = model.create({ title = port.title(original.title) })\n}\ncount(title: Text) (total: Int) { total = model.count({ where = title == title }) }",
),
("module/content/article/job.dever", long_job.as_str()),
(
"module/content/article/port.dever",
"title(value: Text) (title: Text) fails app.DeliveryError",
),
(
"module/content/article/adapter.dever",
"port.title(value: Text) (title: Text) { title = \"production\" }",
),
(
"test/content/article/first.dever",
"port.title(value: Text) (title: Text) { title = \"first\" }\nfirst() () {\n assert_eq(app.count(\"first\"), 0)\n id = app.submit(\"same-key\")\n assert_eq(dever.test.drain_jobs(5), 1)\n assert_eq(app.count(\"first\"), 1)\n dever.test.advance_clock(1000)\n assert_eq(dever.test.drain_jobs(5), 0)\n}",
),
(
"test/content/article/second.dever",
"port.title(value: Text) (title: Text) { title = \"second\" }\nsecond() () {\n assert_eq(app.count(\"first\"), 0)\n id = app.submit(\"same-key\")\n assert_eq(dever.test.drain_jobs(5), 1)\n assert_eq(app.count(\"second\"), 1)\n}",
),
(
"test/content/article/empty.dever",
"empty() () { dever.test.advance_clock(1000)\nassert_eq(dever.test.drain_jobs(5), 0) }",
),
];
for (path, source) in files {
let path = project.path().join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, source).unwrap();
}
let sources =
SourceMap::load_project(&project.path().join("module"), &project.path().join("test"))
.unwrap();
let program = checked(&sources);
assert_eq!(
program
.tests()
.iter()
.filter(|case| case.uses_database())
.count(),
2
);
let native =
dever_core::native::compile_test_suite(&program, &sources, std::ffi::OsStr::new("rustc"))
.unwrap();
for index in 0..program.tests().len() {
let case = temp::TemporaryDirectory::new();
std::fs::create_dir(case.path().join("config")).unwrap();
std::fs::write(case.path().join("config/setting.json"), r#"{"database":{"default":{"type":"sqlite","path":"data/db/case.db","max_connections":1}}}"#).unwrap();
let binary = case.path().join("case");
native.save(&binary).unwrap();
let output = std::process::Command::new(binary)
.arg(index.to_string())
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}
#[cfg(feature = "sqlite")]
#[test]
fn native_dispatch_blocks_changed_schema_corrupt_payload_and_unknown_target() {
let project = temp::TemporaryDirectory::new();
for (path, source) in [
("module/main.dever", "main() () {}"),
(
"module/content/article/model.dever",
"type Article { title: Text }",
),
(
"module/content/article/app.dever",
"type Work { title: Text }\nsubmit() (id: Id) { id = dever.job.enqueue(job.publish, Work { title = \"delivered\" }, \"same\") }\napply(input: Work) () { article = model.create({ title = input.title }) }\ncount() (total: Int) { total = model.count() }",
),
(
"module/content/article/job.dever",
"database default\njob publish(input: app.Work) () retry(2) timeout(1000) { app.apply(input) }",
),
(
"test/content/article/prepare.dever",
"prepare() () { id = app.submit() }",
),
(
"test/content/article/blocked.dever",
"blocked() () { id = app.submit()\nassert_eq(dever.test.drain_jobs(5), 1)\nassert_eq(app.count(), 0) }",
),
(
"test/content/article/unknown.dever",
"unknown() () { id = app.submit()\nassert_eq(dever.test.drain_jobs(5), 2)\nassert_eq(app.count(), 1) }",
),
] {
let path = project.path().join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, source).unwrap();
}
let sources =
SourceMap::load_project(&project.path().join("module"), &project.path().join("test"))
.unwrap();
let program = checked(&sources);
let native =
dever_core::native::compile_test_suite(&program, &sources, std::ffi::OsStr::new("rustc"))
.unwrap();
let index = |suffix: &str| {
program
.tests()
.iter()
.position(|case| case.name().ends_with(suffix))
.unwrap()
.to_string()
};
for (mutation, expected, entry) in [
(
"UPDATE _dever_jobs SET schema='old-version'",
"schema_mismatch",
"blocked",
),
(
"UPDATE _dever_jobs SET payload='{}'",
"invalid_payload",
"blocked",
),
(
"UPDATE _dever_jobs SET timeout_ms=3600001",
"invalid_policy",
"blocked",
),
(
"UPDATE _dever_jobs SET target='removed.job'",
"unknown_target",
"unknown",
),
] {
let case = temp::TemporaryDirectory::new();
std::fs::create_dir(case.path().join("config")).unwrap();
std::fs::write(case.path().join("config/setting.json"), r#"{"database":{"default":{"type":"sqlite","path":"data/db/jobs.db","max_connections":1}}}"#).unwrap();
let binary = case.path().join("case");
native.save(&binary).unwrap();
let run = |entry| {
let output = std::process::Command::new(&binary)
.arg(index(entry))
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
run("prepare");
let path = case.path().join("data/db/jobs.db");
let update_path = path.clone();
dever_runtime::task::run_entry(async move {
let database = dever_runtime::sqlite::Database::open(update_path, 1, 100)
.map_err(|error| error.to_string())?;
database
.execute(mutation, vec![])
.await
.map_err(|error| error.to_string())?;
Ok(())
})
.unwrap();
run(entry);
dever_runtime::task::run_entry(async move {
let database = dever_runtime::sqlite::Database::open(path, 1, 100)
.map_err(|error| error.to_string())?;
let rows = database
.query(
"SELECT error,attempt FROM _dever_jobs WHERE state='blocked'",
vec![],
)
.await
.map_err(|error| error.to_string())?;
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].get(0).unwrap(),
&dever_runtime::orm::Value::Text(expected.into())
);
assert_eq!(rows[0].get(1).unwrap(), &dever_runtime::orm::Value::Int(1));
Ok(())
})
.unwrap();
}
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_durable_state_machine() {
use database::{Executor, Sql};
use dever_runtime::{
config, database,
job::{self, Spec, store},
orm::Value,
wire::Encoded,
};
let executable = std::env::current_exe().unwrap();
if executable.file_stem().unwrap() != "durable-job-case" {
// The child owns all runtime globals and its setting.json; deployment config is never read.
let project = temp::TemporaryDirectory::new();
std::fs::create_dir(project.path().join("config")).unwrap();
std::fs::write(project.path().join("config/setting.json"), r#"{"database":{"default":{"type":"sqlite","path":"data/db/jobs.db","max_connections":2}}}"#).unwrap();
let child = project.path().join("durable-job-case");
std::fs::copy(executable, &child).unwrap();
let output = std::process::Command::new(child)
.args(["--exact", "sqlite_durable_state_machine", "--nocapture"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
config::bootstrap(
config::RuntimeProfile {
sqlite: true,
postgres: false,
},
&[(Some("default"), "content")],
&[],
)
.unwrap();
dever_runtime::task::run_entry(async {
job::scope_system(None, async {
let db = database::database_for(Some("default"), "content").unwrap();
store::initialize(db.clone()).await.unwrap();
store::initialize(db.clone()).await.unwrap();
let spec = Spec { target: "content.article.job.publish", schema: "wire-v1:Job:Unit", attempts: 2, timeout_ms: 1000 };
let sql = |text| Sql { sqlite: text, postgres: text };
db.execute(sql("CREATE TABLE business (id BIGINT PRIMARY KEY)"), vec![]).await.unwrap();
let tx = db.begin().await.unwrap();
Executor::new(db.clone(), Some(&tx)).unwrap().execute(sql("INSERT INTO business(id) VALUES (1)"), vec![]).await.unwrap();
job::enqueue(db.clone(), Some(&tx), &spec, Encoded::null(), "rollback", 0).await.unwrap();
tx.rollback().await.unwrap();
assert!(db.query(sql("SELECT id FROM business"), vec![]).await.unwrap().is_empty());
assert!(db.query(sql("SELECT id FROM _dever_jobs"), vec![]).await.unwrap().is_empty());
let tx = db.begin().await.unwrap();
Executor::new(db.clone(), Some(&tx)).unwrap().execute(sql("INSERT INTO business(id) VALUES (2)"), vec![]).await.unwrap();
let id = job::enqueue(db.clone(), Some(&tx), &spec, Encoded::null(), "commit", 1000).await.unwrap();
tx.commit().await.unwrap();
let (first, second) = tokio::join!(job::enqueue(db.clone(), None, &spec, Encoded::null(), "commit", 1000), job::enqueue(db.clone(), None, &spec, Encoded::null(), "commit", 1000));
assert_eq!(first.unwrap(), id);
assert_eq!(second.unwrap(), id);
assert!(store::claim(db.clone(), &clock_at(999), 1000, 1).await.unwrap().is_empty());
let claim = store::claim(db.clone(), &clock_at(1000), 1000, 1).await.unwrap().remove(0);
assert_eq!(claim.attempt, 1);
assert!(store::claim(db.clone(), &clock_at(1001), 1000, 1).await.unwrap().is_empty());
let reclaimed = store::claim(db.clone(), &clock_at(2000), 1000, 1).await.unwrap().remove(0);
assert_eq!(reclaimed.attempt, 2);
assert_ne!(reclaimed.token, claim.token);
assert!(!store::renew(db.clone(), &claim, &clock_at(2001), 1000).await.unwrap());
assert!(!store::complete(db.clone(), &claim, store::Outcome::Success, &clock_at(2001), 100, 1000).await.unwrap());
assert!(store::complete(db.clone(), &reclaimed, store::Outcome::Success, &clock_at(2001), 100, 1000).await.unwrap());
let reuse = job::enqueue(db.clone(), None, &spec, Encoded::null(), "commit", 3000).await.unwrap();
assert_ne!(reuse, id);
let attempt = store::claim(db.clone(), &clock_at(3000), 1000, 1).await.unwrap().remove(0);
assert!(store::complete(db.clone(), &attempt, store::Outcome::Retry, &clock_at(3000), 100, 1000).await.unwrap());
assert!(store::claim(db.clone(), &clock_at(3099), 1000, 1).await.unwrap().is_empty());
assert_eq!(store::claim(db.clone(), &clock_at(3100), 1000, 1).await.unwrap()[0].attempt, 2);
assert!(store::claim(db.clone(), &clock_at(4100), 1000, 1).await.unwrap().is_empty());
let states = db.query(sql("SELECT state,error FROM _dever_jobs WHERE state='dead'"), vec![]).await.unwrap();
assert_eq!(states[0].get(1).unwrap(), &Value::Text("attempts_exhausted".into()));
// Advance the injected clock while each operation waits for SQLite's
// write lock. Claim starts a fresh lease; renew/finish cannot revive it.
let clock = clock_at(5000);
job::enqueue(db.clone(), None, &spec, Encoded::null(), "lock-wait", 5000).await.unwrap();
let lock = db.begin().await.unwrap();
Executor::new(db.clone(), Some(&lock)).unwrap().execute(sql("UPDATE business SET id=id WHERE id=2"), vec![]).await.unwrap();
let pending = store::claim(db.clone(), &clock, 1000, 1);
tokio::pin!(pending);
assert!(tokio::time::timeout(std::time::Duration::from_millis(20), &mut pending).await.is_err());
clock.advance(1000).unwrap();
lock.commit().await.unwrap();
let locked_claim = pending.await.unwrap().remove(0);
let leases = db.query(sql("SELECT lease_until FROM _dever_jobs WHERE dedupe_key='business:lock-wait'"), vec![]).await.unwrap();
assert_eq!(leases[0].get(0).unwrap(), &Value::Int(7000));
let lock = db.begin().await.unwrap();
Executor::new(db.clone(), Some(&lock)).unwrap().execute(sql("UPDATE business SET id=id WHERE id=2"), vec![]).await.unwrap();
let pending = store::renew(db.clone(), &locked_claim, &clock, 1000);
tokio::pin!(pending);
assert!(tokio::time::timeout(std::time::Duration::from_millis(20), &mut pending).await.is_err());
clock.advance(1001).unwrap();
lock.commit().await.unwrap();
assert!(!pending.await.unwrap());
let reclaimed = store::claim(db.clone(), &clock, 1000, 1).await.unwrap().remove(0);
let lock = db.begin().await.unwrap();
Executor::new(db.clone(), Some(&lock)).unwrap().execute(sql("UPDATE business SET id=id WHERE id=2"), vec![]).await.unwrap();
let pending = store::complete(db.clone(), &reclaimed, store::Outcome::Success, &clock, 100, 1000);
tokio::pin!(pending);
assert!(tokio::time::timeout(std::time::Duration::from_millis(20), &mut pending).await.is_err());
clock.advance(1001).unwrap();
lock.commit().await.unwrap();
assert!(!pending.await.unwrap());
static NEXT: Spec = Spec { target: "content.article.job.next", schema: "unit", attempts: 2, timeout_ms: 1000 };
job::enqueue(db.clone(), None, &NEXT, Encoded::null(), "after-exhausted", clock.now().unwrap()).await.unwrap();
let bindings = vec![job::worker::Binding::fixed(db.clone(), &NEXT, None)];
assert_eq!(job::worker::drain(bindings, clock.clone(), 5, |_| async { store::Outcome::Success }).await.unwrap(), 1);
assert!(store::claim(db.clone(), &clock, 1000, 1).await.unwrap().is_empty());
let cron = dever_runtime::cron::Cron::parse("* * * * *").unwrap();
assert_eq!(store::materialize(db.clone(), &spec, &cron, 60_000).await.unwrap(), 0);
let (a, b) = tokio::join!(store::materialize(db.clone(), &spec, &cron, 120_000), store::materialize(db.clone(), &spec, &cron, 120_000));
assert_eq!(a.unwrap() + b.unwrap(), 1);
let slot = store::claim(db.clone(), &clock_at(120_000), 1000, 1).await.unwrap().remove(0);
store::complete(db.clone(), &slot, store::Outcome::Success, &clock_at(120_001), 100, 1000).await.unwrap();
assert_eq!(store::materialize(db.clone(), &spec, &cron, 120_000).await.unwrap(), 0);
assert!(store::materialize(db.clone(), &spec, &dever_runtime::cron::Cron::parse("0 * * * *").unwrap(), 120_000).await.is_err());
assert_eq!(store::materialize(db.clone(), &spec, &cron, 60_000 * 1000).await.unwrap(), 256);
// A failure after inserting an occurrence must roll back both it and its cursor.
db.execute(sql("CREATE TRIGGER reject_job_cursor BEFORE UPDATE ON _dever_job_schedule BEGIN SELECT RAISE(ABORT, 'test rollback'); END"), vec![]).await.unwrap();
let before = db.query(sql("SELECT COUNT(*) FROM _dever_jobs"), vec![]).await.unwrap();
assert!(store::materialize(db.clone(), &spec, &cron, 60_000 * 259).await.is_err());
let after = db.query(sql("SELECT COUNT(*) FROM _dever_jobs"), vec![]).await.unwrap();
assert_eq!(before[0].get(0).unwrap(), after[0].get(0).unwrap());
db.execute(sql("DROP TRIGGER reject_job_cursor"), vec![]).await.unwrap();
store::initialize(db.clone()).await.unwrap();
assert_eq!(store::materialize(db.clone(), &spec, &cron, 60_000 * 259).await.unwrap(), 1);
db.execute(sql("UPDATE _dever_job_version SET version=99"), vec![]).await.unwrap();
assert!(store::initialize(db.clone()).await.is_err());
Ok(())
}).await
}).unwrap();
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_job_identity_migration_blocks_legacy_work_and_persists_trusted_identity() {
use dever_runtime::{
config, database,
job::{self, ExecutionIdentity, Spec, store},
orm::Value,
wire::Encoded,
};
let executable = std::env::current_exe().unwrap();
if executable.file_stem().unwrap() != "job-identity-migration-case" {
let project = temp::TemporaryDirectory::new();
std::fs::create_dir(project.path().join("config")).unwrap();
std::fs::write(project.path().join("config/setting.json"), r#"{"database":{"default":{"type":"sqlite","path":"data/db/jobs.db","max_connections":2}}}"#).unwrap();
let child = project.path().join("job-identity-migration-case");
std::fs::copy(executable, &child).unwrap();
let output = std::process::Command::new(child)
.args([
"--exact",
"sqlite_job_identity_migration_blocks_legacy_work_and_persists_trusted_identity",
"--nocapture",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
config::bootstrap(
config::RuntimeProfile {
sqlite: true,
postgres: false,
},
&[(Some("default"), "content")],
&[],
)
.unwrap();
dever_runtime::task::run_entry(async {
let db = database::database_for(Some("default"), "content").unwrap();
let sql = |text| database::Sql { sqlite: text, postgres: text };
for statement in [
"CREATE TABLE _dever_job_version (singleton INTEGER PRIMARY KEY CHECK(singleton=1), version BIGINT NOT NULL)",
"INSERT INTO _dever_job_version(singleton,version) VALUES (1,2)",
"CREATE TABLE _dever_jobs (id TEXT PRIMARY KEY, tenant_id BIGINT NOT NULL, target TEXT NOT NULL, schema TEXT NOT NULL, payload TEXT NOT NULL, dedupe_key TEXT NOT NULL, state TEXT NOT NULL, run_at BIGINT NOT NULL, attempt BIGINT NOT NULL, max_attempts BIGINT NOT NULL, timeout_ms BIGINT NOT NULL, claim_token TEXT NOT NULL, lease_until BIGINT NOT NULL, error TEXT NOT NULL, updated_at BIGINT NOT NULL)",
"CREATE UNIQUE INDEX _dever_jobs_active ON _dever_jobs(tenant_id,target,dedupe_key) WHERE state IN ('pending','running')",
"CREATE INDEX _dever_jobs_due ON _dever_jobs(state,run_at,lease_until)",
"CREATE TABLE _dever_job_schedule (identity TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, last_minute BIGINT NOT NULL)",
"INSERT INTO _dever_jobs(id,tenant_id,target,schema,payload,dedupe_key,state,run_at,attempt,max_attempts,timeout_ms,claim_token,lease_until,error,updated_at) VALUES ('legacy',0,'content.article.job.publish','unit','null','business:legacy','pending',0,0,2,1000,'',0,'',0)",
] {
db.execute(sql(statement), vec![]).await.unwrap();
}
store::initialize(db.clone()).await.unwrap();
let migrated = db.query(sql("SELECT version,state,error,execution_kind FROM _dever_job_version CROSS JOIN _dever_jobs WHERE id='legacy'"), vec![]).await.unwrap();
assert_eq!(migrated[0].get(0).unwrap(), &Value::Int(5));
assert_eq!(migrated[0].get(1).unwrap(), &Value::Text("blocked".into()));
assert_eq!(migrated[0].get(2).unwrap(), &Value::Text("missing_execution_identity".into()));
assert_eq!(migrated[0].get(3).unwrap(), &Value::Text("legacy".into()));
static SPEC: Spec = Spec { target: "content.article.job.publish", schema: "unit", attempts: 2, timeout_ms: 1000 };
assert!(job::enqueue(db.clone(), None, &SPEC, Encoded::null(), "missing", 0).await.unwrap_err().to_string().contains("trusted Job execution identity"));
job::scope_system(None, job::enqueue(db.clone(), None, &SPEC, Encoded::null(), "system", 0)).await.unwrap();
let user = ExecutionIdentity::User {
tenant_id: Some(7),
provider: "account".into(),
site: "admin".into(),
subject: "user:42".into(),
session: "session:9".into(),
tenant_claim: Some("tenant-seven".into()),
permission_key: "news.article.admin.publish".into(),
};
job::scope_execution(user.clone(), job::enqueue(db.clone(), None, &SPEC, Encoded::null(), "user", 0)).await.unwrap();
let rows = db.query(sql("SELECT execution_kind,scope_tenant_id,auth_provider,auth_site,auth_subject,auth_session,auth_tenant,auth_permission FROM _dever_jobs WHERE dedupe_key='business:user'"), vec![]).await.unwrap();
assert_eq!(rows[0].get(0).unwrap(), &Value::Text("user".into()));
assert_eq!(rows[0].get(1).unwrap(), &Value::Int(7));
assert_eq!(rows[0].get(2).unwrap(), &Value::Text("account".into()));
assert_eq!(rows[0].get(3).unwrap(), &Value::Text("admin".into()));
assert_eq!(rows[0].get(4).unwrap(), &Value::Text("user:42".into()));
assert_eq!(rows[0].get(5).unwrap(), &Value::Text("session:9".into()));
assert_eq!(rows[0].get(6).unwrap(), &Value::Text("tenant-seven".into()));
assert_eq!(rows[0].get(7).unwrap(), &Value::Text("news.article.admin.publish".into()));
let claims = store::claim(db.clone(), &job::Clock::for_test(), 1000, 2).await.unwrap();
assert!(claims.iter().any(|claim| claim.execution == ExecutionIdentity::System { tenant_id: None }));
assert!(claims.iter().any(|claim| claim.execution == user));
db.execute(sql("DROP TABLE _dever_jobs"), vec![]).await.unwrap();
db.execute(sql("UPDATE _dever_job_version SET version=4 WHERE singleton=1"), vec![]).await.unwrap();
db.execute(sql("CREATE TABLE _dever_jobs (id TEXT PRIMARY KEY, tenant_id BIGINT NOT NULL, scope_tenant_id BIGINT NOT NULL, execution_kind TEXT NOT NULL, auth_provider TEXT NOT NULL, auth_site TEXT NOT NULL, auth_subject TEXT NOT NULL, auth_session TEXT NOT NULL, auth_tenant TEXT NOT NULL, auth_capabilities TEXT NOT NULL, target TEXT NOT NULL, schema TEXT NOT NULL, payload TEXT NOT NULL, dedupe_key TEXT NOT NULL, state TEXT NOT NULL, run_at BIGINT NOT NULL, attempt BIGINT NOT NULL, max_attempts BIGINT NOT NULL, timeout_ms BIGINT NOT NULL, claim_token TEXT NOT NULL, lease_until BIGINT NOT NULL, error TEXT NOT NULL, updated_at BIGINT NOT NULL)"), vec![]).await.unwrap();
for (id, execution_kind) in [("system-v4", "system"), ("user-v4", "user")] {
db.execute(
sql("INSERT INTO _dever_jobs(id,tenant_id,scope_tenant_id,execution_kind,auth_provider,auth_site,auth_subject,auth_session,auth_tenant,auth_capabilities,target,schema,payload,dedupe_key,state,run_at,attempt,max_attempts,timeout_ms,claim_token,lease_until,error,updated_at) VALUES (?1,0,0,?2,'account','admin','user:42','session:9','','[]','content.article.job.publish','unit','null',?3,'pending',0,0,2,1000,'',0,'',0)"),
vec![Value::Text(id.into()), Value::Text(execution_kind.into()), Value::Text(format!("v4:{id}"))],
).await.unwrap();
}
store::initialize(db.clone()).await.unwrap();
let migrated = db.query(sql("SELECT version,id,state,error,auth_permission FROM _dever_job_version CROSS JOIN _dever_jobs ORDER BY id"), vec![]).await.unwrap();
assert_eq!(migrated[0].get(0).unwrap(), &Value::Int(5));
assert_eq!(migrated[0].get(1).unwrap(), &Value::Text("system-v4".into()));
assert_eq!(migrated[0].get(2).unwrap(), &Value::Text("pending".into()));
assert_eq!(migrated[0].get(4).unwrap(), &Value::Text(String::new()));
assert_eq!(migrated[1].get(1).unwrap(), &Value::Text("user-v4".into()));
assert_eq!(migrated[1].get(2).unwrap(), &Value::Text("blocked".into()));
assert_eq!(migrated[1].get(3).unwrap(), &Value::Text("authorization_contract_changed".into()));
assert_eq!(migrated[1].get(4).unwrap(), &Value::Text(String::new()));
let columns = db.query(sql("PRAGMA table_info(_dever_jobs)"), vec![]).await.unwrap();
assert!(!columns.iter().any(|row| {
row.get(1).is_ok_and(|value| value == &Value::Text("auth_capabilities".into()))
}));
database::shutdown().await.unwrap();
Ok(())
}).unwrap();
}
#[cfg(all(feature = "sqlite", feature = "api", feature = "crypto", unix))]
#[test]
fn revoked_user_job_is_blocked_before_business_dispatch() {
use std::io::{Read, Write};
use std::time::{Duration, Instant};
struct Child(std::process::Child);
impl Drop for Child {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn request(
address: std::net::SocketAddr,
path: &str,
body: &str,
cookie: Option<&str>,
) -> String {
let mut stream =
std::net::TcpStream::connect_timeout(&address, Duration::from_secs(1)).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let cookie = cookie
.map(|value| format!("Cookie: {value}\r\n"))
.unwrap_or_default();
write!(stream,
"POST {path} HTTP/1.1\r\nHost: {address}\r\nOrigin: http://{address}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{cookie}\r\n{body}",
body.len(),
).unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
response
}
fn sqlite_rows(path: std::path::PathBuf, sql: &'static str) -> Vec<dever_runtime::orm::Row> {
dever_runtime::task::run_entry(async move {
let database = dever_runtime::sqlite::Database::open(path, 1, 100)
.map_err(|error| error.to_string())?;
database
.query(sql, vec![])
.await
.map_err(|error| error.to_string())
})
.unwrap()
}
fn sqlite_execute(path: std::path::PathBuf, sql: &'static str) {
dever_runtime::task::run_entry(async move {
let database = dever_runtime::sqlite::Database::open(path, 1, 100)
.map_err(|error| error.to_string())?;
database
.execute(sql, vec![])
.await
.map_err(|error| error.to_string())?;
Ok(())
})
.unwrap();
}
let project = temp::TemporaryDirectory::new();
std::fs::create_dir(project.path().join("config")).unwrap();
let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = reservation.local_addr().unwrap();
let setting = |mode: &str| {
format!(
r#"{{
"runtime":{{"mode":"{mode}","shutdown_ms":1000}},
"http":{{"host":"127.0.0.1","port":{}}},
"job":{{"workers":1,"poll_ms":10,"lease_ms":2000,"retry_base_ms":10,"retry_max_ms":100}},
"database":{{"default":{{"type":"sqlite","path":"data/db/jobs.db","max_connections":4}}}},
"auth":{{"providers":{{"session":{{
"verify":"user.account.verify",
"jwtSecret":"0123456789abcdef0123456789abcdef",
"ttlSeconds":3600,
"cookie":"cms_session"
}}}}}},
"sites":{{"front":{{
"path":"front",
"auth":"session",
"hosts":["127.0.0.1"],
"origin":"http://{}"
}}}}
}}"#,
address.port(),
address
)
};
std::fs::write(project.path().join("config/setting.json"), setting("api")).unwrap();
let settings = dever_runtime::config::Settings::load_project(project.path()).unwrap();
let mut application = SourceMap::default();
application.add(
"user/account/model.dever",
"global type Account { session: Text unique\nactive: Bool default true }",
);
application.add(
"user/account/model/effect.dever",
"global type Effect { value: Text }",
);
application.add(
"user/account/app.dever",
r#"
type Identity {
id: Text
user_id: model.id?
tenant_id: model.id?
}
type Work { value: Text }
login(session: Text) (result: Text) {
stored = model.create({ session = session })
discarded = stored
dever.auth.issue_cookie("account:1", session, null)
result = "ok"
}
verify(claims: dever.auth.Claims) (identity: Identity) {
stored = model.first({ where = session == claims.session and active == true })
identity = verified(stored, claims)
}
verified(stored: model.Account, claims: dever.auth.Claims) (identity: Identity) {
identity = Identity {
id = claims.subject
user_id = stored.id
tenant_id = null
}
}
verified(stored: null, claims: dever.auth.Claims) (identity: Identity) {
fail(dever.api.Error.Unauthorized)
}
submit() (id: Id) {
id = dever.job.enqueue(job.deliver, Work { value = "executed" }, "revoked-user")
}
deliver(input: Work) () {
stored = model.effect.create({ value = input.value })
discarded = stored
}
"#,
);
application.add(
"user/account/api/front/session.dever",
"public post login = app.login\npost submit = app.submit",
);
application.add("user/account/job.dever", "database default\njob deliver(input: app.Work) () retry(2) timeout(1000) { app.deliver(input) }");
let program =
dever_core::check_with_settings(&application, &settings).unwrap_or_else(|errors| {
panic!(
"{}",
errors
.iter()
.map(|error| error.render(&application))
.collect::<String>()
)
});
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
let native = dever_core::native::compile_project(
&program,
&application,
&rustc,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
let binary = project.path().join("application");
native.save(&binary).unwrap();
drop(reservation);
let mut api = Child(
std::process::Command::new(&binary)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(15);
while std::net::TcpStream::connect_timeout(&address, Duration::from_millis(20)).is_err() {
if api.0.try_wait().unwrap().is_some() || Instant::now() >= deadline {
let mut stderr = String::new();
if let Some(mut pipe) = api.0.stderr.take() {
pipe.read_to_string(&mut stderr).unwrap();
}
panic!("API did not start: {stderr}");
}
std::thread::sleep(Duration::from_millis(10));
}
let login = request(
address,
"/user/account/front/session/login",
r#"{"session":"session-1"}"#,
None,
);
assert!(login.starts_with("HTTP/1.1 200"), "{login}");
let cookie = login
.lines()
.find_map(|line| {
line.strip_prefix("set-cookie: ")
.or_else(|| line.strip_prefix("Set-Cookie: "))
.map(|value| value.split(';').next().unwrap().to_owned())
})
.expect("login response Cookie");
let database_path = project.path().join("data/db/jobs.db");
for statement in [
"INSERT INTO _dever_auth_role(id,site,name,all_permissions,enabled) VALUES ('submitter','front','Submitter',0,1)",
"INSERT INTO _dever_auth_role_permission(site,role_id,permission_key) VALUES ('front','submitter','user.account.front.submit')",
"INSERT INTO _dever_auth_user_role(user_id,site,role_id) VALUES (1,'front','submitter')",
] {
sqlite_execute(database_path.clone(), statement);
}
let submit = request(
address,
"/user/account/front/session/submit",
"{}",
Some(&cookie),
);
assert!(submit.starts_with("HTTP/1.1 200"), "{submit}");
let _ = api.0.kill();
let _ = api.0.wait();
sqlite_execute(
database_path.clone(),
"DELETE FROM _dever_auth_user_role WHERE user_id=1 AND site='front' AND role_id='submitter'",
);
std::fs::write(
project.path().join("config/setting.json"),
setting("worker"),
)
.unwrap();
let mut worker = Child(
std::process::Command::new(&binary)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(15);
let mut blocked = false;
while Instant::now() < deadline {
if worker.0.try_wait().unwrap().is_some() {
break;
}
let rows = sqlite_rows(
database_path.clone(),
"SELECT state,error FROM _dever_jobs WHERE dedupe_key='business:revoked-user'",
);
blocked = rows.first().is_some_and(|row| {
row.get(0).ok() == Some(&dever_runtime::orm::Value::Text("blocked".into()))
&& row.get(1).ok()
== Some(&dever_runtime::orm::Value::Text("identity_rejected".into()))
});
if blocked {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
if !blocked {
let mut stderr = String::new();
if let Some(mut pipe) = worker.0.stderr.take() {
pipe.read_to_string(&mut stderr).unwrap();
}
panic!("revoked Job was not blocked: {stderr}");
}
assert!(
sqlite_rows(database_path, "SELECT id FROM effect").is_empty(),
"revoked Job executed its business write"
);
assert!(
std::process::Command::new("/bin/sh")
.args([
"-c",
"kill -TERM \"$1\"",
"dever-test-signal",
&worker.0.id().to_string()
])
.status()
.unwrap()
.success()
);
while worker.0.try_wait().unwrap().is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
worker
.0
.try_wait()
.unwrap()
.is_some_and(|status| status.success())
);
}
#[cfg(all(feature = "sqlite", unix))]
#[test]
fn one_native_application_gates_api_worker_and_all_modes() {
use std::time::{Duration, Instant};
struct Child(std::process::Child);
impl Drop for Child {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let mut sources = sources(
"database default\njob publish() () retry(2) timeout(1000) { app.apply() }",
"submit() (id: Id) { id = dever.job.enqueue(job.publish, \"mode\") }\napply() () { article = model.create({ title = \"delivered\" }) }",
);
sources.add(
"content/article/model.dever",
"global type Article { title: Text }",
);
sources.add("content/article/api.dever", "cmd submit = app.submit");
sources.add(
"monitor/health/app.dever",
"health() (response: Bool) { response = true }",
);
sources.add("monitor/health/api.dever", "public get health = app.health");
let program = checked(&sources);
let native = dever_core::native::compile_project(
&program,
&sources,
std::ffi::OsStr::new("rustc"),
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
for mode in ["api", "worker", "all"] {
let project = temp::TemporaryDirectory::new();
std::fs::create_dir(project.path().join("config")).unwrap();
let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = reservation.local_addr().unwrap();
let http = if mode == "worker" {
String::new()
} else {
format!(
",\"http\":{{\"host\":\"127.0.0.1\",\"port\":{}}}",
address.port()
)
};
std::fs::write(project.path().join("config/setting.json"), format!("{{\"database\":{{\"default\":{{\"type\":\"sqlite\",\"path\":\"data/db/jobs.db\",\"max_connections\":2}}}},\"runtime\":{{\"mode\":\"{mode}\",\"shutdown_ms\":1000}},\"job\":{{\"workers\":1,\"poll_ms\":10,\"lease_ms\":1500,\"retry_base_ms\":10,\"retry_max_ms\":100}}{http}}}")).unwrap();
let binary = project.path().join("application");
native.save(&binary).unwrap();
let queued = std::process::Command::new(&binary)
.args(["content.article.submit", "{}"])
.output()
.unwrap();
assert!(
queued.status.success(),
"{}\n{}",
String::from_utf8_lossy(&queued.stdout),
String::from_utf8_lossy(&queued.stderr)
);
let response: serde_json::Value = serde_json::from_slice(&queued.stdout).unwrap();
assert_eq!(response["code"], 0);
drop(reservation);
let mut child = Child(
std::process::Command::new(binary)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap(),
);
let path = project.path().join("data/db/jobs.db");
let expected = if mode == "api" {
"pending"
} else {
"succeeded"
};
let deadline = Instant::now() + Duration::from_secs(15);
let mut ready = false;
while Instant::now() < deadline {
if child.0.try_wait().unwrap().is_some() {
break;
}
let listening =
std::net::TcpStream::connect_timeout(&address, Duration::from_millis(20)).is_ok();
let state = if path.exists() {
let path = path.clone();
dever_runtime::task::run_entry(async move {
let database = dever_runtime::sqlite::Database::open(path, 1, 100)
.map_err(|error| error.to_string())?;
let rows = database
.query(
"SELECT state FROM _dever_jobs WHERE dedupe_key='business:mode'",
vec![],
)
.await
.map_err(|error| error.to_string())?;
Ok(rows.first().and_then(|row| row.get(0).ok()).cloned())
})
.ok()
.flatten()
} else {
None
};
if listening == (mode != "worker")
&& state == Some(dever_runtime::orm::Value::Text(expected.into()))
{
ready = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
if !ready {
let _ = child.0.kill();
let _ = child.0.wait();
let mut stderr = String::new();
if let Some(mut pipe) = child.0.stderr.take() {
use std::io::Read;
pipe.read_to_string(&mut stderr).unwrap();
}
panic!("{mode} did not reach its service state: {stderr}");
}
assert!(
std::process::Command::new("/bin/sh")
.args([
"-c",
"kill -TERM \"$1\"",
"dever-test-signal",
&child.0.id().to_string()
])
.status()
.unwrap()
.success()
);
while child.0.try_wait().unwrap().is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
child
.0
.try_wait()
.unwrap()
.is_some_and(|status| status.success()),
"{mode} did not drain successfully"
);
}
}
#[cfg(all(feature = "sqlite", unix))]
#[test]
fn worker_signal_stops_claiming_and_drains_or_cancels_before_database_close() {
use dever_runtime::{
config, database,
job::{self, Clock, Spec, store, worker},
wire::Encoded,
};
use std::time::{Duration, Instant};
let executable = std::env::current_exe().unwrap();
let name = executable.file_stem().unwrap().to_str().unwrap();
if !name.starts_with("durable-job-signal-") {
for scenario in ["grace", "timeout", "transaction"] {
let project = temp::TemporaryDirectory::new();
std::fs::create_dir(project.path().join("config")).unwrap();
let shutdown = if scenario == "timeout" { 50 } else { 2000 };
std::fs::write(project.path().join("config/setting.json"), format!(r#"{{"runtime":{{"mode":"worker","shutdown_ms":{shutdown}}},"job":{{"workers":1,"poll_ms":5,"lease_ms":2400,"retry_base_ms":100,"retry_max_ms":1000}},"database":{{"default":{{"type":"sqlite","path":"data/db/jobs.db","max_connections":2}}}}}}"#)).unwrap();
let child_path = project
.path()
.join(format!("durable-job-signal-{scenario}"));
std::fs::copy(&executable, &child_path).unwrap();
let mut child = std::process::Command::new(child_path)
.args([
"--exact",
"worker_signal_stops_claiming_and_drains_or_cancels_before_database_close",
"--nocapture",
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(15);
while !project.path().join("ready").exists() && Instant::now() < deadline {
if child.try_wait().unwrap().is_some() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
if !project.path().join("ready").exists() {
let _ = child.kill();
let output = child.wait_with_output().unwrap();
panic!(
"worker did not become ready: {}",
String::from_utf8_lossy(&output.stderr)
);
}
assert!(
std::process::Command::new("/bin/sh")
.args([
"-c",
"kill -TERM \"$1\"",
"dever-test-signal",
&child.id().to_string()
])
.status()
.unwrap()
.success()
);
while child.try_wait().unwrap().is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
if child.try_wait().unwrap().is_none() {
let _ = child.kill();
}
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
std::fs::read_to_string(project.path().join("closed")).unwrap(),
scenario
);
}
return;
}
let directory = executable.parent().unwrap().to_path_buf();
let graceful = !name.ends_with("timeout");
let transactional = name.ends_with("transaction");
config::bootstrap(
config::RuntimeProfile {
sqlite: true,
postgres: false,
},
&[(Some("default"), "content")],
&[],
)
.unwrap();
dever_runtime::lifecycle::configure(false, true).unwrap();
dever_runtime::task::run_entry(async move {
job::scope_system(None, async move {
let db = database::database_for(Some("default"), "content").unwrap();
store::initialize(db.clone()).await.unwrap();
static SPEC: Spec = Spec { target: "content.article.job.signal", schema: "unit", attempts: 3, timeout_ms: 2000 };
config::settings().validate_job_connection(Some("default"), "content", SPEC.timeout_ms).unwrap();
let old_policy = Spec { timeout_ms: 3000, ..SPEC };
job::enqueue(db.clone(), None, &old_policy, Encoded::null(), "old-policy", -1).await.unwrap();
// UUID tie-breaking is intentionally random within one millisecond; give
// this ordering assertion distinct due times instead of assuming key order.
for (run_at, key) in ["a", "b"].into_iter().enumerate() {
job::enqueue(db.clone(), None, &SPEC, Encoded::null(), key, run_at as i64).await.unwrap();
}
let settings = config::settings().jobs().unwrap();
let ready = directory.join("ready");
let service_db = db.clone();
let result = dever_runtime::lifecycle::run(async move {
worker::serve(vec![worker::Binding::fixed(service_db.clone(), &SPEC, None)], Clock::default(), settings, move |_| {
let ready = ready.clone();
let db = service_db.clone();
async move {
let transaction = if transactional { Some(db.begin().await.unwrap()) } else { None };
std::fs::write(ready, "claimed").unwrap();
tokio::time::sleep(Duration::from_millis(1000)).await;
if let Some(transaction) = transaction { transaction.commit().await.unwrap(); }
store::Outcome::Success
}
}).await
}).await;
assert_eq!(result.is_ok(), graceful, "{result:?}");
let sql = database::Sql { sqlite: "SELECT state,error FROM _dever_jobs WHERE dedupe_key!='business:old-policy' ORDER BY dedupe_key", postgres: "SELECT state,error FROM _dever_jobs WHERE dedupe_key!='business:old-policy' ORDER BY dedupe_key" };
let rows = db.query(sql, vec![]).await.unwrap();
let expected = if graceful { "succeeded" } else { "running" };
assert_eq!(rows[0].get(0).unwrap(), &dever_runtime::orm::Value::Text(expected.into()), "{:?}", rows[0].get(1));
assert_eq!(rows[1].get(0).unwrap(), &dever_runtime::orm::Value::Text("pending".into()));
let policy = db.query(database::Sql { sqlite: "SELECT state,error FROM _dever_jobs WHERE dedupe_key='business:old-policy'", postgres: "SELECT state,error FROM _dever_jobs WHERE dedupe_key='business:old-policy'" }, vec![]).await.unwrap();
assert_eq!(policy[0].get(0).unwrap(), &dever_runtime::orm::Value::Text("blocked".into()));
assert_eq!(policy[0].get(1).unwrap(), &dever_runtime::orm::Value::Text("invalid_policy".into()));
database::shutdown().await.unwrap();
assert!(db.query(database::Sql { sqlite: "SELECT 1", postgres: "SELECT 1" }, vec![]).await.is_err());
dever_runtime::log::flush();
std::fs::write(directory.join("closed"), if transactional { "transaction" } else if graceful { "grace" } else { "timeout" }).unwrap();
Ok(())
}).await
}).unwrap();
}
#[cfg(feature = "sqlite")]
#[test]
fn tenant_jobs_share_the_business_database_and_restore_only_tenant_scope() {
use dever_runtime::{
config, database,
job::{self, Clock, Spec, store, worker},
orm::Value,
wire::Encoded,
};
let executable = std::env::current_exe().unwrap();
if executable.file_stem().unwrap() != "tenant-job-case" {
let project = temp::TemporaryDirectory::new();
std::fs::create_dir(project.path().join("config")).unwrap();
std::fs::write(project.path().join("config/setting.json"), r#"{
"tenant":{"database":"default","max_pools":2,"idle_timeout_ms":1000},
"database":{"default":{"type":"sqlite","path":"data/platform.db","tenant_directory":"data/tenants","max_connections":2}}
}"#).unwrap();
let child = project.path().join("tenant-job-case");
std::fs::copy(executable, &child).unwrap();
let output = std::process::Command::new(child)
.args([
"--exact",
"tenant_jobs_share_the_business_database_and_restore_only_tenant_scope",
"--nocapture",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
config::bootstrap_scoped(
config::RuntimeProfile {
sqlite: true,
postgres: false,
},
&[(Some("default"), "content", true)],
&[&[(Some("default"), "content", true)]],
)
.unwrap();
dever_runtime::task::run_entry(async {
static SPEC: Spec = Spec {
target: "content.article.job.publish",
schema: "unit",
attempts: 2,
timeout_ms: 1000,
};
const FINGERPRINT: &str = "tenant-job-test-v1";
dever_runtime::tenant::initialize(FINGERPRINT)
.await
.unwrap();
for tenant_id in [1, 2] {
let db = dever_runtime::tenant::begin_migration("default", tenant_id, FINGERPRINT)
.await
.unwrap();
store::initialize(db.clone()).await.unwrap();
db.execute(
database::Sql {
sqlite: "CREATE TABLE business (id BIGINT PRIMARY KEY)",
postgres: "CREATE TABLE business (id BIGINT PRIMARY KEY)",
},
vec![],
)
.await
.unwrap();
dever_runtime::tenant::mark_database_ready(&db, tenant_id, FINGERPRINT)
.await
.unwrap();
dever_runtime::tenant::finish_migration(tenant_id, FINGERPRINT)
.await
.unwrap();
}
let storage = Box::leak(Box::new(database::StorageBinding::new(
Some("default"),
"content",
database::ModelScope::Tenant,
)));
let db1 = dever_runtime::tenant::scope(1, storage.database())
.await
.unwrap();
let rolled_back = db1.begin().await.unwrap();
database::Executor::new(db1.clone(), Some(&rolled_back))
.unwrap()
.execute(
database::Sql {
sqlite: "INSERT INTO business(id) VALUES (1)",
postgres: "INSERT INTO business(id) VALUES (1)",
},
vec![],
)
.await
.unwrap();
job::scope_system(
Some(1),
job::enqueue(
db1.clone(),
Some(&rolled_back),
&SPEC,
Encoded::null(),
"same",
0,
),
)
.await
.unwrap();
rolled_back.rollback().await.unwrap();
assert!(
db1.query(
database::Sql {
sqlite: "SELECT id FROM business",
postgres: "SELECT id FROM business"
},
vec![]
)
.await
.unwrap()
.is_empty()
);
assert!(
db1.query(
database::Sql {
sqlite: "SELECT id FROM _dever_jobs",
postgres: "SELECT id FROM _dever_jobs"
},
vec![]
)
.await
.unwrap()
.is_empty()
);
let tenant1_job = dever_runtime::tenant::scope(
1,
job::scope_system(Some(1), async {
let db = storage.database().await.unwrap();
job::enqueue(db, None, &SPEC, Encoded::null(), "same", 0)
.await
.unwrap()
}),
)
.await;
let tenant2_job = dever_runtime::tenant::scope(
2,
job::scope_system(Some(2), async {
let db = storage.database().await.unwrap();
job::enqueue(db, None, &SPEC, Encoded::null(), "same", 0)
.await
.unwrap()
}),
)
.await;
assert_ne!(tenant1_job, tenant2_job);
let binding = worker::Binding::scoped(storage, database::ModelScope::Tenant, &SPEC, None);
let drained = dever_runtime::tenant::scope(
1,
worker::drain(vec![binding], Clock::for_test(), 1, |claim| async move {
assert_eq!(claim.tenant_id, Some(1));
assert_eq!(dever_runtime::tenant::current_id().unwrap(), 1);
#[cfg(feature = "api")]
assert!(dever_runtime::auth::id().is_err());
store::Outcome::Success
}),
)
.await
.unwrap();
assert_eq!(drained, 1);
let db2 = dever_runtime::tenant::scope(2, storage.database())
.await
.unwrap();
let rows = db2
.query(
database::Sql {
sqlite: "SELECT tenant_id FROM _dever_jobs WHERE state='pending'",
postgres: "SELECT tenant_id FROM _dever_jobs WHERE state='pending'",
},
vec![],
)
.await
.unwrap();
assert_eq!(rows[0].get(0).unwrap(), &Value::Int(2));
db2.execute(
database::Sql {
sqlite: "UPDATE _dever_jobs SET tenant_id=1 WHERE state='pending'",
postgres: "UPDATE _dever_jobs SET tenant_id=1 WHERE state='pending'",
},
vec![],
)
.await
.unwrap();
assert!(
store::claim(db2, &Clock::for_test(), 1000, 1)
.await
.unwrap_err()
.to_string()
.contains("tenant ownership")
);
let Err(unknown) = dever_runtime::tenant::scope(3, storage.database()).await else {
panic!("unknown tenant must not resolve a database");
};
assert!(unknown.to_string().contains("not provisioned"));
database::shutdown().await.unwrap();
Ok(())
})
.unwrap();
}