merge-request: harden latest schema migration

This commit is contained in:
2026-08-16 18:02:47 +09:00
parent 22c3d1f7c9
commit b2422da98a
4 changed files with 934 additions and 416 deletions
File diff suppressed because it is too large Load Diff
+53 -67
View File
@@ -345,76 +345,24 @@ fn bounded_context_rejects_oversized_revision_evidence() {
} }
#[test] #[test]
fn rejected_v6_schema_missing_diff_digest_is_archived_before_fresh_v7() { fn v6_legacy_schema_fails_closed_without_archiving() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("legacy.db"); let path = dir.path().join("legacy.db");
let conn = Connection::open(&path).unwrap(); let conn = Connection::open(&path).unwrap();
conn.execute_batch( conn.execute_batch(
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\ "CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\
INSERT INTO merge_request_schema_migrations(version,name) VALUES(6,'rejected_merge_request_v6');\ INSERT INTO merge_request_schema_migrations(version,name) VALUES(6,'rejected_merge_request_v6');\
CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id));\ CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL,lifecycle_generation INTEGER NOT NULL,current_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id));",
CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));\
CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id));\
CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL,lifecycle_generation INTEGER NOT NULL,current_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id));\
CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id));\
CREATE TABLE merge_request_revisions(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,revision_id TEXT NOT NULL,ordinal INTEGER NOT NULL,base_commit TEXT NOT NULL,head_commit TEXT NOT NULL,head_tree TEXT NOT NULL,assignment_id TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,revision_id));",
).unwrap();
drop(conn);
let store = SqliteMergeRequestStore::open(&path, "ws-a").unwrap();
assert!(store.show_for_ticket("missing").unwrap().is_none());
let conn = Connection::open(&path).unwrap();
let archived: i64 = conn.query_row("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='legacy_v6_merge_requests'",[],|row|row.get(0)).unwrap();
assert_eq!(archived, 1);
for table in [
"merge_request_review_attempts",
"merge_request_completion_operations",
] {
let present: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
params![table],
|row| row.get(0),
)
.unwrap();
assert_eq!(present, 1);
}
}
#[test]
fn v7_completion_operations_are_preserved_as_legacy_assigned_coder_authority() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v7.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\
INSERT INTO merge_request_schema_migrations(version,name) VALUES(7,'fresh_bounded_context_authority');\
CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id));\
CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));\
INSERT INTO typed_tickets VALUES('ws-a','T1','done',1,'t');\
CREATE TABLE merge_request_completion_operations(workspace_id TEXT NOT NULL,operation_id TEXT NOT NULL,ticket_id TEXT NOT NULL,revision_id TEXT NOT NULL,assignment_id TEXT NOT NULL,fingerprint TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN ('pending','completed')),result_ticket_state TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,operation_id),FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id));\
INSERT INTO merge_request_completion_operations VALUES('ws-a','legacy-op','T1','V1','A1','legacy-fingerprint','completed','done','t','t');",
).unwrap(); ).unwrap();
drop(conn); drop(conn);
SqliteMergeRequestStore::open(&path, "ws-a").unwrap(); let error = SqliteMergeRequestStore::open(&path, "ws-a").unwrap_err();
let conn = Connection::open(&path).unwrap(); assert!(
let row: (String, String, Option<String>, Option<String>, String) = conn error
.query_row( .to_string()
"SELECT authority_kind,implementation_assignment_id,completion_actor_runtime_id,completion_actor_worker_id,fingerprint FROM merge_request_completion_operations WHERE operation_id='legacy-op'", .contains("unsupported legacy merge request schema version 6")
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
)
.unwrap();
assert_eq!(
row,
(
"legacy_assigned_coder".into(),
"A1".into(),
None,
None,
"legacy-fingerprint".into()
)
); );
let conn = Connection::open(&path).unwrap();
let version: i64 = conn let version: i64 = conn
.query_row( .query_row(
"SELECT MAX(version) FROM merge_request_schema_migrations", "SELECT MAX(version) FROM merge_request_schema_migrations",
@@ -422,23 +370,61 @@ fn v7_completion_operations_are_preserved_as_legacy_assigned_coder_authority() {
|row| row.get(0), |row| row.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(version, 9); assert_eq!(version, 6);
let head_tree_columns: i64 = conn let original: i64 = conn
.query_row( .query_row(
"SELECT COUNT(*) FROM pragma_table_info('merge_request_revisions') WHERE name='head_tree'", "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='merge_requests'",
[], [],
|row| row.get(0), |row| row.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(head_tree_columns, 0); let archived: i64 = conn
let merge_result_table: i64 = conn
.query_row( .query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='merge_request_merge_results'", "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE 'legacy_v6_%'",
[], [],
|row| row.get(0), |row| row.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(merge_result_table, 1); assert_eq!(original, 1);
assert_eq!(archived, 0);
}
#[test]
fn v7_schema_is_rejected_without_mutating_completion_evidence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v7.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\
INSERT INTO merge_request_schema_migrations(version,name) VALUES(7,'fresh_bounded_context_authority');\
CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));\
INSERT INTO typed_tickets VALUES('ws-a','T1','done',1,'t');\
CREATE TABLE merge_request_completion_operations(workspace_id TEXT NOT NULL,operation_id TEXT NOT NULL,ticket_id TEXT NOT NULL,revision_id TEXT NOT NULL,assignment_id TEXT NOT NULL,fingerprint TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN ('pending','completed')),result_ticket_state TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,operation_id),FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id));\
INSERT INTO merge_request_completion_operations VALUES('ws-a','legacy-op','T1','V1','A1','legacy-fingerprint','completed','done','t','t');",
).unwrap();
drop(conn);
let error = SqliteMergeRequestStore::open(&path, "ws-a").unwrap_err();
assert!(
error
.to_string()
.contains("unsupported legacy merge request schema version 7")
);
let conn = Connection::open(&path).unwrap();
let row: (String, String) = conn.query_row(
"SELECT assignment_id,fingerprint FROM merge_request_completion_operations WHERE operation_id='legacy-op'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
).unwrap();
assert_eq!(row, ("A1".into(), "legacy-fingerprint".into()));
let version: i64 = conn
.query_row(
"SELECT MAX(version) FROM merge_request_schema_migrations",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(version, 7);
} }
#[test] #[test]
+2 -1
View File
@@ -17013,8 +17013,9 @@ mod tests {
); );
assert!(!default_root.starts_with(workspace_root.join(".yoi"))); assert!(!default_root.starts_with(workspace_root.join(".yoi")));
let config = ServerConfig::local_dev(workspace_root, test_identity()) let mut config = ServerConfig::local_dev(workspace_root, test_identity())
.with_embedded_runtime_store_root(default_root.clone()); .with_embedded_runtime_store_root(default_root.clone());
config.database_path = ServerConfig::server_database_path_for_data_dir(&data_dir);
let app = build_router( let app = build_router(
WorkspaceApi::new_with_execution_backend( WorkspaceApi::new_with_execution_backend(
config, config,
@@ -42,3 +42,9 @@ Schema authority changes used by live typed tools need generation fencing:
3. MR tool failures should report the running Server generation and observed schema version/columns in bounded diagnostics. 3. MR tool failures should report the running Server generation and observed schema version/columns in bounded diagnostics.
4. Deployment should atomically sequence migration and executable activation, or preserve a deliberate compatibility reader during the transition; a stale binary must never continue serving writes after the incompatible migration commits. 4. Deployment should atomically sequence migration and executable activation, or preserve a deliberate compatibility reader during the transition; a stale binary must never continue serving writes after the incompatible migration commits.
5. Coder review handoff should remain fail-closed, as it did here; it must not bypass immutable MR authority or fabricate review evidence when MR creation is unavailable. 5. Coder review handoff should remain fail-closed, as it did here; it must not bypass immutable MR authority or fabricate review evidence when MR creation is unavailable.
## Resolution and follow-up
The deployment/bootstrap path was subsequently corrected and the user restarted the dogfood control plane. The live schema-generation blocker cleared without the Coder restarting its own Server or Runtime. The attempted revision evidence above is historical incident evidence only and is not a current MR candidate.
Work then resumed on the assigned Coder branch under the Ticket's stricter migration contract: fresh databases materialize one latest v9 baseline, only an exact v8 schema may migrate automatically, older/unversioned/drifted states fail closed, and v8-to-v9 DDL, evidence copy, foreign-key validation, and marker advancement commit atomically. Final Git and MR evidence is recorded separately on the Ticket and immutable MR revision.