use dbos::sysdb::migrations::{Dialect, Migration, build_migrations};
use sqlx::{PgPool, Row};
use dbos_test_support::{Backend, raw_database};
fn dialect_for(backend: Backend) -> Dialect {
match backend {
Backend::Postgres => Dialect::Postgres,
Backend::Cockroach => Dialect::Cockroach,
}
}
async fn apply_all(pool: &PgPool, schema: &str, migrations: &[Migration]) {
for m in migrations {
if m.sql.trim().is_empty() {
continue;
}
if let Some(guard) = m.guard {
let already = sqlx::query(guard)
.bind(schema)
.fetch_optional(pool)
.await
.unwrap_or_else(|e| panic!("migration {} guard failed: {e}", m.version));
if already.is_some() {
continue;
}
}
sqlx::raw_sql(sqlx::AssertSqlSafe(m.sql.clone()))
.execute(pool)
.await
.unwrap_or_else(|e| panic!("migration {} failed: {e}\n---\n{}", m.version, m.sql));
}
}
async fn table_names(pool: &PgPool, schema: &str) -> Vec<String> {
let rows = sqlx::query("SELECT table_name FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name")
.bind(schema)
.fetch_all(pool)
.await
.expect("failed to list tables");
rows.iter().map(|r| r.get::<String, _>(0)).collect()
}
async fn columns_of(pool: &PgPool, schema: &str, table: &str) -> Vec<String> {
let rows = sqlx::query("SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 ORDER BY column_name")
.bind(schema)
.bind(table)
.fetch_all(pool)
.await
.expect("failed to list columns");
rows.iter().map(|r| r.get::<String, _>(0)).collect()
}
async fn trigger_names(pool: &PgPool, schema: &str) -> Vec<String> {
sqlx::query_scalar(
"SELECT DISTINCT trigger_name FROM information_schema.triggers \
WHERE trigger_schema = $1 ORDER BY 1",
)
.bind(schema)
.fetch_all(pool)
.await
.expect("failed to list triggers")
}
async fn function_names(pool: &PgPool, schema: &str) -> Vec<String> {
sqlx::query_scalar(
"SELECT p.proname FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \
WHERE n.nspname = $1 ORDER BY 1",
)
.bind(schema)
.fetch_all(pool)
.await
.expect("failed to list functions")
}
#[tokio::test]
async fn the_corpus_applies_to_a_real_database() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
let migrations = build_migrations(schema, dialect_for(db.backend()), true);
assert_eq!(
migrations.len(),
dbos::sysdb::migrations::SHARED_MIGRATIONS as usize,
"one entry per migration, including the padding to the shared base",
);
dbos::sysdb::migrations::runner::run(&pool, schema, true)
.await
.expect("migration failed");
let tables = table_names(&pool, schema).await;
for expected in [
"application_versions",
"event_dispatch_kv",
"notifications",
"operation_outputs",
"queues",
"streams",
"workflow_events",
"workflow_events_history",
"workflow_schedules",
"workflow_status",
] {
assert!(
tables.iter().any(|t| t == expected),
"missing table {expected}; got {tables:?}",
);
}
}
#[tokio::test]
async fn late_migrations_take_effect() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
sqlx::raw_sql(r#"CREATE SCHEMA IF NOT EXISTS "dbos""#)
.execute(&pool)
.await
.unwrap();
apply_all(
&pool,
schema,
&build_migrations(schema, dialect_for(db.backend()), true),
)
.await;
let cols = columns_of(&pool, schema, "workflow_status").await;
for expected in [
"queue_partition_key", "forked_from", "owner_xid", "serialization", "delay_until_epoch_ms", "rate_limited", "completed_at", "attributes", "schedule_name", "debounce_deadline_epoch_ms", ] {
assert!(
cols.iter().any(|c| c == expected),
"workflow_status is missing {expected}; got {cols:?}",
);
}
}
#[tokio::test]
async fn the_shared_series_adds_application_name_everywhere() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
sqlx::raw_sql(r#"CREATE SCHEMA IF NOT EXISTS "dbos""#)
.execute(&pool)
.await
.unwrap();
apply_all(
&pool,
schema,
&build_migrations(schema, dialect_for(db.backend()), true),
)
.await;
for table in [
"workflow_status", "queues", "workflow_schedules", "application_versions", "operation_outputs", ] {
let cols = columns_of(&pool, schema, table).await;
assert!(
cols.iter().any(|c| c == "application_name"),
"{table} is missing application_name; got {cols:?}",
);
}
}
#[tokio::test]
async fn an_enqueue_may_claim_an_application_or_leave_it_unclaimed() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
sqlx::raw_sql(r#"CREATE SCHEMA IF NOT EXISTS "dbos""#)
.execute(&pool)
.await
.unwrap();
apply_all(
&pool,
schema,
&build_migrations(schema, dialect_for(db.backend()), true),
)
.await;
let unclaimed: String = sqlx::query_scalar(
r#"SELECT "dbos".enqueue_workflow($1::TEXT, $2::TEXT, ARRAY[]::JSON[], '{}'::JSON,
NULL, NULL, $3::TEXT, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)"#,
)
.bind("a-workflow")
.bind("a-queue")
.bind("unclaimed-id")
.fetch_one(&pool)
.await
.expect("the sixteen-argument call should still resolve");
let claimed: String = sqlx::query_scalar(
r#"SELECT "dbos".enqueue_workflow($1::TEXT, $2::TEXT, ARRAY[]::JSON[], '{}'::JSON,
NULL, NULL, $3::TEXT, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $4::TEXT)"#,
)
.bind("a-workflow")
.bind("a-queue")
.bind("claimed-id")
.bind("alpha")
.fetch_one(&pool)
.await
.expect("the seventeen-argument call should enqueue");
for (id, expected) in [(unclaimed, None), (claimed, Some("alpha".to_string()))] {
let stored: Option<String> = sqlx::query_scalar(
r#"SELECT "application_name" FROM "dbos"."workflow_status" WHERE "workflow_uuid" = $1"#,
)
.bind(&id)
.fetch_one(&pool)
.await
.expect("the enqueued workflow should exist");
assert_eq!(stored, expected, "{id} recorded the wrong application");
}
}
#[tokio::test]
async fn an_sql_enqueue_writes_its_inputs_to_the_payload_table() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
sqlx::raw_sql(r#"CREATE SCHEMA IF NOT EXISTS "dbos""#)
.execute(&pool)
.await
.unwrap();
apply_all(
&pool,
schema,
&build_migrations(schema, dialect_for(db.backend()), true),
)
.await;
let id: String = sqlx::query_scalar(
r#"SELECT "dbos".enqueue_workflow($1::TEXT, $2::TEXT, ARRAY['7'::JSON], '{}'::JSON,
NULL, NULL, $3::TEXT)"#,
)
.bind("a-workflow")
.bind("a-queue")
.bind("sql-enqueued")
.fetch_one(&pool)
.await
.expect("the enqueue should succeed");
let legacy: Option<String> = sqlx::query_scalar(
r#"SELECT "inputs" FROM "dbos"."workflow_status" WHERE "workflow_uuid" = $1"#,
)
.bind(&id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(legacy, None, "the legacy column must not be written");
let inputs: Option<String> = sqlx::query_scalar(
r#"SELECT "inputs" FROM "dbos"."workflow_input" WHERE "workflow_uuid" = $1"#,
)
.bind(&id)
.fetch_one(&pool)
.await
.expect("the inputs should be in workflow_input");
let inputs: serde_json::Value =
serde_json::from_str(&inputs.expect("inputs were written")).unwrap();
assert_eq!(
inputs,
serde_json::json!({"positionalArgs": [7], "namedArgs": {}})
);
}
#[tokio::test]
async fn version_uniqueness_is_split_by_owner() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
sqlx::raw_sql(r#"CREATE SCHEMA IF NOT EXISTS "dbos""#)
.execute(&pool)
.await
.unwrap();
apply_all(
&pool,
schema,
&build_migrations(schema, dialect_for(db.backend()), true),
)
.await;
let indexes: Vec<String> = sqlx::query_scalar(
"SELECT i.relname FROM pg_index ix \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
WHERE n.nspname = $1 AND t.relname = 'application_versions' AND ix.indisvalid",
)
.bind(schema)
.fetch_all(&pool)
.await
.expect("failed to list indexes");
for expected in [
"uq_application_versions_owner_version", "uq_application_versions_unclaimed_version", ] {
assert!(
indexes.iter().any(|i| i == expected),
"{expected} is missing or invalid; got {indexes:?}",
);
}
}
fn index_columns(definition: &str) -> (Vec<String>, Vec<String>) {
fn list(after: &str) -> Vec<String> {
let inner = &after[..after.find(')').expect("unterminated column list")];
inner
.split(',')
.map(|c| c.trim().trim_end_matches(" ASC").to_owned())
.collect()
}
let keys = definition
.split_once("USING btree (")
.map(|(_, rest)| list(rest))
.expect("a btree column list");
let included = ["INCLUDE (", "STORING ("]
.iter()
.find_map(|marker| definition.split_once(marker))
.map(|(_, rest)| list(rest))
.unwrap_or_default();
(keys, included)
}
#[tokio::test]
async fn the_index_rebuilds_replace_their_predecessors() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
sqlx::raw_sql(r#"CREATE SCHEMA IF NOT EXISTS "dbos""#)
.execute(&pool)
.await
.unwrap();
apply_all(
&pool,
schema,
&build_migrations(schema, dialect_for(db.backend()), true),
)
.await;
let definitions: Vec<(String, String)> = sqlx::query_as(
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 \
AND tablename IN ('workflow_status', 'operation_outputs')",
)
.bind(schema)
.fetch_all(&pool)
.await
.expect("failed to list index definitions");
let valid: Vec<String> = sqlx::query_scalar(
"SELECT i.relname FROM pg_index ix \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
WHERE n.nspname = $1 AND t.relname IN ('workflow_status', 'operation_outputs') \
AND ix.indisvalid",
)
.bind(schema)
.fetch_all(&pool)
.await
.expect("failed to list valid indexes");
let in_flight = ["ENQUEUED", "PENDING"].as_slice();
for (built, keys, predicate, dropped) in [
(
"idx_workflow_status_in_flight_v2", ["queue_name", "status", "priority", "created_at"].as_slice(),
in_flight,
"idx_workflow_status_in_flight", ),
(
"idx_workflow_status_partition_dequeue_v3", [
"queue_name",
"status",
"queue_partition_key",
"priority",
"created_at",
"workflow_uuid",
]
.as_slice(),
["ENQUEUED", "PENDING", "queue_partition_key IS NOT NULL"].as_slice(),
"idx_workflow_status_partition_dequeue_v2", ),
(
"idx_operation_outputs_completed_at_function_name_v2", ["completed_at_epoch_ms", "function_name"].as_slice(),
[].as_slice(),
"idx_operation_outputs_completed_at_function_name", ),
] {
assert!(
valid.iter().any(|i| i == built),
"{built} is missing or invalid; got {valid:?}",
);
let definition = &definitions
.iter()
.find(|(name, _)| name == built)
.expect("a valid index has a definition")
.1;
let (actual_keys, included) = index_columns(definition);
assert_eq!(actual_keys, keys, "{built} key columns: {definition}");
assert_eq!(
included,
["application_name"],
"{built} included columns: {definition}"
);
match predicate {
[] => assert!(
!definition.contains(" WHERE "),
"{built} should be a full index: {definition}"
),
fragments => {
let (_, condition) = definition
.split_once(" WHERE ")
.unwrap_or_else(|| panic!("{built} should be partial: {definition}"));
for fragment in fragments {
assert!(
condition.contains(fragment),
"{built} predicate lacks {fragment}: {definition}"
);
}
}
}
assert!(
!definitions.iter().any(|(name, _)| name == dropped),
"{dropped} should have been dropped; got {definitions:?}",
);
}
}
#[tokio::test]
async fn migration_121_adds_a_32_bit_consumer_step_column() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
dbos::sysdb::migrations::runner::run(&pool, schema, true)
.await
.expect("migration failed");
let data_type: String = sqlx::query_scalar(
"SELECT data_type FROM information_schema.columns \
WHERE table_schema = $1 AND table_name = 'notifications' \
AND column_name = 'consumed_by_function_id'",
)
.bind(schema)
.fetch_one(&pool)
.await
.expect("consumed_by_function_id is missing");
assert_eq!(data_type, "integer");
}
#[tokio::test]
async fn migration_122_builds_the_deadline_index() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
dbos::sysdb::migrations::runner::run(&pool, schema, true)
.await
.expect("migration failed");
let definition: String = sqlx::query_scalar(
"SELECT indexdef FROM pg_indexes WHERE schemaname = $1 \
AND tablename = 'workflow_status' AND indexname = 'idx_workflow_status_deadline'",
)
.bind(schema)
.fetch_one(&pool)
.await
.expect("idx_workflow_status_deadline is missing");
let valid: bool = sqlx::query_scalar(
"SELECT ix.indisvalid FROM pg_index ix \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_namespace n ON n.oid = i.relnamespace \
WHERE n.nspname = $1 AND i.relname = 'idx_workflow_status_deadline'",
)
.bind(schema)
.fetch_one(&pool)
.await
.expect("failed to read the index's validity");
assert!(valid, "the index is invalid: {definition}");
let (keys, included) = index_columns(&definition);
assert_eq!(keys, ["workflow_deadline_epoch_ms"], "{definition}");
assert!(included.is_empty(), "{definition}");
let (_, condition) = definition
.split_once(" WHERE ")
.unwrap_or_else(|| panic!("the index should be partial: {definition}"));
for fragment in [
"ENQUEUED",
"PENDING",
"DELAYED",
"workflow_deadline_epoch_ms IS NOT NULL",
] {
assert!(
condition.contains(fragment),
"the predicate lacks {fragment}: {definition}"
);
}
}
#[tokio::test]
async fn only_the_notifications_trigger_survives() {
let db = raw_database().await;
if db.backend() == Backend::Cockroach {
return; }
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
sqlx::raw_sql(r#"CREATE SCHEMA IF NOT EXISTS "dbos""#)
.execute(&pool)
.await
.unwrap();
apply_all(
&pool,
schema,
&build_migrations(schema, Dialect::Postgres, true),
)
.await;
assert_eq!(
trigger_names(&pool, schema).await,
["dbos_notifications_trigger"]
);
let indexes: Vec<String> = sqlx::query_scalar(
"SELECT indexname FROM pg_indexes WHERE schemaname = $1 \
AND indexname LIKE 'idx_workflow_status_partition%' ORDER BY 1",
)
.bind(schema)
.fetch_all(&pool)
.await
.expect("failed to list indexes");
assert_eq!(
indexes,
vec!["idx_workflow_status_partition_dequeue_v3".to_owned()],
);
}
#[tokio::test]
async fn the_trigger_drops_run_without_listen_notify() {
let db = raw_database().await;
if db.backend() == Backend::Cockroach {
return; }
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
sqlx::raw_sql(r#"CREATE SCHEMA IF NOT EXISTS "dbos""#)
.execute(&pool)
.await
.unwrap();
let with_notify = build_migrations(schema, Dialect::Postgres, true);
let without = build_migrations(schema, Dialect::Postgres, false);
assert_eq!(with_notify[41].version, 42);
apply_all(&pool, schema, &with_notify[..42]).await;
apply_all(&pool, schema, &without[42..]).await;
assert_eq!(
trigger_names(&pool, schema).await,
["dbos_notifications_trigger"]
);
let functions = function_names(&pool, schema).await;
for dropped in ["workflow_events_function", "streams_function"] {
assert!(
!functions.iter().any(|f| f == dropped),
"{dropped} should have been dropped, got {functions:?}",
);
}
}
#[tokio::test]
async fn migration_ten_is_a_no_op_on_a_schema_we_created() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
sqlx::raw_sql(r#"CREATE SCHEMA IF NOT EXISTS "dbos""#)
.execute(&pool)
.await
.unwrap();
let migrations = build_migrations(schema, dialect_for(db.backend()), true);
let ten = migrations.iter().find(|m| m.version == 10).unwrap();
let guard = ten.guard.expect("migration 10 carries a guard");
apply_all(&pool, schema, &migrations[..9]).await;
let already = sqlx::query(guard)
.bind(schema)
.fetch_optional(&pool)
.await
.expect("the guard query should execute on both backends");
assert!(
already.is_some(),
"migration 1 should already have created the notifications primary key",
);
}
#[tokio::test]
async fn dialects_agree_on_the_number_of_versions() {
let pg = build_migrations("dbos", Dialect::Postgres, true);
let crdb = build_migrations("dbos", Dialect::Cockroach, true);
let no_notify = build_migrations("dbos", Dialect::Postgres, false);
assert_eq!(pg.len(), crdb.len());
assert_eq!(pg.len(), no_notify.len());
for (a, b) in pg.iter().zip(crdb.iter()) {
assert_eq!(a.version, b.version);
}
assert!(
crdb.iter()
.find(|m| m.version == 20)
.unwrap()
.sql
.is_empty()
);
assert!(
crdb.iter()
.find(|m| m.version == 39)
.unwrap()
.sql
.is_empty()
);
assert!(!pg.iter().find(|m| m.version == 20).unwrap().sql.is_empty());
assert!(crdb.iter().all(|m| !m.online));
assert_eq!(
pg.iter().filter(|m| m.online).count(),
pg.iter()
.filter(|m| dbos::sysdb::migrations::is_online(m.version))
.count(),
);
}
#[tokio::test]
async fn the_corpus_applies_without_listen_notify() {
let db = raw_database().await;
if db.backend() == Backend::Cockroach {
return; }
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
sqlx::raw_sql(r#"CREATE SCHEMA IF NOT EXISTS "dbos""#)
.execute(&pool)
.await
.unwrap();
apply_all(
&pool,
schema,
&build_migrations(schema, Dialect::Postgres, false),
)
.await;
let triggers: i64 = sqlx::query_scalar(
"SELECT count(*) FROM information_schema.triggers WHERE trigger_schema = $1",
)
.bind(schema)
.fetch_one(&pool)
.await
.expect("failed to count triggers");
assert_eq!(
triggers, 0,
"no triggers should exist with notifications off"
);
let functions = function_names(&pool, schema).await;
for absent in [
"notifications_function",
"workflow_events_function",
"streams_function",
] {
assert!(
!functions.iter().any(|f| f == absent),
"{absent} should not exist with notifications off, got {functions:?}",
);
}
for present in ["enqueue_workflow", "send_message"] {
assert!(
functions.iter().any(|f| f == present),
"{present} is unrelated to notifications and should exist, got {functions:?}",
);
}
assert!(
table_names(&pool, schema)
.await
.iter()
.any(|t| t == "workflow_status")
);
}
#[tokio::test]
async fn migrations_124_and_125_build_the_version_indexes() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
dbos::sysdb::migrations::runner::run(&pool, schema, true)
.await
.expect("migration failed");
let again: Vec<Migration> = build_migrations(schema, dialect_for(db.backend()), true)
.into_iter()
.filter(|m| matches!(m.version, 124 | 125))
.collect();
assert_eq!(again.len(), 2);
apply_all(&pool, schema, &again).await;
for (index, expected) in [
(
"idx_application_versions_timestamp",
&["version_timestamp DESC"][..],
),
(
"idx_application_versions_owner_timestamp",
&["application_name", "version_timestamp DESC"][..],
),
] {
let definition: String = sqlx::query_scalar(
"SELECT indexdef FROM pg_indexes WHERE schemaname = $1 \
AND tablename = 'application_versions' AND indexname = $2",
)
.bind(schema)
.bind(index)
.fetch_one(&pool)
.await
.unwrap_or_else(|e| panic!("{index} is missing: {e}"));
let valid: bool = sqlx::query_scalar(
"SELECT ix.indisvalid FROM pg_index ix \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_namespace n ON n.oid = i.relnamespace \
WHERE n.nspname = $1 AND i.relname = $2",
)
.bind(schema)
.bind(index)
.fetch_one(&pool)
.await
.expect("failed to read the index's validity");
assert!(valid, "the index is invalid: {definition}");
assert!(
definition.starts_with("CREATE INDEX"),
"the index should not be unique: {definition}"
);
let (keys, included) = index_columns(&definition);
assert_eq!(keys, expected, "{definition}");
assert!(included.is_empty(), "{definition}");
}
}
#[tokio::test]
async fn migration_123_adds_the_creator_token_column() {
let db = raw_database().await;
let pool = db.pool().await;
let schema = dbos::sysdb::DEFAULT_SCHEMA;
dbos::sysdb::migrations::runner::run(&pool, schema, true)
.await
.expect("migration failed");
let (data_type, nullable, default): (String, String, Option<String>) = sqlx::query_as(
"SELECT data_type, is_nullable, column_default FROM information_schema.columns \
WHERE table_schema = $1 AND table_name = 'workflow_status' \
AND column_name = 'creator_xid'",
)
.bind(schema)
.fetch_one(&pool)
.await
.expect("creator_xid is missing");
assert_eq!(data_type, "text");
assert_eq!(nullable, "YES");
assert!(
default
.as_deref()
.is_none_or(|default| default.eq_ignore_ascii_case("NULL")),
"creator_xid defaults to {default:?}"
);
let statement = dbos::sysdb::migrations::SOURCES
.iter()
.find(|source| source.version == 123)
.expect("migration 123 is registered")
.sql
.replace(
"{{schema}}",
&dbos::sysdb::migrations::quote_identifier(schema),
);
sqlx::raw_sql(sqlx::AssertSqlSafe(statement))
.execute(&pool)
.await
.expect("applying migration 123 again failed");
}