fix: fence runtime removal races and retries

This commit is contained in:
2026-09-13 02:54:01 +09:00
parent 6c609808c9
commit d2cb50d081
6 changed files with 416 additions and 21 deletions
@@ -1107,6 +1107,150 @@ BEGIN
SELECT RAISE(ABORT, 'runtime_removal_in_progress'); SELECT RAISE(ABORT, 'runtime_removal_in_progress');
END; END;
CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_registry FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_registry FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal
BEFORE INSERT ON workdir_registry FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal
BEFORE UPDATE ON workdir_registry FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_assignment_insert_blocked_by_runtime_removal
BEFORE INSERT ON ticket_current_worker_assignments FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_assignment_update_blocked_by_runtime_removal
BEFORE UPDATE ON ticket_current_worker_assignments FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_workdir_links FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_workdir_links FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_create_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_create_reservations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_create_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_create_reservations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
BEFORE INSERT ON workdir_create_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.resolved_runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal
BEFORE UPDATE ON workdir_create_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.resolved_runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_removal_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_removal_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_removal_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_removal_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_removal_insert_blocked_by_runtime_removal
BEFORE INSERT ON workdir_removal_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_removal_update_blocked_by_runtime_removal
BEFORE UPDATE ON workdir_removal_operations FOR EACH ROW
WHEN EXISTS (
SELECT 1 FROM runtime_removal_operations operation
WHERE operation.runtime_id = NEW.runtime_id
AND operation.state IN ('pending', 'cleanup_pending')
)
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TABLE workspace_deletion_operations ( CREATE TABLE workspace_deletion_operations (
operation_id TEXT PRIMARY KEY, operation_id TEXT PRIMARY KEY,
request_fingerprint TEXT NOT NULL, request_fingerprint TEXT NOT NULL,
+184 -10
View File
@@ -9587,7 +9587,8 @@ fn verify_canonical_workspace_runtime_binding(binding: WorkspaceRuntimeBinding)
} }
fn migrate_runtime_removal_operations_v59_to_v60(conn: &Connection) -> Result<()> { fn migrate_runtime_removal_operations_v59_to_v60(conn: &Connection) -> Result<()> {
conn.execute_batch( let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
tx.execute_batch(
r#"CREATE TABLE runtime_removal_operations ( r#"CREATE TABLE runtime_removal_operations (
operation_id TEXT PRIMARY KEY, operation_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
@@ -9632,13 +9633,82 @@ fn migrate_runtime_removal_operations_v59_to_v60(conn: &Connection) -> Result<()
SELECT RAISE(ABORT, 'runtime_removal_in_progress'); SELECT RAISE(ABORT, 'runtime_removal_in_progress');
END;"#, END;"#,
)?; )?;
conn.execute( tx.execute_batch(
r#"
CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_registry FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_registry FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal
BEFORE INSERT ON workdir_registry FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal
BEFORE UPDATE ON workdir_registry FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_assignment_insert_blocked_by_runtime_removal
BEFORE INSERT ON ticket_current_worker_assignments FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_assignment_update_blocked_by_runtime_removal
BEFORE UPDATE ON ticket_current_worker_assignments FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_workdir_links FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_workdir_links FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_create_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_create_reservations FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_create_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_create_reservations FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
BEFORE INSERT ON workdir_create_operations FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.resolved_runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal
BEFORE UPDATE ON workdir_create_operations FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.resolved_runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_removal_insert_blocked_by_runtime_removal
BEFORE INSERT ON worker_removal_operations FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER worker_removal_update_blocked_by_runtime_removal
BEFORE UPDATE ON worker_removal_operations FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_removal_insert_blocked_by_runtime_removal
BEFORE INSERT ON workdir_removal_operations FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
CREATE TRIGGER workdir_removal_update_blocked_by_runtime_removal
BEFORE UPDATE ON workdir_removal_operations FOR EACH ROW
WHEN EXISTS (SELECT 1 FROM runtime_removal_operations operation WHERE operation.runtime_id = NEW.runtime_id AND operation.state IN ('pending', 'cleanup_pending'))
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
"#,
)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![ params![
LATEST_SCHEMA_VERSION, LATEST_SCHEMA_VERSION,
RUNTIME_REMOVAL_OPERATION_MIGRATION_NAME RUNTIME_REMOVAL_OPERATION_MIGRATION_NAME
], ],
)?; )?;
tx.commit()?;
Ok(()) Ok(())
} }
@@ -10438,7 +10508,7 @@ mod tests {
#[test] #[test]
fn runtime_removal_rejects_shared_binding_and_fences_new_binding_until_cleanup() { fn runtime_removal_rejects_shared_binding_and_fences_new_binding_until_cleanup() {
let (_temp, store) = runtime_removal_test_store(); let (temp, store) = runtime_removal_test_store();
store store
.with_conn(|conn| { .with_conn(|conn| {
conn.execute_batch( conn.execute_batch(
@@ -10500,8 +10570,11 @@ mod tests {
1, 1,
) )
.unwrap(); .unwrap();
let blocked_insert = store.with_conn(|conn| { let competing_conn = Connection::open(temp.path().join("server.db")).unwrap();
conn.execute( competing_conn
.execute_batch("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;")
.unwrap();
let blocked_insert = competing_conn.execute(
"INSERT INTO workspace_runtime_bindings(\ "INSERT INTO workspace_runtime_bindings(\
workspace_id, runtime_id, display_name, base_url, public_key, \ workspace_id, runtime_id, display_name, base_url, public_key, \
public_key_fingerprint, binding_revision, state, authentication_mode, \ public_key_fingerprint, binding_revision, state, authentication_mode, \
@@ -10511,15 +10584,36 @@ mod tests {
'key-a', 'fingerprint-b', 1, 'verified', 'legacy_server_issuer', '1', '1'\ 'key-a', 'fingerprint-b', 1, 'verified', 'legacy_server_issuer', '1', '1'\
)", )",
[], [],
)?; );
Ok(())
});
assert!( assert!(
blocked_insert blocked_insert
.unwrap_err() .unwrap_err()
.to_string() .to_string()
.contains("runtime_removal_in_progress") .contains("runtime_removal_in_progress")
); );
for competing_mutation in [
"INSERT INTO worker_registry(\
workspace_id, worker_id, runtime_id, display_name, retention_state, created_at, updated_at\
) VALUES ('workspace-a', 'racing-worker', 'runtime-a', 'Racing Worker', 'normal', '2', '2')",
"INSERT INTO worker_create_reservations(\
workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint, state, created_at, updated_at\
) VALUES ('workspace-a', 'racing-allocation', 'racing-worker', 'runtime-a', 'racing-create', 'reserved', '2', '2')",
"INSERT INTO workdir_create_operations(\
workspace_id, operation_id, request_fingerprint, repository_id, resolved_runtime_id, \
config_revision, config_projection_digest, working_directory_id, state, created_at, updated_at\
) VALUES (\
'workspace-a', 'racing-workdir-create', 'racing-request', 'repository-a', 'runtime-a', \
1, 'racing-projection', 'racing-workdir', 'pending', '2', '2'\
)",
] {
let error = competing_conn.execute(competing_mutation, []).unwrap_err();
assert!(
error.to_string().contains("runtime_removal_in_progress"),
"competing Runtime resource mutation was not fenced: {error}"
);
}
assert_runtime_removal_binding_unchanged(&store);
} }
#[test] #[test]
@@ -10602,6 +10696,22 @@ mod tests {
conn.execute_batch( conn.execute_batch(
"DROP TRIGGER runtime_binding_insert_blocked_by_removal; \ "DROP TRIGGER runtime_binding_insert_blocked_by_removal; \
DROP TRIGGER runtime_binding_update_blocked_by_removal; \ DROP TRIGGER runtime_binding_update_blocked_by_removal; \
DROP TRIGGER worker_registry_insert_blocked_by_runtime_removal; \
DROP TRIGGER worker_registry_update_blocked_by_runtime_removal; \
DROP TRIGGER workdir_registry_insert_blocked_by_runtime_removal; \
DROP TRIGGER workdir_registry_update_blocked_by_runtime_removal; \
DROP TRIGGER worker_assignment_insert_blocked_by_runtime_removal; \
DROP TRIGGER worker_assignment_update_blocked_by_runtime_removal; \
DROP TRIGGER workdir_attachment_insert_blocked_by_runtime_removal; \
DROP TRIGGER workdir_attachment_update_blocked_by_runtime_removal; \
DROP TRIGGER worker_create_insert_blocked_by_runtime_removal; \
DROP TRIGGER worker_create_update_blocked_by_runtime_removal; \
DROP TRIGGER workdir_create_insert_blocked_by_runtime_removal; \
DROP TRIGGER workdir_create_update_blocked_by_runtime_removal; \
DROP TRIGGER worker_removal_insert_blocked_by_runtime_removal; \
DROP TRIGGER worker_removal_update_blocked_by_runtime_removal; \
DROP TRIGGER workdir_removal_insert_blocked_by_runtime_removal; \
DROP TRIGGER workdir_removal_update_blocked_by_runtime_removal; \
DROP TABLE runtime_removal_operations; \ DROP TABLE runtime_removal_operations; \
DELETE FROM __yoi_schema_migrations; \ DELETE FROM __yoi_schema_migrations; \
INSERT INTO __yoi_schema_migrations(version, name) \ INSERT INTO __yoi_schema_migrations(version, name) \
@@ -10623,7 +10733,10 @@ mod tests {
)?; )?;
let trigger_count: i64 = conn.query_row( let trigger_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master \ "SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'trigger' AND name LIKE 'runtime_binding_%_blocked_by_removal'", WHERE type = 'trigger' AND (\
name LIKE '%_blocked_by_runtime_removal' OR \
name LIKE 'runtime_binding_%_blocked_by_removal'\
)",
[], [],
|row| row.get(0), |row| row.get(0),
)?; )?;
@@ -10632,13 +10745,58 @@ mod tests {
row.get(0) row.get(0)
})?; })?;
assert_eq!(table_count, 1); assert_eq!(table_count, 1);
assert_eq!(trigger_count, 2); assert_eq!(trigger_count, 18);
assert_eq!(foreign_key_failures, 0); assert_eq!(foreign_key_failures, 0);
Ok(()) Ok(())
}) })
.unwrap(); .unwrap();
} }
#[test]
fn schema_v59_runtime_removal_migration_rolls_back_partial_ddl_and_marker() {
let temp = tempfile::tempdir().unwrap();
let store = SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap();
store
.with_conn(|conn| {
let trigger_names = {
let mut stmt = conn.prepare(
"SELECT name FROM sqlite_master \
WHERE type = 'trigger' AND sql LIKE '%runtime_removal_operations%'",
)?;
stmt.query_map([], |row| row.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?
};
for trigger_name in trigger_names {
conn.execute_batch(&format!("DROP TRIGGER \"{trigger_name}\";"))?;
}
conn.execute_batch(
"DROP TABLE runtime_removal_operations; \
DELETE FROM __yoi_schema_migrations WHERE version = 60; \
CREATE INDEX runtime_removal_operations_one_active_runtime \
ON workspace_runtime_bindings(runtime_id);",
)?;
let migration_error = migrate_runtime_removal_operations_v59_to_v60(conn)
.expect_err("injected index-name collision must fail migration");
assert!(migration_error.to_string().contains("already exists"));
let table_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'table' AND name = 'runtime_removal_operations'",
[],
|row| row.get(0),
)?;
let marker_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM __yoi_schema_migrations WHERE version = 60",
[],
|row| row.get(0),
)?;
assert_eq!(table_count, 0, "partial Runtime removal DDL leaked");
assert_eq!(marker_count, 0, "failed migration advanced schema marker");
Ok(())
})
.unwrap();
}
#[test] #[test]
fn current_schema_accepts_every_retained_canonical_provenance() { fn current_schema_accepts_every_retained_canonical_provenance() {
for baseline_version in OLDEST_SCHEMA_VERSION..=LATEST_SCHEMA_VERSION { for baseline_version in OLDEST_SCHEMA_VERSION..=LATEST_SCHEMA_VERSION {
@@ -10707,6 +10865,22 @@ mod tests {
DROP TABLE workdir_create_credential_candidates; DROP TABLE workdir_create_credential_candidates;
DROP TRIGGER runtime_binding_insert_blocked_by_removal; DROP TRIGGER runtime_binding_insert_blocked_by_removal;
DROP TRIGGER runtime_binding_update_blocked_by_removal; DROP TRIGGER runtime_binding_update_blocked_by_removal;
DROP TRIGGER worker_registry_insert_blocked_by_runtime_removal;
DROP TRIGGER worker_registry_update_blocked_by_runtime_removal;
DROP TRIGGER workdir_registry_insert_blocked_by_runtime_removal;
DROP TRIGGER workdir_registry_update_blocked_by_runtime_removal;
DROP TRIGGER worker_assignment_insert_blocked_by_runtime_removal;
DROP TRIGGER worker_assignment_update_blocked_by_runtime_removal;
DROP TRIGGER workdir_attachment_insert_blocked_by_runtime_removal;
DROP TRIGGER workdir_attachment_update_blocked_by_runtime_removal;
DROP TRIGGER worker_create_insert_blocked_by_runtime_removal;
DROP TRIGGER worker_create_update_blocked_by_runtime_removal;
DROP TRIGGER workdir_create_insert_blocked_by_runtime_removal;
DROP TRIGGER workdir_create_update_blocked_by_runtime_removal;
DROP TRIGGER worker_removal_insert_blocked_by_runtime_removal;
DROP TRIGGER worker_removal_update_blocked_by_runtime_removal;
DROP TRIGGER workdir_removal_insert_blocked_by_runtime_removal;
DROP TRIGGER workdir_removal_update_blocked_by_runtime_removal;
DROP TABLE runtime_removal_operations; DROP TABLE runtime_removal_operations;
DROP INDEX workspace_signing_identity_audit_workspace_idx; DROP INDEX workspace_signing_identity_audit_workspace_idx;
DROP TABLE workspace_signing_identity_audit; DROP TABLE workspace_signing_identity_audit;
@@ -1026,6 +1026,23 @@ export async function updateRemoteRuntime(
return finishMutation(response, workspaceId, runtimeId); return finishMutation(response, workspaceId, runtimeId);
} }
export class RuntimeRemovalAttempt {
#operationId: string | null = null;
operationId(create: () => string = () => crypto.randomUUID()): string {
if (this.#operationId === null) this.#operationId = create();
return this.#operationId;
}
complete(operationId: string): void {
if (this.#operationId === operationId) this.#operationId = null;
}
reset(): void {
this.#operationId = null;
}
}
export async function removeRemoteRuntime( export async function removeRemoteRuntime(
workspaceId: string, workspaceId: string,
runtimeId: string, runtimeId: string,
@@ -13,6 +13,7 @@
updateRemoteRuntime, updateRemoteRuntime,
RuntimeTrustConflictError, RuntimeTrustConflictError,
RuntimeTrustRouteFence, RuntimeTrustRouteFence,
RuntimeRemovalAttempt,
RuntimeTrustRequestError, RuntimeTrustRequestError,
type RuntimeTrustRouteOperation, type RuntimeTrustRouteOperation,
} from '$lib/workspace/api/runtime-management'; } from '$lib/workspace/api/runtime-management';
@@ -37,6 +38,7 @@
let replacementFingerprintError = $state<string | null>(null); let replacementFingerprintError = $state<string | null>(null);
let fingerprintGeneration = 0; let fingerprintGeneration = 0;
const routeFence = new RuntimeTrustRouteFence(); const routeFence = new RuntimeTrustRouteFence();
const runtimeRemovalAttempt = new RuntimeRemovalAttempt();
let routeGeneration = 0; let routeGeneration = 0;
$effect(() => { $effect(() => {
@@ -51,6 +53,7 @@
endpoint = data.runtimeDetail?.endpoint ?? ''; endpoint = data.runtimeDetail?.endpoint ?? '';
editingMetadata = false; editingMetadata = false;
deleteRuntimeConfirmation = ''; deleteRuntimeConfirmation = '';
runtimeRemovalAttempt.reset();
busyAction = null; busyAction = null;
fieldError = null; fieldError = null;
deleteRuntimeError = null; deleteRuntimeError = null;
@@ -290,6 +293,7 @@
deleteRuntimeError = 'The authoritative Runtime binding revision is unavailable. Reload before removal.'; deleteRuntimeError = 'The authoritative Runtime binding revision is unavailable. Reload before removal.';
return; return;
} }
const operationId = runtimeRemovalAttempt.operationId();
busyAction = 'delete'; busyAction = 'delete';
deleteRuntimeError = null; deleteRuntimeError = null;
try { try {
@@ -297,11 +301,12 @@
data.workspaceId, data.workspaceId,
routeOperation.runtimeId, routeOperation.runtimeId,
{ {
operation_id: crypto.randomUUID(), operation_id: operationId,
expected_binding_revision: trust.revision, expected_binding_revision: trust.revision,
}, },
); );
if (!isCurrentRoute(routeOperation)) return; if (!isCurrentRoute(routeOperation)) return;
runtimeRemovalAttempt.complete(operationId);
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, { await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
replaceState: true, replaceState: true,
}); });
@@ -178,7 +178,9 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.", "Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
"await revokeRuntimeTrustKey(", "await revokeRuntimeTrustKey(",
"await removeRemoteRuntime(", "await removeRemoteRuntime(",
"operation_id: crypto.randomUUID()", "new RuntimeRemovalAttempt()",
"runtimeRemovalAttempt.operationId()",
"operation_id: operationId",
"expected_binding_revision: trust.revision", "expected_binding_revision: trust.revision",
"The Backend removes Workspace trust and this Runtime registration as one guarded operation.", "The Backend removes Workspace trust and this Runtime registration as one guarded operation.",
"deleteRuntimeConfirmation.trim() !== data.runtimeId", "deleteRuntimeConfirmation.trim() !== data.runtimeId",
@@ -12,6 +12,7 @@ import {
previewRuntimePublicKeyFingerprint, previewRuntimePublicKeyFingerprint,
removeRemoteRuntime, removeRemoteRuntime,
revokeRuntimeTrustKey, revokeRuntimeTrustKey,
RuntimeRemovalAttempt,
RuntimeTrustConflictError, RuntimeTrustConflictError,
RuntimeTrustRouteFence, RuntimeTrustRouteFence,
updateRemoteRuntime, updateRemoteRuntime,
@@ -314,6 +315,58 @@ Deno.test("Runtime metadata update never sends public key authority", async () =
assert(!("public_key" in body), "metadata update sent public_key"); assert(!("public_key" in body), "metadata update sent public_key");
}); });
Deno.test("Runtime removal attempt retains its id across response-loss retry", async () => {
const attempt = new RuntimeRemovalAttempt();
const operationIds: string[] = [];
let calls = 0;
const submit = async () => {
const operationId = attempt.operationId(() => "stable-removal-operation");
operationIds.push(operationId);
const operation = await removeRemoteRuntime(
"workspace-a",
"runtime-a",
{ operation_id: operationId, expected_binding_revision: 4 },
() => {
calls += 1;
if (calls === 1) return Promise.reject(new Error("response lost"));
return Promise.resolve(Response.json({
operation_id: operationId,
workspace_id: "workspace-a",
runtime_id: "runtime-a",
state: "succeeded",
binding_removed: true,
runtime_registration_removed: true,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:01Z",
completed_at: "2026-01-01T00:00:01Z",
}));
},
);
attempt.complete(operation.operation_id);
};
try {
await submit();
throw new Error("expected response-loss retry to fail");
} catch (error) {
assert(
error instanceof Error && error.message.includes("response lost"),
`unexpected response-loss error: ${String(error)}`,
);
}
await submit();
assert(
operationIds.length === 2 &&
operationIds.every((id) => id === "stable-removal-operation"),
`response-loss retry changed operation id: ${operationIds.join(",")}`,
);
assert(
attempt.operationId(() => "next-removal-operation") ===
"next-removal-operation",
"authoritative success did not clear the completed operation id",
);
});
Deno.test("Runtime removal uses the Workspace-scoped operation route", async () => { Deno.test("Runtime removal uses the Workspace-scoped operation route", async () => {
let requestedUrl = ""; let requestedUrl = "";
let requestedMethod = ""; let requestedMethod = "";