1574 lines
67 KiB
Rust
1574 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("kill")
|
|
.args(["-TERM", &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("kill")
|
|
.args(["-TERM", &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("kill")
|
|
.args(["-TERM", &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();
|
|
}
|