fix(#2279): versioned DB migrations via schema_versions table
Replace the try-and-ignore-duplicate-column approach in apply_migrations with proper schema versioning using a shared schema_versions table. ## mechanism New function: db::apply_versioned_migrations(conn, subsystem, legacy_column, migrations). Tracks the applied-migration count in a schema_versions table (one row per subsystem key). Only migrations past the stored version run. Legacy detection: pre-versioning databases have no schema_versions row. The legacy_column tuple (table, column) identifies a column that exists only in a fully-migrated legacy database. If present, all known migrations are skipped. If absent, migrations start from 0. ## stores migrated - broker: removes bespoke ensure_message_columns / ensure_reminder_columns. Unified into BROKER_MIGRATIONS (v1-v5). Legacy detector: messages.priority (added in the last pre-versioning migration). - approvals: 4 historical migrations (v1-v4). Legacy detector: approvals.submitter. - operator_questions: 3 historical migrations (v1-v3). Legacy detector: operator_questions.target. - scheduled_prompts: 1 historical migration (v1). Legacy detector: scheduled_prompts.paused_at_unix. apply_migrations removed (no callers). ## tests (db.rs) - fresh_install_runs_all_migrations - legacy_install_skips_all_migrations - partial_migration_resumes_from_version - already_at_latest_is_noop - multiple_stores_in_same_db
This commit is contained in:
parent
01db1af346
commit
7d36ec5e1f
5 changed files with 333 additions and 115 deletions
|
|
@ -156,12 +156,42 @@ pub struct Broker {
|
|||
inflight: Mutex<HashMap<String, RecipientInflight>>,
|
||||
}
|
||||
|
||||
/// 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] = &[
|
||||
// v1: acked_at on messages, with backfill so existing delivered rows
|
||||
// are not phantom-requeued on the next open. The multi-statement
|
||||
// batch runs atomically (execute_batch wraps in a transaction).
|
||||
"ALTER TABLE messages ADD COLUMN acked_at INTEGER;\
|
||||
UPDATE messages SET acked_at = delivered_at WHERE delivered_at IS NOT NULL;",
|
||||
// v2: in_reply_to for thread-parent tracking. NULL = root of a thread.
|
||||
"ALTER TABLE messages ADD COLUMN in_reply_to INTEGER",
|
||||
// v3: priority for operator-message fast-path. Rebuild the delivery
|
||||
// index to include priority as a secondary sort key.
|
||||
"ALTER TABLE messages ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;\
|
||||
DROP INDEX IF EXISTS idx_messages_undelivered;\
|
||||
CREATE INDEX idx_messages_undelivered \
|
||||
ON messages (recipient, priority DESC, id) WHERE delivered_at IS NULL",
|
||||
// v4: attempt_count on reminders for the MAX_REMINDER_ATTEMPTS cap.
|
||||
"ALTER TABLE reminders ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 0",
|
||||
// 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 last_error TEXT",
|
||||
];
|
||||
|
||||
impl Broker {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = crate::db::open(path, "broker")?;
|
||||
conn.execute_batch(SCHEMA).context("apply broker schema")?;
|
||||
ensure_message_columns(&conn).context("migrate messages columns")?;
|
||||
ensure_reminder_columns(&conn).context("migrate reminders columns")?;
|
||||
crate::db::apply_versioned_migrations(
|
||||
&conn,
|
||||
"broker",
|
||||
("messages", "priority"),
|
||||
BROKER_MIGRATIONS,
|
||||
)
|
||||
.context("broker migrations")?;
|
||||
let (events, _) = broadcast::channel(EVENT_CHANNEL);
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
|
|
@ -1108,65 +1138,6 @@ impl Broker {
|
|||
/// back-fills it for every already-delivered row, so the
|
||||
/// pre-migration sessions count as "fully handled" and won't be
|
||||
/// resurfaced by the first `requeue_inflight` after upgrade.
|
||||
fn ensure_message_columns(conn: &Connection) -> Result<()> {
|
||||
let has_acked: bool = conn
|
||||
.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = 'acked_at'")?
|
||||
.exists([])?;
|
||||
if !has_acked {
|
||||
conn.execute_batch("ALTER TABLE messages ADD COLUMN acked_at INTEGER;")
|
||||
.context("add messages.acked_at column")?;
|
||||
// Backfill: treat every existing delivered row as acked. The
|
||||
// session it was delivered to is gone, so requeue would just
|
||||
// surface phantom traffic to whatever harness reads next.
|
||||
conn.execute(
|
||||
"UPDATE messages SET acked_at = delivered_at \
|
||||
WHERE delivered_at IS NOT NULL AND acked_at IS NULL",
|
||||
[],
|
||||
)
|
||||
.context("backfill messages.acked_at from delivered_at")?;
|
||||
}
|
||||
let has_reply: bool = conn
|
||||
.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = 'in_reply_to'")?
|
||||
.exists([])?;
|
||||
if !has_reply {
|
||||
conn.execute_batch("ALTER TABLE messages ADD COLUMN in_reply_to INTEGER;")
|
||||
.context("add messages.in_reply_to column")?;
|
||||
// No backfill needed — existing messages simply have NULL here,
|
||||
// meaning "root of a new thread", which is correct.
|
||||
}
|
||||
let has_priority: bool = conn
|
||||
.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = 'priority'")?
|
||||
.exists([])?;
|
||||
if !has_priority {
|
||||
// Add column + rebuild the index with priority as the second key so
|
||||
// recv_batch can serve operator messages ahead of queued wakes without
|
||||
// a separate sort pass.
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE messages ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
|
||||
DROP INDEX IF EXISTS idx_messages_undelivered;
|
||||
CREATE INDEX idx_messages_undelivered
|
||||
ON messages (recipient, priority DESC, id) WHERE delivered_at IS NULL;",
|
||||
)
|
||||
.context("add messages.priority column and update delivery index")?;
|
||||
// No backfill needed — all existing rows are correctly at priority 0.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Idempotent reminder-table migrations — plain additive columns, via
|
||||
/// `db::apply_migrations`. (The messages-table migration above stays
|
||||
/// bespoke: its backfill must run only when `acked_at` was just
|
||||
/// created, which try-and-ignore can't express.)
|
||||
fn ensure_reminder_columns(conn: &Connection) -> Result<()> {
|
||||
crate::db::apply_migrations(
|
||||
conn,
|
||||
"broker reminders",
|
||||
&[
|
||||
"ALTER TABLE reminders ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE reminders ADD COLUMN last_error TEXT",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
|||
Loading…
Reference in a new issue