#![cfg(feature = "postgres")]
mod upgrade;
use std::error::Error;
use std::sync::Arc;
use std::time::UNIX_EPOCH;
use oxide_batch::{PostgresJobRepository, PostgresMigrator};
use serde_json::{Value, json};
use upgrade::{
DurableDigest, Failure, FixedClock, ProbeRun, SourceColumns, admin_url, apply_seed,
assert_historical_shape, config, durable_tables, execution_manifest, fixtures,
install_historical_schema, major_version, migrator_url, read_through_port, recreate_database,
retain_observation, run_schema2_runtime, schema_version, server_version, with_database,
};
const RUNTIME_SCHEMA_VERSION: u64 = 2;
const DATABASE_SCHEMA_VERSION: u32 = 3;
const DATABASE: &str = "oxide_batch_m5_upgrade_rejection";
#[test]
fn schema2_runtime_rejects_schema3() -> Result<(), Box<dyn Error>> {
let Some(migrator) = migrator_url() else {
eprintln!("skipped: OXIDEBATCH_POSTGRES_MIGRATOR_TEST_URL is not set");
return Ok(());
};
let Some(admin) = admin_url() else {
eprintln!("skipped: OXIDEBATCH_POSTGRES_BACKUP_TEST_URL is not set");
return Ok(());
};
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(run_report(&migrator, &admin))
}
#[allow(
clippy::too_many_lines,
reason = "building a schema-2 database, upgrading it, building the runtime that shipped \
against schema 2, and requiring it to refuse without writing form one report that \
is only meaningful in order"
)]
async fn run_report(migrator: &str, admin: &str) -> Result<(), Box<dyn Error>> {
let url = with_database(migrator, DATABASE)?;
recreate_database(admin, DATABASE).await?;
install_historical_schema(&url, RUNTIME_SCHEMA_VERSION.try_into()?).await?;
assert_historical_shape(&url, RUNTIME_SCHEMA_VERSION.try_into()?).await?;
apply_seed(&url, &fixtures().join("schema-2").join("seed.sql")).await?;
PostgresMigrator::migrate(&config(url.clone())?).await?;
let installed = schema_version(&url).await?;
assert_eq!(
installed,
Some(DATABASE_SCHEMA_VERSION),
"the report offers a schema-{DATABASE_SCHEMA_VERSION} database and this one is not",
);
assert_historical_shape(&url, DATABASE_SCHEMA_VERSION).await?;
let tables = durable_tables(DATABASE_SCHEMA_VERSION);
let columns = SourceColumns::capture(&url, &tables).await?;
let before = DurableDigest::read(&url, &columns, &tables).await?;
let applied_before = applied_migrations(&url).await?;
let probe = run_schema2_runtime(&url)?;
let observed = probe_report(&probe)?;
assert!(
probe.exit_success,
"the schema-2 runtime did not fail closed on a schema-{DATABASE_SCHEMA_VERSION} \
database: {}",
probe.report,
);
assert_eq!(
observed.supported, RUNTIME_SCHEMA_VERSION,
"the report is about a runtime that supports schema {RUNTIME_SCHEMA_VERSION}, and the \
one that ran reports {}",
observed.supported,
);
for (entry, attempt) in [
(&observed.repository_open, "repository startup"),
(&observed.migrator_run, "the migrator"),
] {
assert!(
!entry.accepted,
"{attempt} on the schema-2 runtime accepted a schema-{DATABASE_SCHEMA_VERSION} \
database",
);
assert_eq!(
entry.error.as_deref(),
Some("NewerSchema"),
"{attempt} on the schema-2 runtime must refuse with the typed newer-schema failure \
rather than any other error, which would refuse for a reason that is not the \
contract",
);
assert_eq!(
entry.observed,
Some(u64::from(DATABASE_SCHEMA_VERSION)),
"{attempt} must report the schema version it actually found",
);
assert_eq!(
entry.supported,
Some(RUNTIME_SCHEMA_VERSION),
"{attempt} must report the schema version that runtime supports",
);
}
let after = DurableDigest::read(&url, &columns, &tables).await?;
assert_eq!(
before.differences(&after),
Vec::<String>::new(),
"the schema-2 runtime changed durable state while refusing the database",
);
assert_eq!(
schema_version(&url).await?,
Some(DATABASE_SCHEMA_VERSION),
"the schema-2 runtime changed the recorded schema version while refusing the database",
);
assert_eq!(
applied_migrations(&url).await?,
applied_before,
"the schema-2 runtime applied or removed a migration while refusing the database",
);
let repository =
PostgresJobRepository::connect(config(url.clone())?, Arc::new(FixedClock(UNIX_EPOCH)))
.await?;
let reading = read_through_port(&repository).await?;
assert!(
reading.instance.is_some(),
"the current runtime must still open and project the database the schema-2 runtime \
refused",
);
repository.close().await?;
let server = server_version(&url).await?;
retain_observation(
"schema-rejection",
&json!({
"report": "a schema-2 runtime refusing a schema-3 database",
"scenario": "schema2_runtime_rejects_schema3",
"fixture": "postgres-upgrade",
"server_version": server,
"postgres_major_version": major_version(&server),
"paths": [{
"source_schema_version": RUNTIME_SCHEMA_VERSION,
"target_schema_version": DATABASE_SCHEMA_VERSION,
"migration_result": "ok",
"repository_open_result": "refused: NewerSchema",
"durable_state_verified": true,
"backup_restore_result": Value::Null,
"observed_schema_version": installed,
"database": DATABASE,
"reached_schema_3_by":
"upgrading a seeded schema-2 database with this crate's migrator",
"runtime": {
"revision": probe.revision,
"supported_schema_version": observed.supported,
"built_from": "a worktree of the last revision before schema 3 was added",
"probe": "tests/fixtures/upgrade/schema-2-runtime/probe.rs",
},
"runtime_repository_open": observed.repository_open.rendered.clone(),
"runtime_migrator_run": observed.migrator_run.rendered.clone(),
"failed_closed": true,
"durable_state_unchanged": true,
"rows_compared": before.counts(),
"applied_migrations_unchanged": true,
"current_runtime_still_opens_it": true,
"violations": Vec::<String>::new(),
"passed": true,
}],
"execution_manifest": execution_manifest()?,
"violations": Vec::<String>::new(),
"passed": true,
}),
)?;
Ok(())
}
async fn applied_migrations(url: &str) -> Result<Vec<(i64, String)>, Box<dyn Error>> {
use sqlx::Row;
use sqlx::postgres::PgPoolOptions;
let pool = PgPoolOptions::new().max_connections(1).connect(url).await?;
let rows = sqlx::query(
"SELECT version, description FROM oxide_batch._sqlx_migrations ORDER BY version",
)
.fetch_all(&pool)
.await?;
let mut applied = Vec::new();
for row in &rows {
applied.push((row.try_get::<i64, _>(0)?, row.try_get::<String, _>(1)?));
}
pool.close().await;
Ok(applied)
}
struct Observed {
supported: u64,
repository_open: Attempt,
migrator_run: Attempt,
}
struct Attempt {
accepted: bool,
error: Option<String>,
observed: Option<u64>,
supported: Option<u64>,
rendered: Value,
}
fn probe_report(probe: &ProbeRun) -> Result<Observed, Box<dyn Error>> {
let supported = probe
.report
.get("supported_schema_version")
.and_then(Value::as_u64)
.ok_or_else(|| Failure("the probe reported no supported schema version".to_owned()))?;
Ok(Observed {
supported,
repository_open: attempt(&probe.report, "repository_open")?,
migrator_run: attempt(&probe.report, "migrator_run")?,
})
}
fn attempt(report: &Value, name: &str) -> Result<Attempt, Box<dyn Error>> {
let entry = report
.get(name)
.ok_or_else(|| Failure(format!("the probe reported no {name}")))?;
Ok(Attempt {
accepted: entry
.get("accepted")
.and_then(Value::as_bool)
.ok_or_else(|| Failure(format!("the probe's {name} says nothing about acceptance")))?,
error: entry
.get("error")
.and_then(Value::as_str)
.map(str::to_owned),
observed: entry.get("observed_schema_version").and_then(Value::as_u64),
supported: entry
.get("supported_schema_version")
.and_then(Value::as_u64),
rendered: entry.clone(),
})
}