Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions crates/buzz-db/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations");
/// Run all pending Buzz database migrations.
pub async fn run_migrations(pool: &PgPool) -> Result<()> {
reject_legacy_nip_rs_cardinality_ambiguity(pool).await?;
refresh_replica_heartbeat_migration_checksum(pool).await?;
MIGRATOR.run(pool).await?;
// The replica-fence proof (see `replica_fence`) requires the commit-time
// `created_at` floor trigger from migration 0021 — correctly shaped — on
Expand All @@ -25,6 +26,71 @@ pub async fn run_migrations(pool: &PgPool) -> Result<()> {
Ok(())
}

/// Migration 0026 originally used non-idempotent DDL. After Postgres failover
/// during the migration transaction, the `replica_heartbeat` table can exist
/// while `_sqlx_migrations` has no version-26 row — every boot re-runs 0026 and
/// crash-loops on `CREATE TABLE`. The SQL is now idempotent; refresh the stored
/// checksum on brownfield relays so sqlx accepts the updated file.
async fn refresh_replica_heartbeat_migration_checksum(pool: &PgPool) -> Result<()> {
const VERSION: i64 = 26;
let migrations_table: Option<String> =
sqlx::query_scalar("SELECT to_regclass('_sqlx_migrations')::text")
.fetch_one(pool)
.await?;
if migrations_table.is_none() {
return Ok(());
}

let embedded = MIGRATOR
.iter()
.find(|migration| migration.version == VERSION)
.ok_or_else(|| {
crate::DbError::InvalidData(format!("embedded migration {VERSION} missing"))
})?;

let stored: Option<Vec<u8>> =
sqlx::query_scalar("SELECT checksum FROM _sqlx_migrations WHERE version = $1 AND success")
.bind(VERSION)
.fetch_optional(pool)
.await?;

let Some(stored) = stored else {
return Ok(());
};

if stored == embedded.checksum.as_ref() {
return Ok(());
}

let table_ready: bool = sqlx::query_scalar(
"SELECT EXISTS (\
SELECT 1 FROM replica_heartbeat WHERE id = 1\
) AND EXISTS (\
SELECT 1 FROM _operator_global_tables WHERE table_name = 'replica_heartbeat'\
)",
)
.fetch_one(pool)
.await?;

if !table_ready {
return Err(crate::DbError::InvalidData(format!(
"migration {VERSION} checksum drifted but replica_heartbeat is incomplete — manual repair required"
)));
}

sqlx::query("UPDATE _sqlx_migrations SET checksum = $1 WHERE version = $2 AND success")
.bind(embedded.checksum.as_ref())
.bind(VERSION)
.execute(pool)
.await?;

tracing::info!(
version = VERSION,
"refreshed _sqlx_migrations checksum after idempotent replica_heartbeat rewrite"
);
Ok(())
}

/// Migration 0007 is checksum-frozen and predates exact NIP-RS tag-cardinality
/// enforcement. A populated database still on 0001-0006 must not let 0007
/// irreversibly purge duplicate-tag history. Fail before sqlx starts its
Expand Down Expand Up @@ -914,10 +980,11 @@ mod tests {
// the routing proof.
assert_eq!(migrations[25].version, 26);
let heartbeat = migrations[25].sql.as_str();
assert!(heartbeat.contains("CREATE TABLE replica_heartbeat"));
assert!(heartbeat.contains("CREATE TABLE IF NOT EXISTS replica_heartbeat"));
assert!(heartbeat.contains("CHECK (id = 1)"));
assert!(heartbeat.contains("epoch"));
assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1)"));
assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1) ON CONFLICT (id) DO NOTHING"));
assert!(heartbeat.contains("ON CONFLICT (table_name) DO NOTHING"));
assert!(heartbeat.contains("_operator_global_tables"));
}

Expand Down
7 changes: 4 additions & 3 deletions migrations/0026_replica_heartbeat.sql
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@
-- and deliberately deployment-global (no community_id) — it describes the
-- replication topology, not tenant data.

CREATE TABLE replica_heartbeat (
CREATE TABLE IF NOT EXISTS replica_heartbeat (
id smallint PRIMARY KEY CHECK (id = 1),
epoch uuid NOT NULL DEFAULT gen_random_uuid(),
token bigint NOT NULL DEFAULT 0
);

INSERT INTO replica_heartbeat (id) VALUES (1);
INSERT INTO replica_heartbeat (id) VALUES (1) ON CONFLICT (id) DO NOTHING;

INSERT INTO _operator_global_tables (table_name, reason) VALUES
('replica_heartbeat', 'single-row replication freshness token; describes deployment topology, never tenant data');
('replica_heartbeat', 'single-row replication freshness token; describes deployment topology, never tenant data')
ON CONFLICT (table_name) DO NOTHING;
7 changes: 4 additions & 3 deletions schema/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1061,13 +1061,14 @@ INSERT INTO _operator_global_tables (table_name, reason) VALUES
-- coverage. Deployment-global by design: describes replication topology,
-- never tenant data.

CREATE TABLE replica_heartbeat (
CREATE TABLE IF NOT EXISTS replica_heartbeat (
id smallint PRIMARY KEY CHECK (id = 1),
epoch uuid NOT NULL DEFAULT gen_random_uuid(),
token bigint NOT NULL DEFAULT 0
);

INSERT INTO replica_heartbeat (id) VALUES (1);
INSERT INTO replica_heartbeat (id) VALUES (1) ON CONFLICT (id) DO NOTHING;

INSERT INTO _operator_global_tables (table_name, reason) VALUES
('replica_heartbeat', 'single-row replication freshness token; describes deployment topology, never tenant data');
('replica_heartbeat', 'single-row replication freshness token; describes deployment topology, never tenant data')
ON CONFLICT (table_name) DO NOTHING;