fix(db): make task checkout atomic
This commit is contained in:
1 parent
dffeccf3e2
commit
0785c9ba47
8 files changed
+329
-128
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { Migration, MigrationDeps } from "./types";
|
||||
import { nowIso, tableExists } from "./types";
|
||||
|
||||
export function createMigrationsV53V53(deps: MigrationDeps): Migration[] {
|
||||
const { db } = deps;
|
||||
return [
|
||||
{
|
||||
version: 53,
|
||||
name: "task_leases_restore_run_fk",
|
||||
fn: () => {
|
||||
if (!tableExists(db, "task_leases")) {
|
||||
log.info("[migration v53] task_leases table missing; nothing to do");
|
||||
return;
|
||||
}
|
||||
|
||||
const danglingLeases = db.prepare(
|
||||
`SELECT l.task_id AS taskId, l.run_id AS runId
|
||||
FROM task_leases l
|
||||
LEFT JOIN runs r ON r.id = l.run_id
|
||||
WHERE r.id IS NULL`,
|
||||
).all() as Array<{ taskId: string; runId: string }>;
|
||||
|
||||
if (danglingLeases.length > 0) {
|
||||
const ts = nowIso();
|
||||
const repairTask = db.prepare(
|
||||
`UPDATE tasks
|
||||
SET active_run_id = CASE WHEN active_run_id = ? THEN NULL ELSE active_run_id END,
|
||||
checkout_run_id = CASE WHEN checkout_run_id = ? THEN NULL ELSE checkout_run_id END,
|
||||
execution_locked_at = CASE
|
||||
WHEN active_run_id = ? OR checkout_run_id = ? THEN NULL
|
||||
ELSE execution_locked_at
|
||||
END,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
);
|
||||
for (const lease of danglingLeases) {
|
||||
repairTask.run(lease.runId, lease.runId, lease.runId, lease.runId, ts, lease.taskId);
|
||||
}
|
||||
db.prepare(
|
||||
`DELETE FROM task_leases
|
||||
WHERE run_id NOT IN (SELECT id FROM runs)`,
|
||||
).run();
|
||||
log.warn(
|
||||
`[migration v53] removed ${danglingLeases.length} dangling task lease(s) before restoring run_id FK`,
|
||||
);
|
||||
}
|
||||
|
||||
db.exec("PRAGMA defer_foreign_keys = ON");
|
||||
db.exec(`
|
||||
CREATE TABLE task_leases_v53 (
|
||||
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
||||
acquired_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
checkout_run_id TEXT,
|
||||
UNIQUE(task_id, run_id)
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO task_leases_v53
|
||||
(task_id, run_id, acquired_at, expires_at, checkout_run_id)
|
||||
SELECT task_id, run_id, acquired_at, expires_at, checkout_run_id
|
||||
FROM task_leases
|
||||
`);
|
||||
db.exec("DROP INDEX IF EXISTS idx_task_leases_expires_at");
|
||||
db.exec("DROP TABLE task_leases");
|
||||
db.exec("ALTER TABLE task_leases_v53 RENAME TO task_leases");
|
||||
db.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_leases_expires_at ON task_leases(expires_at)",
|
||||
);
|
||||
|
||||
log.info("[migration v53] task_leases.run_id FK restored");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -7,6 +7,13 @@ import { type ReadAll, type Transaction, nowIso, toBoolean } from "./helpers";
|
||||
import { buildRunLogMethods } from "./run-logs";
|
||||
import { buildRunEventMethods } from "./run-events";
|
||||
import { buildCostEntryMethods } from "./cost-entries";
|
||||
import { checkoutTaskInTransaction } from "./task-checkout";
|
||||
|
||||
class TaskCheckoutConflict extends Error {
|
||||
constructor(readonly taskId: string) {
|
||||
super(`Task checkout conflict: ${taskId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRunMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
@@ -16,6 +23,33 @@ export function buildRunMethods(
|
||||
logsDir: string;
|
||||
},
|
||||
) {
|
||||
function insertRunRow(input: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
taskId: string | null;
|
||||
agentId: string;
|
||||
workspaceId: string | null;
|
||||
connectorId: ConnectorId;
|
||||
logPath: string;
|
||||
ts: string;
|
||||
}): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, log_path, created_at, updated_at) values (?, ?, ?, ?, ?, ?, 'queued', '', 0, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
input.id,
|
||||
input.companyId,
|
||||
input.taskId,
|
||||
input.agentId,
|
||||
input.workspaceId,
|
||||
input.connectorId,
|
||||
input.logPath,
|
||||
input.ts,
|
||||
input.ts,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...buildRunLogMethods(getDb, readAll, transaction),
|
||||
...buildRunEventMethods(getDb, readAll, transaction),
|
||||
@@ -51,19 +85,41 @@ export function buildRunMethods(
|
||||
const ts = nowIso();
|
||||
const logPath = join(deps.logsDir, `${id}.log`);
|
||||
transaction(() => {
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, log_path, created_at, updated_at) values (?, ?, ?, ?, ?, ?, 'queued', '', 0, ?, ?, ?)",
|
||||
)
|
||||
.run(id, input.companyId, input.taskId, input.agentId, input.workspaceId, input.connectorId, logPath, ts, ts);
|
||||
if (input.taskId !== null) {
|
||||
getDb().prepare("update tasks set active_run_id = ?, updated_at = ? where id = ?").run(id, ts, input.taskId);
|
||||
}
|
||||
insertRunRow({ ...input, id, logPath, ts });
|
||||
});
|
||||
writeFileSync(logPath, "", "utf8");
|
||||
return { id, logPath };
|
||||
},
|
||||
|
||||
createTaskRun(input: {
|
||||
id?: string;
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
workspaceId: string | null;
|
||||
connectorId: ConnectorId;
|
||||
}): { id: string; logPath: string } | null {
|
||||
const id = input.id ?? randomUUID();
|
||||
const ts = nowIso();
|
||||
const logPath = join(deps.logsDir, `${id}.log`);
|
||||
try {
|
||||
transaction(() => {
|
||||
insertRunRow({ ...input, id, logPath, ts });
|
||||
const checkoutOk = checkoutTaskInTransaction(getDb(), input.taskId, id);
|
||||
if (!checkoutOk) {
|
||||
throw new TaskCheckoutConflict(input.taskId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TaskCheckoutConflict) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
writeFileSync(logPath, "", "utf8");
|
||||
return { id, logPath };
|
||||
},
|
||||
|
||||
markRunStatus(runId: string, status: RunRecord["status"], message: string | null) {
|
||||
const ts = nowIso();
|
||||
const startedAt = status === "running" ? ts : null;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createMigrationsV49V49 } from "./migrations/v49-v49";
|
||||
import { createMigrationsV50V50 } from "./migrations/v50-v50";
|
||||
import { createMigrationsV51V51 } from "./migrations/v51-v51";
|
||||
import { createMigrationsV52V52 } from "./migrations/v52-v52";
|
||||
import { createMigrationsV53V53 } from "./migrations/v53-v53";
|
||||
import {
|
||||
columnExists,
|
||||
nowIso,
|
||||
@@ -216,6 +217,7 @@ export function runMigrations(
|
||||
...createMigrationsV50V50(deps),
|
||||
...createMigrationsV51V51(deps),
|
||||
...createMigrationsV52V52(deps),
|
||||
...createMigrationsV53V53(deps),
|
||||
];
|
||||
|
||||
// Pre-migration snapshot. If any migration is pending we take a full
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export const TASK_LEASE_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
function isoFromMs(ms: number): string {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
function assertRunExists(db: DatabaseSync, runId: string): void {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS present FROM runs WHERE id = ?")
|
||||
.get(runId) as { present: number } | undefined;
|
||||
if (!row) {
|
||||
throw new Error(`Run not found for task checkout: ${runId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function checkoutTaskInTransaction(
|
||||
db: DatabaseSync,
|
||||
taskId: string,
|
||||
runId: string,
|
||||
): boolean {
|
||||
assertRunExists(db, runId);
|
||||
const nowMs = Date.now();
|
||||
const ts = isoFromMs(nowMs);
|
||||
const result = db
|
||||
.prepare(
|
||||
// eslint-disable-next-line no-restricted-syntax -- B2 Phase 1 grandfathered: checkout CAS is the single sanctioned status='in_progress' lock acquisition. The same transaction writes active_run_id and task_leases.
|
||||
`UPDATE tasks
|
||||
SET checkout_run_id = ?,
|
||||
active_run_id = ?,
|
||||
execution_locked_at = ?,
|
||||
status = 'in_progress',
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND checkout_run_id IS NULL
|
||||
AND status IN ('todo', 'backlog')`,
|
||||
)
|
||||
.run(runId, runId, ts, ts, taskId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = isoFromMs(nowMs + TASK_LEASE_TTL_MS);
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO task_leases
|
||||
(task_id, run_id, acquired_at, expires_at, checkout_run_id)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(taskId, runId, ts, expiresAt, runId);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -7,38 +7,7 @@ import {
|
||||
taskPriorityOrderSql,
|
||||
} from "./helpers";
|
||||
import { chooseBestTaskAssignee } from "./task-assignment";
|
||||
|
||||
/**
|
||||
* Default lease TTL used when the dual-write path seeds `task_leases`
|
||||
* from the legacy `checkout_run_id` / `execution_locked_at` columns.
|
||||
* Matches the stale-lock timeout in database-base so the sweep logic
|
||||
* stays in sync across the shadow tables.
|
||||
*/
|
||||
const DUAL_WRITE_LEASE_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Mirror a successful legacy checkout onto the new `task_leases` row.
|
||||
*
|
||||
* Called from `checkoutTask` immediately after the legacy CAS succeeds.
|
||||
* `INSERT OR REPLACE` is safe here because the preceding SQL CAS
|
||||
* guarded on `checkout_run_id IS NULL` — any live lease row at this
|
||||
* point refers to the same run (replay under retry) or is itself stale
|
||||
* (recoverStaleLocks has not caught up yet). The new expires_at resets
|
||||
* the stale-lock sweep deadline either way.
|
||||
*/
|
||||
function writeCheckoutLease(
|
||||
db: DatabaseSync,
|
||||
taskId: string,
|
||||
runId: string,
|
||||
ts: string,
|
||||
): void {
|
||||
const expiresAt = new Date(Date.now() + DUAL_WRITE_LEASE_TTL_MS).toISOString();
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO task_leases
|
||||
(task_id, run_id, acquired_at, expires_at, checkout_run_id)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(taskId, runId, ts, expiresAt, runId);
|
||||
}
|
||||
import { TASK_LEASE_TTL_MS, checkoutTaskInTransaction } from "./task-checkout";
|
||||
|
||||
export function buildTaskStatusMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
@@ -222,47 +191,7 @@ export function buildTaskStatusMethods(
|
||||
},
|
||||
|
||||
checkoutTask(taskId: string, runId: string): boolean {
|
||||
const ts = nowIso();
|
||||
// Primary path is `status IN ('todo','backlog')`. The extra
|
||||
// `in_progress AND active_run_id IS NULL` branch recovers from a
|
||||
// specific orphan: finishRun has cleared active_run_id and
|
||||
// checkout_run_id for the previous run but the status transition
|
||||
// to done/in_review never fired (e.g. the run succeeded without a
|
||||
// deliverable and automation parked it at in_progress, or a
|
||||
// crashed run left the row behind). Without the reclaim, heartbeat
|
||||
// picks the task (activeRunId=null passes the candidate filter)
|
||||
// and then loops on `checkout_race_lost` forever because status
|
||||
// never flips back to todo on its own.
|
||||
//
|
||||
// The `active_run_id IS NULL` guard preserves the original CAS
|
||||
// contract for the busy case: a task whose active_run_id still
|
||||
// points at a live run is refused here, exactly as before.
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
// eslint-disable-next-line no-restricted-syntax -- B2 Phase 1 grandfathered: checkoutTask CAS writes status='in_progress' as part of the atomic lock acquisition. The status→phase trigger mirrors to the new phase column and writeCheckoutLease below writes the shadow lease in the same call.
|
||||
`UPDATE tasks
|
||||
SET checkout_run_id = ?, execution_locked_at = ?, status = 'in_progress', updated_at = ?
|
||||
WHERE id = ?
|
||||
AND checkout_run_id IS NULL
|
||||
AND (
|
||||
status IN ('todo', 'backlog')
|
||||
OR (status = 'in_progress' AND active_run_id IS NULL)
|
||||
)`,
|
||||
)
|
||||
.run(runId, ts, ts, taskId);
|
||||
if (result.changes > 0) {
|
||||
// B2 Phase 1 step 3/5 — dual-write the new lease shadow. The
|
||||
// status → phase trigger from v50 already mirrored the phase
|
||||
// column by this point; the lease table still needs an explicit
|
||||
// write. Same-run re-acquires overwrite in place, different-run
|
||||
// acquires would return null (and should not happen here
|
||||
// because the SQL CAS above already gated on `checkout_run_id
|
||||
// IS NULL`). Wrapped in a best-effort try to keep the new path
|
||||
// non-blocking for step 3 — the observability pass in step 4/5
|
||||
// surfaces any divergence.
|
||||
writeCheckoutLease(getDb(), taskId, runId, ts);
|
||||
}
|
||||
return result.changes > 0;
|
||||
return transaction(() => checkoutTaskInTransaction(getDb(), taskId, runId));
|
||||
},
|
||||
|
||||
releaseTaskLock(taskId: string, runId: string): void {
|
||||
@@ -291,7 +220,7 @@ export function buildTaskStatusMethods(
|
||||
// Shadow renewal: push `expires_at` forward in lockstep with
|
||||
// `execution_locked_at` so the stale-lock sweep over the new
|
||||
// table stays consistent with the legacy sweep.
|
||||
const expiresAt = new Date(Date.now() + DUAL_WRITE_LEASE_TTL_MS).toISOString();
|
||||
const expiresAt = new Date(Date.now() + TASK_LEASE_TTL_MS).toISOString();
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE task_leases SET expires_at = ? WHERE task_id = ? AND run_id = ?`,
|
||||
|
||||
Reference in new issue
Block a user