|
4 | 4 | * Port of Python's `nodetool.models.job`. |
5 | 5 | */ |
6 | 6 |
|
7 | | -import { eq, and, desc, lt } from "drizzle-orm"; |
| 7 | +import { eq, and, desc, lt, inArray, notInArray } from "drizzle-orm"; |
8 | 8 | import { DBModel, createTimeOrderedUuid } from "./base-model.js"; |
9 | 9 | import { getDb } from "./db.js"; |
10 | 10 | import { jobs } from "./schema/jobs.js"; |
@@ -52,6 +52,12 @@ export class Job extends DBModel { |
52 | 52 | declare suspension_metadata_json: Record<string, unknown> | null; |
53 | 53 | declare execution_strategy: string | null; |
54 | 54 | declare execution_id: string | null; |
| 55 | + /** |
| 56 | + * The server instance executing this run, when the deployment has more than |
| 57 | + * one (see `getInstanceId` in the websocket package). Null means |
| 58 | + * single-machine: nothing routes by it. |
| 59 | + */ |
| 60 | + declare runner_instance: string | null; |
55 | 61 | declare metadata_json: Record<string, unknown> | null; |
56 | 62 | declare created_at: string; |
57 | 63 | declare updated_at: string; |
@@ -85,6 +91,7 @@ export class Job extends DBModel { |
85 | 91 | this.suspension_metadata_json ??= null; |
86 | 92 | this.execution_strategy ??= null; |
87 | 93 | this.execution_id ??= null; |
| 94 | + this.runner_instance ??= null; |
88 | 95 | this.metadata_json ??= null; |
89 | 96 | this.name ??= ""; |
90 | 97 | } |
@@ -228,6 +235,55 @@ export class Job extends DBModel { |
228 | 235 |
|
229 | 236 | // ── Static queries ─────────────────────────────────────────────── |
230 | 237 |
|
| 238 | + /** |
| 239 | + * Cancel a run without reading it first. |
| 240 | + * |
| 241 | + * A cancel arriving on an instance that does not own the run races the |
| 242 | + * owner's own terminal write. Loading the row, mutating it and calling |
| 243 | + * `save()` would send a full-row upsert built from a snapshot taken before |
| 244 | + * that race — resurrecting a `completed` job as `cancelled` and overwriting |
| 245 | + * the cost and timestamps the owner had just written. This touches only the |
| 246 | + * two columns a cancel owns, and only while the row is still active. |
| 247 | + * |
| 248 | + * Returns whether a row actually changed: false means the run was already |
| 249 | + * terminal (or is not this user's), which is the caller's cue that there was |
| 250 | + * nothing to cancel. |
| 251 | + */ |
| 252 | + static async markCancelledIfActive( |
| 253 | + jobId: string, |
| 254 | + userId: string |
| 255 | + ): Promise<boolean> { |
| 256 | + const db = getDb(); |
| 257 | + const now = new Date().toISOString(); |
| 258 | + const updated = await db |
| 259 | + .update(jobs) |
| 260 | + .set({ status: "cancelled", finished_at: now, updated_at: now }) |
| 261 | + .where( |
| 262 | + and( |
| 263 | + eq(jobs.id, jobId), |
| 264 | + eq(jobs.user_id, userId), |
| 265 | + notInArray(jobs.status, ["completed", "failed", "cancelled"]) |
| 266 | + ) |
| 267 | + ) |
| 268 | + .returning({ id: jobs.id }); |
| 269 | + return updated.length > 0; |
| 270 | + } |
| 271 | + |
| 272 | + /** |
| 273 | + * Which of these ids are now cancelled. The poller's one query per tick — |
| 274 | + * indexed on the primary key, and bounded by how many runs an instance is |
| 275 | + * actually executing. |
| 276 | + */ |
| 277 | + static async cancelledAmong(jobIds: string[]): Promise<string[]> { |
| 278 | + if (jobIds.length === 0) return []; |
| 279 | + const db = getDb(); |
| 280 | + const rows = await db |
| 281 | + .select({ id: jobs.id }) |
| 282 | + .from(jobs) |
| 283 | + .where(and(inArray(jobs.id, jobIds), eq(jobs.status, "cancelled"))); |
| 284 | + return rows.map((row: { id: string }) => row.id); |
| 285 | + } |
| 286 | + |
231 | 287 | /** Find a job by id, scoped to the user. */ |
232 | 288 | static async find(userId: string, jobId: string): Promise<Job | null> { |
233 | 289 | const job = await Job.get<Job>(jobId); |
|
0 commit comments