use std::fmt;
use tokio_postgres::Transaction;
use crate::desired_state::Checksum;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Migration {
pub version: i32,
pub name: &'static str,
pub sql: &'static str,
}
impl Migration {
pub fn checksum(&self) -> Checksum {
Checksum::of(self.sql.as_bytes())
}
}
pub const MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "control_plane_0001_initial",
sql: include_str!("../../../sql/control_plane_0001_initial.sql"),
}];
pub fn required_version() -> i32 {
MIGRATIONS
.last()
.expect("at least one migration ships")
.version
}
pub const MINIMUM_SERVER_VERSION_NUM: i32 = 140_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaStatus {
Absent,
Unrecorded,
Current {
version: i32,
},
Behind {
applied: i32,
required: i32,
},
Ahead {
applied: i32,
required: i32,
},
Drifted {
version: i32,
expected: Checksum,
found: Checksum,
},
Incomplete {
applied: i32,
missing: Vec<i32>,
},
Renamed {
version: i32,
expected: &'static str,
found: String,
},
Malformed {
message: String,
},
}
impl SchemaStatus {
pub fn is_current(&self) -> bool {
matches!(self, Self::Current { .. })
}
pub fn is_migratable(&self) -> bool {
matches!(self, Self::Absent | Self::Behind { .. })
}
}
impl fmt::Display for SchemaStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Absent => write!(
f,
"the control-plane schema is not present; run `axond migrate apply` (or apply \
ops/postgres/control_plane_0001_initial.sql)"
),
Self::Unrecorded => write!(
f,
"`{MIGRATION_TABLE}` exists but records no migrations, so this build cannot tell \
whether the schema it describes was ever applied and will not migrate from \
zero over objects that may already exist; if nothing was applied, drop the empty \
`{MIGRATION_TABLE}` table and run `axond migrate apply`, and if the DDL was \
applied by hand, record the baseline it corresponds to ({}) and re-run",
MIGRATIONS
.iter()
.map(|migration| format!(
"INSERT INTO {MIGRATION_TABLE} (version, name, checksum) VALUES ({}, '{}', \
'{}')",
migration.version,
migration.name,
migration.checksum()
))
.collect::<Vec<_>>()
.join("; ")
),
Self::Current { version } => write!(f, "control-plane schema v{version} is current"),
Self::Behind { applied, required } => write!(
f,
"control-plane schema is v{applied}, but this build requires v{required}; run \
`axond migrate apply` before starting replicas"
),
Self::Ahead { applied, required } => write!(
f,
"control-plane schema is v{applied}, which is newer than the v{required} this \
build knows; a newer gateway owns this database"
),
Self::Drifted {
version,
expected,
found,
} => write!(
f,
"control-plane migration v{version} was applied as {found}, but this build ships \
{expected}; an applied migration was edited in place"
),
Self::Incomplete { applied, missing } => write!(
f,
"control-plane schema records v{applied} but is missing {}; the applied versions \
are not a complete history, so this build cannot tell what the database contains",
missing
.iter()
.map(|version| format!("v{version}"))
.collect::<Vec<_>>()
.join(", ")
),
Self::Renamed {
version,
expected,
found,
} => write!(
f,
"control-plane migration v{version} is recorded as `{found}`, but this build ships \
v{version} as `{expected}`; a migration was renumbered or renamed rather than \
added"
),
Self::Malformed { message } => write!(
f,
"the control-plane migration ledger is not the one this build writes: {message}"
),
}
}
}
const MIGRATION_TABLE: &str = "axond_cp_schema_migration";
pub(super) async fn status(
transaction: &Transaction<'_>,
) -> Result<SchemaStatus, tokio_postgres::Error> {
let present: Option<String> = transaction
.query_one("SELECT to_regclass($1)::text", &[&MIGRATION_TABLE])
.await?
.get(0);
if present.is_none() {
return Ok(SchemaStatus::Absent);
}
let rows = match transaction
.query(
&format!("SELECT version, name, checksum FROM {MIGRATION_TABLE} ORDER BY version"),
&[],
)
.await
{
Ok(rows) => rows,
Err(error) if is_schema_disagreement(&error) => {
return Ok(SchemaStatus::Malformed {
message: format!(
"reading `{MIGRATION_TABLE}` as (version, name, checksum) failed: {error}"
),
});
}
Err(error) => return Err(error),
};
let mut recorded = Vec::with_capacity(rows.len());
for row in &rows {
let decoded = row
.try_get(0)
.and_then(|version| {
Ok(Recorded {
version,
name: row.try_get(1)?,
checksum: row.try_get(2)?,
})
})
.map_err(|error| format!("`{MIGRATION_TABLE}` holds a row this build cannot read as (version integer, name text, checksum text): {error}"));
match decoded {
Ok(row) => recorded.push(row),
Err(message) => return Ok(SchemaStatus::Malformed { message }),
}
}
Ok(classify(&recorded))
}
fn is_schema_disagreement(error: &tokio_postgres::Error) -> bool {
error
.code()
.is_some_and(|code| code.code().starts_with("42"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Recorded {
pub version: i32,
pub name: String,
pub checksum: String,
}
fn classify(recorded: &[Recorded]) -> SchemaStatus {
let required = required_version();
if let Some(row) = recorded.iter().find(|row| row.version < 1) {
return SchemaStatus::Malformed {
message: format!(
"v{} is recorded, but migration versions start at 1",
row.version
),
};
}
let mut versions: Vec<i32> = recorded.iter().map(|row| row.version).collect();
versions.sort_unstable();
versions.dedup();
if versions.len() != recorded.len() {
return SchemaStatus::Malformed {
message: "a version is recorded more than once, so the ledger's primary key is not \
the one this build writes"
.to_owned(),
};
}
let Some(applied) = versions.last().copied() else {
return SchemaStatus::Unrecorded;
};
for row in recorded {
let Some(migration) = MIGRATIONS.iter().find(|m| m.version == row.version) else {
return SchemaStatus::Ahead {
applied: row.version,
required,
};
};
if row.name != migration.name {
return SchemaStatus::Renamed {
version: row.version,
expected: migration.name,
found: row.name.clone(),
};
}
let expected = migration.checksum();
if row.checksum != expected.to_string() {
return SchemaStatus::Drifted {
version: row.version,
expected,
found: Checksum::parse(&row.checksum).unwrap_or(expected),
};
}
}
let missing: Vec<i32> = (1..=applied)
.filter(|version| !versions.contains(version))
.collect();
if !missing.is_empty() {
return SchemaStatus::Incomplete { applied, missing };
}
match applied.cmp(&required) {
std::cmp::Ordering::Equal => SchemaStatus::Current { version: applied },
std::cmp::Ordering::Less => SchemaStatus::Behind { applied, required },
std::cmp::Ordering::Greater => SchemaStatus::Ahead { applied, required },
}
}
pub fn pending(from: &SchemaStatus) -> Vec<i32> {
let applied = match from {
SchemaStatus::Absent => 0,
SchemaStatus::Behind { applied, .. } => *applied,
SchemaStatus::Current { .. }
| SchemaStatus::Unrecorded
| SchemaStatus::Ahead { .. }
| SchemaStatus::Drifted { .. }
| SchemaStatus::Incomplete { .. }
| SchemaStatus::Renamed { .. }
| SchemaStatus::Malformed { .. } => return Vec::new(),
};
MIGRATIONS
.iter()
.filter(|migration| migration.version > applied)
.map(|migration| migration.version)
.collect()
}
pub(super) async fn migrate(
transaction: &Transaction<'_>,
from: &SchemaStatus,
) -> Result<(), tokio_postgres::Error> {
let applied = match from {
SchemaStatus::Behind { applied, .. } => *applied,
_ => 0,
};
for migration in MIGRATIONS.iter().filter(|m| m.version > applied) {
transaction.batch_execute(migration.sql).await?;
transaction
.execute(
&format!(
"INSERT INTO {MIGRATION_TABLE} (version, name, checksum) VALUES ($1, $2, $3) \
ON CONFLICT (version) DO NOTHING"
),
&[
&migration.version,
&migration.name,
&migration.checksum().to_string(),
],
)
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn recorded(version: i32) -> Recorded {
let migration = MIGRATIONS
.iter()
.find(|m| m.version == version)
.expect("shipped migration");
Recorded {
version,
name: migration.name.to_owned(),
checksum: migration.checksum().to_string(),
}
}
fn foreign(version: i32, name: &str, checksum: &str) -> Recorded {
Recorded {
version,
name: name.to_owned(),
checksum: checksum.to_owned(),
}
}
#[test]
fn migrations_are_gapless_and_never_reordered() {
for (index, migration) in MIGRATIONS.iter().enumerate() {
assert_eq!(
migration.version,
i32::try_from(index + 1).expect("small"),
"migrations are numbered from 1 without gaps, so `applied` is a version count"
);
assert!(
migration.name.starts_with("control_plane_"),
"a migration's name is its shipped file's stem"
);
assert!(!migration.sql.trim().is_empty());
}
assert_eq!(required_version(), MIGRATIONS.len() as i32);
}
#[test]
fn an_unrecorded_or_partial_history_is_not_current_and_a_complete_one_is() {
assert_eq!(classify(&[]), SchemaStatus::Unrecorded);
let complete: Vec<_> = MIGRATIONS.iter().map(|m| recorded(m.version)).collect();
assert_eq!(
classify(&complete),
SchemaStatus::Current {
version: required_version()
}
);
assert!(classify(&complete).is_current());
}
#[test]
fn an_unknown_version_is_ahead_and_never_migratable() {
let status = classify(&[
recorded(1),
foreign(
99,
"control_plane_0099_future",
&Checksum::of(b"newer").to_string(),
),
]);
assert_eq!(
status,
SchemaStatus::Ahead {
applied: 99,
required: required_version()
}
);
assert!(!status.is_migratable());
assert!(status.to_string().contains("newer gateway"));
}
#[test]
fn an_edited_applied_migration_is_drift_rather_than_a_matching_version() {
let status = classify(&[foreign(
1,
MIGRATIONS[0].name,
&Checksum::of(b"edited in place").to_string(),
)]);
let SchemaStatus::Drifted {
version,
expected,
found,
} = status.clone()
else {
panic!("an edited migration must be reported as drift, got {status:?}");
};
assert_eq!(version, 1);
assert_eq!(expected, MIGRATIONS[0].checksum());
assert_eq!(found, Checksum::of(b"edited in place"));
assert!(!status.is_migratable());
assert!(!status.is_current());
}
#[test]
fn a_renamed_migration_is_reported_as_a_rename_even_when_its_text_matches() {
let mut row = recorded(1);
row.name = "control_plane_0001_initial_v2".to_owned();
let status = classify(&[row]);
assert_eq!(
status,
SchemaStatus::Renamed {
version: 1,
expected: MIGRATIONS[0].name,
found: "control_plane_0001_initial_v2".to_owned(),
}
);
assert!(
!status.is_migratable(),
"a version this build ships under another name is not a history it can extend"
);
assert!(status.to_string().contains("renumbered or renamed"));
}
#[test]
fn a_hole_in_the_applied_prefix_is_incomplete_rather_than_current_or_behind() {
let status = classify(&[foreign(
2,
"control_plane_0002_later",
&Checksum::of(b"later").to_string(),
)]);
assert_eq!(
status,
SchemaStatus::Ahead {
applied: 2,
required: required_version()
},
"this build ships one migration, so v2 is a future version before it is a hole"
);
let versions = [2];
let missing: Vec<i32> = (1..=2)
.filter(|version| !versions.contains(version))
.collect();
let status = SchemaStatus::Incomplete {
applied: 2,
missing,
};
assert!(!status.is_migratable());
assert!(!status.is_current());
assert!(status.to_string().contains("missing v1"), "{status}");
}
#[test]
fn a_ledger_that_is_not_this_ledger_is_malformed_rather_than_behind() {
let duplicated = classify(&[recorded(1), recorded(1)]);
assert!(
matches!(duplicated, SchemaStatus::Malformed { .. }),
"two rows for one version is not a history: {duplicated:?}"
);
assert!(!duplicated.is_migratable());
let zeroed = classify(&[foreign(0, "control_plane_0000", "sha256:0")]);
assert!(
matches!(zeroed, SchemaStatus::Malformed { .. }),
"versions start at 1: {zeroed:?}"
);
assert!(
zeroed.to_string().contains("not the one this build writes"),
"{zeroed}"
);
}
#[test]
fn an_empty_ledger_is_refused_while_an_absent_one_pends_every_shipped_version() {
let empty = classify(&[]);
assert_eq!(empty, SchemaStatus::Unrecorded);
assert!(
!empty.is_migratable() && !empty.is_current(),
"an empty ledger must not be migrated from zero: {empty:?}"
);
assert!(
pending(&empty).is_empty(),
"nothing is pending against an empty ledger, or an apply would replay every file"
);
let rendered = empty.to_string();
for expected in [
"records no migrations",
"drop the empty",
"INSERT INTO axond_cp_schema_migration",
&MIGRATIONS[0].checksum().to_string(),
] {
assert!(
rendered.contains(expected),
"the refusal has to name the action to take, missing `{expected}`: {rendered}"
);
}
assert_eq!(
pending(&SchemaStatus::Absent),
MIGRATIONS.iter().map(|m| m.version).collect::<Vec<_>>()
);
for refused in [
SchemaStatus::Unrecorded,
SchemaStatus::Ahead {
applied: 9,
required: 1,
},
SchemaStatus::Drifted {
version: 1,
expected: MIGRATIONS[0].checksum(),
found: Checksum::of(b"edited"),
},
SchemaStatus::Current { version: 1 },
] {
assert!(
pending(&refused).is_empty(),
"nothing is pending against {refused:?}: an apply must not write there"
);
}
}
#[test]
fn the_shipped_ddl_is_the_migration_this_build_applies() {
let ddl = MIGRATIONS[0].sql;
for object in [
"axond_cp_schema_migration",
"axond_cp_blob",
"axond_cp_resource_version",
"axond_cp_resource_dependency",
"axond_cp_mutation",
"axond_cp_revision",
"axond_cp_revision_entry",
"axond_cp_revision_blob",
"axond_cp_audit_event",
"axond_cp_idempotency",
"axond_cp_head",
] {
assert!(
ddl.contains(&format!("CREATE TABLE IF NOT EXISTS {object}")),
"the journal's {object} table is missing from the shipped DDL"
);
}
}
}