fix: migration error
This commit is contained in:
@@ -1220,6 +1220,15 @@ impl SqliteWorkspaceStore {
|
|||||||
"materialize {assignment_worker_tombstone_repairs} legacy Ticket assignment Worker tombstone(s)"
|
"materialize {assignment_worker_tombstone_repairs} legacy Ticket assignment Worker tombstone(s)"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
apply_migrations_through(&candidate, 42)?;
|
||||||
|
if current_schema_version < 43 {
|
||||||
|
let stale_current = repairable_legacy_current_ticket_assignment_count(&candidate)?;
|
||||||
|
if stale_current > 0 {
|
||||||
|
repairs.push(format!(
|
||||||
|
"clear {stale_current} tombstoned legacy current Ticket assignment pointer(s)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
apply_migrations_through(&candidate, i64::MAX)?;
|
apply_migrations_through(&candidate, i64::MAX)?;
|
||||||
ticket::migrate_sqlite_ticket_schema(&candidate)?;
|
ticket::migrate_sqlite_ticket_schema(&candidate)?;
|
||||||
merge_request::migrate(&candidate).map_err(|error| Error::Store(error.to_string()))?;
|
merge_request::migrate(&candidate).map_err(|error| Error::Store(error.to_string()))?;
|
||||||
@@ -7517,10 +7526,20 @@ fn create_workspace_memory_settings_authority(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generalize_ticket_role_assignments(conn: &Connection) -> Result<()> {
|
fn repairable_legacy_current_ticket_assignment_count(conn: &Connection) -> Result<i64> {
|
||||||
let invalid_current: i64 = conn.query_row(
|
let (stale_current, unclassified_current): (i64, i64) = conn.query_row(
|
||||||
r#"
|
r#"
|
||||||
SELECT COUNT(*)
|
SELECT
|
||||||
|
COALESCE(SUM(CASE
|
||||||
|
WHEN ticket.ticket_id IS NULL OR worker.worker_id IS NULL THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END), 0),
|
||||||
|
COALESCE(SUM(CASE
|
||||||
|
WHEN (ticket.ticket_id IS NULL AND ticket_tombstone.ticket_id IS NULL)
|
||||||
|
OR (worker.worker_id IS NULL AND worker_tombstone.worker_id IS NULL)
|
||||||
|
THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END), 0)
|
||||||
FROM ticket_current_worker_assignments AS current
|
FROM ticket_current_worker_assignments AS current
|
||||||
LEFT JOIN typed_tickets AS ticket
|
LEFT JOIN typed_tickets AS ticket
|
||||||
ON ticket.workspace_id = current.workspace_id
|
ON ticket.workspace_id = current.workspace_id
|
||||||
@@ -7529,16 +7548,51 @@ fn generalize_ticket_role_assignments(conn: &Connection) -> Result<()> {
|
|||||||
ON worker.workspace_id = current.workspace_id
|
ON worker.workspace_id = current.workspace_id
|
||||||
AND worker.runtime_id = current.runtime_id
|
AND worker.runtime_id = current.runtime_id
|
||||||
AND worker.worker_id = current.worker_id
|
AND worker.worker_id = current.worker_id
|
||||||
WHERE ticket.ticket_id IS NULL OR worker.worker_id IS NULL
|
LEFT JOIN ticket_assignment_ticket_tombstones AS ticket_tombstone
|
||||||
|
ON ticket_tombstone.workspace_id = current.workspace_id
|
||||||
|
AND ticket_tombstone.ticket_id = current.ticket_id
|
||||||
|
LEFT JOIN ticket_assignment_worker_tombstones AS worker_tombstone
|
||||||
|
ON worker_tombstone.workspace_id = current.workspace_id
|
||||||
|
AND worker_tombstone.runtime_id = current.runtime_id
|
||||||
|
AND worker_tombstone.worker_id = current.worker_id
|
||||||
"#,
|
"#,
|
||||||
[],
|
[],
|
||||||
|row| row.get(0),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)?;
|
)?;
|
||||||
if invalid_current != 0 {
|
if unclassified_current != 0 {
|
||||||
return Err(Error::Store(format!(
|
return Err(Error::Store(format!(
|
||||||
"migration 43 cannot classify {invalid_current} legacy current Ticket assignment(s) as valid Coder Worker principals"
|
"migration 43 cannot repair {unclassified_current} legacy current Ticket assignment(s) whose missing Ticket or Worker has no matching tombstone"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
Ok(stale_current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generalize_ticket_role_assignments(conn: &Connection) -> Result<()> {
|
||||||
|
let stale_current = repairable_legacy_current_ticket_assignment_count(conn)?;
|
||||||
|
if stale_current > 0 {
|
||||||
|
let cleared = conn.execute(
|
||||||
|
r#"
|
||||||
|
DELETE FROM ticket_current_worker_assignments AS current
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM typed_tickets AS ticket
|
||||||
|
WHERE ticket.workspace_id = current.workspace_id
|
||||||
|
AND ticket.ticket_id = current.ticket_id
|
||||||
|
)
|
||||||
|
OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM worker_registry AS worker
|
||||||
|
WHERE worker.workspace_id = current.workspace_id
|
||||||
|
AND worker.runtime_id = current.runtime_id
|
||||||
|
AND worker.worker_id = current.worker_id
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
if i64::try_from(cleared).ok() != Some(stale_current) {
|
||||||
|
return Err(Error::Store(format!(
|
||||||
|
"migration 43 classified {stale_current} stale current Ticket assignment(s) but cleared {cleared}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
@@ -7574,6 +7628,7 @@ fn generalize_ticket_role_assignments(conn: &Connection) -> Result<()> {
|
|||||||
assigned_by TEXT NOT NULL,
|
assigned_by TEXT NOT NULL,
|
||||||
assigned_at TEXT NOT NULL,
|
assigned_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (workspace_id, assignment_id),
|
PRIMARY KEY (workspace_id, assignment_id),
|
||||||
|
UNIQUE (workspace_id, ticket_id, assignment_id),
|
||||||
UNIQUE (workspace_id, ticket_id, role, assignment_id),
|
UNIQUE (workspace_id, ticket_id, role, assignment_id),
|
||||||
UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id),
|
UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id),
|
||||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
@@ -8621,6 +8676,58 @@ pub(crate) fn apply_migrations_through(conn: &Connection, through_version: i64)
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if migration.version == 43 {
|
||||||
|
// Other in-database authorities may reference the assignment history table. Keep
|
||||||
|
// those foreign keys on the canonical table name while rebuilding its role-aware
|
||||||
|
// schema, then validate the complete database before restoring FK enforcement.
|
||||||
|
conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?;
|
||||||
|
let result = (|| -> Result<()> {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
(migration.apply)(&tx)?;
|
||||||
|
let dangling_reference: Option<(String, String)> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT name, sql FROM sqlite_schema \
|
||||||
|
WHERE sql LIKE '%ticket_worker_assignments_v43%' LIMIT 1",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some((object, sql)) = dangling_reference {
|
||||||
|
return Err(Error::Store(format!(
|
||||||
|
"migration 43 left a temporary Ticket assignment reference in `{object}`: {sql}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let foreign_key_failures: i64 = tx
|
||||||
|
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.map_err(|error| {
|
||||||
|
Error::Store(format!(
|
||||||
|
"migration 43 could not evaluate foreign keys: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if foreign_key_failures != 0 {
|
||||||
|
return Err(Error::Store(format!(
|
||||||
|
"migration 43 found {foreign_key_failures} foreign key violation(s)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||||
|
params![migration.version, migration.name],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;")
|
||||||
|
.map_err(|error| {
|
||||||
|
Error::Store(format!(
|
||||||
|
"migration 43 could not restore FK enforcement: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
result?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
if migration.version == 37 {
|
if migration.version == 37 {
|
||||||
crate::retention::repair_worker_diagnostics_archive_table(&tx)?;
|
crate::retention::repair_worker_diagnostics_archive_table(&tx)?;
|
||||||
@@ -10493,6 +10600,166 @@ INSERT INTO worker_registry (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_v43_clears_tombstoned_legacy_current_assignment_pointers() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("legacy-v42.db");
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
configure_sqlite(&conn).unwrap();
|
||||||
|
apply_migrations_through(&conn, 42).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
INSERT INTO workspaces (
|
||||||
|
workspace_id, display_name, state, created_at, updated_at
|
||||||
|
) VALUES ('workspace-legacy', 'Legacy', 'active', '2026-09-01', '2026-09-01');
|
||||||
|
INSERT INTO typed_tickets (
|
||||||
|
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||||
|
workflow_state, workflow_state_explicit
|
||||||
|
) VALUES
|
||||||
|
('workspace-legacy', 'ticket-deleted-worker', 'deleted-worker',
|
||||||
|
'Deleted Worker', 'open', 'task', 'normal', '', 'in_progress', 1),
|
||||||
|
('workspace-legacy', 'ticket-deleted-ticket', 'deleted-ticket',
|
||||||
|
'Deleted Ticket', 'open', 'task', 'normal', '', 'in_progress', 1);
|
||||||
|
INSERT INTO worker_registry (
|
||||||
|
workspace_id, runtime_id, worker_id, display_name, retention_state,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES
|
||||||
|
('workspace-legacy', 'runtime-legacy',
|
||||||
|
'00000000-0000-7000-8000-000000000001', 'Deleted Worker', 'normal',
|
||||||
|
'2026-09-01', '2026-09-01'),
|
||||||
|
('workspace-legacy', 'runtime-legacy',
|
||||||
|
'00000000-0000-7000-8000-000000000002', 'Retained Worker', 'normal',
|
||||||
|
'2026-09-01', '2026-09-01');
|
||||||
|
INSERT INTO ticket_worker_assignments (
|
||||||
|
workspace_id, ticket_id, assignment_id, runtime_id, worker_id,
|
||||||
|
assigned_by, assigned_at
|
||||||
|
) VALUES
|
||||||
|
('workspace-legacy', 'ticket-deleted-worker', 'assignment-deleted-worker',
|
||||||
|
'runtime-legacy', '00000000-0000-7000-8000-000000000001',
|
||||||
|
'legacy', '2026-09-01'),
|
||||||
|
('workspace-legacy', 'ticket-deleted-ticket', 'assignment-deleted-ticket',
|
||||||
|
'runtime-legacy', '00000000-0000-7000-8000-000000000002',
|
||||||
|
'legacy', '2026-09-01');
|
||||||
|
INSERT INTO ticket_current_worker_assignments (
|
||||||
|
workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at
|
||||||
|
) VALUES
|
||||||
|
('workspace-legacy', 'ticket-deleted-worker', 'assignment-deleted-worker',
|
||||||
|
'runtime-legacy', '00000000-0000-7000-8000-000000000001', '2026-09-01'),
|
||||||
|
('workspace-legacy', 'ticket-deleted-ticket', 'assignment-deleted-ticket',
|
||||||
|
'runtime-legacy', '00000000-0000-7000-8000-000000000002', '2026-09-01');
|
||||||
|
INSERT INTO ticket_worker_assignment_events (
|
||||||
|
workspace_id, ticket_id, event_id, action, assignment_id, actor, created_at
|
||||||
|
) VALUES
|
||||||
|
('workspace-legacy', 'ticket-deleted-worker', 'event-deleted-worker',
|
||||||
|
'assigned', 'assignment-deleted-worker', 'legacy', '2026-09-01'),
|
||||||
|
('workspace-legacy', 'ticket-deleted-ticket', 'event-deleted-ticket',
|
||||||
|
'assigned', 'assignment-deleted-ticket', 'legacy', '2026-09-01');
|
||||||
|
CREATE TABLE legacy_assignment_consumer (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
assignment_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (workspace_id, ticket_id, assignment_id)
|
||||||
|
REFERENCES ticket_worker_assignments (
|
||||||
|
workspace_id, ticket_id, assignment_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
INSERT INTO legacy_assignment_consumer
|
||||||
|
VALUES ('workspace-legacy', 'ticket-deleted-worker', 'assignment-deleted-worker');
|
||||||
|
DELETE FROM worker_registry
|
||||||
|
WHERE workspace_id = 'workspace-legacy'
|
||||||
|
AND worker_id = '00000000-0000-7000-8000-000000000001';
|
||||||
|
DELETE FROM typed_tickets
|
||||||
|
WHERE workspace_id = 'workspace-legacy'
|
||||||
|
AND ticket_id = 'ticket-deleted-ticket';
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM ticket_current_worker_assignments",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
let plan = SqliteWorkspaceStore::migration_plan(&db_path).unwrap();
|
||||||
|
assert!(plan.repairs.iter().any(|repair| {
|
||||||
|
repair == "clear 2 tombstoned legacy current Ticket assignment pointer(s)"
|
||||||
|
}));
|
||||||
|
|
||||||
|
let migrated = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||||
|
migrated
|
||||||
|
.with_conn(|conn| {
|
||||||
|
assert_eq!(current_schema_version(conn)?, 43);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?,
|
||||||
|
1,
|
||||||
|
"migration must restore foreign key enforcement"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM ticket_current_worker_assignments",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM ticket_worker_assignments WHERE role = 'coder'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?,
|
||||||
|
2,
|
||||||
|
"migration must retain assignment history"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM ticket_worker_assignment_events WHERE role = 'coder'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?,
|
||||||
|
2,
|
||||||
|
"migration must retain assignment events"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM ticket_assignment_worker_tombstones",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM ticket_assignment_ticket_tombstones",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM legacy_assignment_consumer",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?,
|
||||||
|
1,
|
||||||
|
"migration must preserve external assignment history references"
|
||||||
|
);
|
||||||
|
let foreign_key_failures: i64 =
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})?;
|
||||||
|
assert_eq!(foreign_key_failures, 0);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn role_assignment_routes_and_manual_start_are_state_fenced_atomically() {
|
async fn role_assignment_routes_and_manual_start_are_state_fenced_atomically() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user