fix(#2279): idempotent column-guard migrations, drop invalid ADD COLUMN IF NOT EXISTS

This commit is contained in:
damocles 2026-07-10 16:51:50 +02:00 committed by mara
commit dfbf198ef3
5 changed files with 188 additions and 121 deletions

View file

@ -10,6 +10,8 @@ use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use hive_sh4re::wire_time::now_unix;
use hive_sh4re::{InboxRow, Message};
use crate::db::Migration;
use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize;
use tokio::sync::broadcast;
@ -157,52 +159,60 @@ pub struct Broker {
}
/// Ordered schema migrations for the broker. Tracked in `schema_versions`
/// under key `"broker"`. Legacy databases (fully migrated via the old
/// per-column check approach) are detected via the `priority` column on
/// `messages` — the last column added before versioning.
const BROKER_MIGRATIONS: &[&str] = &[
/// under key `"broker"`. Each migration declares the column it adds, so a
/// legacy DB (fully or partially migrated via the old per-column approach)
/// is converged by skipping the migrations whose column already exists.
const BROKER_MIGRATIONS: &[Migration] = &[
// v1: acked_at on messages, with backfill so existing delivered rows
// are not phantom-requeued on the next open.
//
// IF NOT EXISTS: safe to run on a partially-migrated legacy DB where
// acked_at already exists but priority (the legacy marker) does not.
// The BEGIN/COMMIT block makes ALTER + UPDATE atomic — if the UPDATE
// fails the version counter is not bumped and the whole step retries.
"BEGIN;\
ALTER TABLE messages ADD COLUMN IF NOT EXISTS acked_at INTEGER;\
// are not phantom-requeued on the next open. The BEGIN/COMMIT block
// makes ALTER + UPDATE atomic — either both land or neither, so the
// guard column (acked_at) faithfully marks the whole step as done.
Migration {
sql: "BEGIN;\
ALTER TABLE messages ADD COLUMN acked_at INTEGER;\
UPDATE messages SET acked_at = delivered_at \
WHERE delivered_at IS NOT NULL AND acked_at IS NULL;\
COMMIT;",
adds_column: Some(("messages", "acked_at")),
},
// v2: in_reply_to for thread-parent tracking. NULL = root of a thread.
"ALTER TABLE messages ADD COLUMN IF NOT EXISTS in_reply_to INTEGER",
Migration {
sql: "ALTER TABLE messages ADD COLUMN in_reply_to INTEGER",
adds_column: Some(("messages", "in_reply_to")),
},
// v3: priority for operator-message fast-path. Rebuild the delivery
// index to include priority as a secondary sort key.
"BEGIN;\
ALTER TABLE messages ADD COLUMN IF NOT EXISTS \
// index to include priority as a secondary sort key. Atomic BEGIN/COMMIT
// so priority existing implies the index rebuild also committed.
Migration {
sql: "BEGIN;\
ALTER TABLE messages ADD COLUMN \
priority INTEGER NOT NULL DEFAULT 0;\
DROP INDEX IF EXISTS idx_messages_undelivered;\
CREATE INDEX IF NOT EXISTS idx_messages_undelivered \
ON messages (recipient, priority DESC, id) WHERE delivered_at IS NULL;\
COMMIT;",
adds_column: Some(("messages", "priority")),
},
// v4: attempt_count on reminders for the MAX_REMINDER_ATTEMPTS cap.
"ALTER TABLE reminders ADD COLUMN IF NOT EXISTS \
Migration {
sql: "ALTER TABLE reminders ADD COLUMN \
attempt_count INTEGER NOT NULL DEFAULT 0",
adds_column: Some(("reminders", "attempt_count")),
},
// v5: last_error on reminders — last delivery failure surfaced on the
// dashboard so a stuck reminder is visible without digging in logs.
"ALTER TABLE reminders ADD COLUMN IF NOT EXISTS last_error TEXT",
Migration {
sql: "ALTER TABLE reminders ADD COLUMN last_error TEXT",
adds_column: Some(("reminders", "last_error")),
},
];
impl Broker {
pub fn open(path: &Path) -> Result<Self> {
let conn = crate::db::open(path, "broker")?;
conn.execute_batch(SCHEMA).context("apply broker schema")?;
crate::db::apply_versioned_migrations(
&conn,
"broker",
("messages", "priority"),
BROKER_MIGRATIONS,
)
.context("broker migrations")?;
crate::db::apply_versioned_migrations(&conn, "broker", BROKER_MIGRATIONS)
.context("broker migrations")?;
let (events, _) = broadcast::channel(EVENT_CHANNEL);
Ok(Self {
conn: Mutex::new(conn),