labtasker-server 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,859 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from typing import Any, Literal, cast, overload
9
+
10
+ from sqlalchemy import and_, func, or_, select, update
11
+ from sqlalchemy.orm import Session, selectinload
12
+
13
+ from labtasker_server.database import Database
14
+ from labtasker_server.errors import conflict, invalid, not_found
15
+ from labtasker_server.filtering import compile_filter
16
+ from labtasker_server.models import QueueRow, TaskRouteRow, TaskRow
17
+ from labtasker_server.pagination import (
18
+ CursorPosition,
19
+ TaskSelection,
20
+ decode_cursor,
21
+ encode_cursor,
22
+ )
23
+ from labtasker_server.schemas import (
24
+ BulkUpdateResult,
25
+ ClaimResponse,
26
+ FailureReport,
27
+ HeartbeatResponse,
28
+ LastError,
29
+ Task,
30
+ TaskCreate,
31
+ TaskOrderField,
32
+ TaskPage,
33
+ TaskStatus,
34
+ TaskUpdate,
35
+ )
36
+ from labtasker_server.validation import (
37
+ MAX_TASK_DATA_BYTES,
38
+ JSONValue,
39
+ validate_identifier,
40
+ validate_run_id,
41
+ validate_task_id,
42
+ )
43
+
44
+ HEARTBEAT_TIMEOUT_US = 300_000_000
45
+ TerminalAction = Literal["complete", "fail", "unclaim"]
46
+ TASK_ORDER_COLUMNS = {
47
+ "id": TaskRow.task_id,
48
+ "name": TaskRow.name,
49
+ "status": TaskRow.status,
50
+ "priority": TaskRow.priority,
51
+ "attempt": TaskRow.attempt,
52
+ "max_attempts": TaskRow.max_attempts,
53
+ "last_route": TaskRow.last_route,
54
+ "created_at": TaskRow.created_at_us,
55
+ "updated_at": TaskRow.updated_at_us,
56
+ "started_at": TaskRow.started_at_us,
57
+ "finished_at": TaskRow.finished_at_us,
58
+ }
59
+ NULLABLE_ORDER_FIELDS = {"name", "last_route", "started_at", "finished_at"}
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class PreparedUpdate:
64
+ name: str | None
65
+ args_json: str
66
+ metadata_json: str
67
+ priority: int
68
+ max_attempts: int
69
+ routes: tuple[str, ...]
70
+ result_json: str
71
+ changed: bool
72
+
73
+
74
+ def system_now_us() -> int:
75
+ return int(datetime.now(UTC).timestamp() * 1_000_000)
76
+
77
+
78
+ def canonical_json(value: object) -> str:
79
+ return json.dumps(
80
+ value,
81
+ ensure_ascii=False,
82
+ allow_nan=False,
83
+ separators=(",", ":"),
84
+ sort_keys=True,
85
+ )
86
+
87
+
88
+ @overload
89
+ def datetime_from_us(value: int) -> datetime: ...
90
+
91
+
92
+ @overload
93
+ def datetime_from_us(value: None) -> None: ...
94
+
95
+
96
+ def datetime_from_us(value: int | None) -> datetime | None:
97
+ if value is None:
98
+ return None
99
+ return datetime.fromtimestamp(value / 1_000_000, UTC)
100
+
101
+
102
+ class TaskService:
103
+ def __init__(self, database: Database, *, now_us: Callable[[], int] = system_now_us) -> None:
104
+ self.database = database
105
+ self.now_us = now_us
106
+
107
+ def create(self, queue: str, task_id: str, request: TaskCreate) -> tuple[Task, bool]:
108
+ queue = validate_identifier(queue, kind="Queue")
109
+ task_id = validate_task_id(task_id)
110
+ normalized = request.model_dump(mode="json")
111
+ normalized["routes"] = sorted(request.routes)
112
+ creation_hash = hashlib.sha256(canonical_json(normalized).encode()).hexdigest()
113
+
114
+ with self.database.write_session() as session:
115
+ if session.get(QueueRow, queue) is None:
116
+ raise not_found("queue_not_found", "Queue does not exist.", queue=queue)
117
+
118
+ existing = session.scalar(
119
+ select(TaskRow)
120
+ .options(selectinload(TaskRow.routes))
121
+ .where(TaskRow.queue_name == queue, TaskRow.task_id == task_id)
122
+ )
123
+ if existing is not None:
124
+ if existing.creation_hash != creation_hash:
125
+ raise conflict(
126
+ "task_id_conflict",
127
+ "Task ID is already associated with a different creation request.",
128
+ task_id=task_id,
129
+ queue=queue,
130
+ )
131
+ return task_from_row(existing), False
132
+
133
+ _validate_stored_size(normalized, result={})
134
+ now = self.now_us()
135
+ row = TaskRow(
136
+ queue_name=queue,
137
+ task_id=task_id,
138
+ status="pending",
139
+ name=request.name,
140
+ args_json=canonical_json(request.args),
141
+ metadata_json=canonical_json(request.metadata),
142
+ result_json="{}",
143
+ priority=request.priority,
144
+ attempt=0,
145
+ max_attempts=request.max_attempts,
146
+ created_at_us=now,
147
+ updated_at_us=now,
148
+ last_route=None,
149
+ started_at_us=None,
150
+ finished_at_us=None,
151
+ last_error_json=None,
152
+ creation_hash=creation_hash,
153
+ active_run_id=None,
154
+ lease_expires_at_us=None,
155
+ last_terminal_run_id=None,
156
+ last_terminal_action=None,
157
+ pending_at_us=now,
158
+ )
159
+ row.routes = [
160
+ TaskRouteRow(queue_name=queue, task_id=task_id, route=route)
161
+ for route in request.routes
162
+ ]
163
+ session.add(row)
164
+ session.flush()
165
+ return task_from_row(row), True
166
+
167
+ def get(self, queue: str, task_id: str) -> Task:
168
+ queue = validate_identifier(queue, kind="Queue")
169
+ task_id = validate_task_id(task_id)
170
+ with self.database.read_session() as session:
171
+ row = session.scalar(
172
+ select(TaskRow)
173
+ .options(selectinload(TaskRow.routes))
174
+ .where(TaskRow.queue_name == queue, TaskRow.task_id == task_id)
175
+ )
176
+ if row is None:
177
+ raise not_found(
178
+ "task_not_found",
179
+ "Task does not exist.",
180
+ queue=queue,
181
+ task_id=task_id,
182
+ )
183
+ return task_from_row(row)
184
+
185
+ def list_tasks(
186
+ self,
187
+ queue: str,
188
+ *,
189
+ status: TaskStatus | None = None,
190
+ name: str | None = None,
191
+ filter_expression: str | None = None,
192
+ order_by: TaskOrderField = "created_at",
193
+ descending: bool = True,
194
+ limit: int = 100,
195
+ cursor: str | None = None,
196
+ ) -> TaskPage:
197
+ queue = validate_identifier(queue, kind="Queue")
198
+ _validate_list_inputs(status, order_by, descending, limit)
199
+ selection = TaskSelection(
200
+ queue=queue,
201
+ status=status,
202
+ name=name,
203
+ filter=filter_expression,
204
+ order_by=order_by,
205
+ descending=descending,
206
+ )
207
+ position = decode_cursor(cursor, selection) if cursor is not None else None
208
+ conditions = _selection_conditions(
209
+ queue,
210
+ status=status,
211
+ name=name,
212
+ filter_expression=filter_expression,
213
+ )
214
+ if position is not None:
215
+ conditions.append(_after_cursor(order_by, descending, position))
216
+
217
+ column = TASK_ORDER_COLUMNS[order_by]
218
+ direction = column.desc if descending else column.asc
219
+ ordering: list[Any] = []
220
+ if order_by in NULLABLE_ORDER_FIELDS:
221
+ ordering.append(column.is_(None).asc())
222
+ ordering.append(direction())
223
+ if order_by != "id":
224
+ ordering.append(TaskRow.task_id.desc() if descending else TaskRow.task_id.asc())
225
+
226
+ with self.database.read_session() as session:
227
+ if session.get(QueueRow, queue) is None:
228
+ raise not_found("queue_not_found", "Queue does not exist.", queue=queue)
229
+ rows = session.scalars(
230
+ select(TaskRow)
231
+ .options(selectinload(TaskRow.routes))
232
+ .where(*conditions)
233
+ .order_by(*ordering)
234
+ .limit(limit + 1)
235
+ ).all()
236
+ has_more = len(rows) > limit
237
+ page_rows = rows[:limit]
238
+ next_cursor = None
239
+ if has_more and page_rows:
240
+ last = page_rows[-1]
241
+ next_cursor = encode_cursor(
242
+ selection,
243
+ CursorPosition(
244
+ value=_order_value(last, order_by),
245
+ task_id=last.task_id,
246
+ ),
247
+ )
248
+ return TaskPage(
249
+ items=[task_from_row(row) for row in page_rows],
250
+ next_cursor=next_cursor,
251
+ )
252
+
253
+ def count_tasks(
254
+ self,
255
+ queue: str,
256
+ *,
257
+ status: TaskStatus | None = None,
258
+ name: str | None = None,
259
+ filter_expression: str | None = None,
260
+ ) -> int:
261
+ queue = validate_identifier(queue, kind="Queue")
262
+ conditions = _selection_conditions(
263
+ queue,
264
+ status=status,
265
+ name=name,
266
+ filter_expression=filter_expression,
267
+ )
268
+ with self.database.read_session() as session:
269
+ if session.get(QueueRow, queue) is None:
270
+ raise not_found("queue_not_found", "Queue does not exist.", queue=queue)
271
+ value = session.scalar(select(func.count()).select_from(TaskRow).where(*conditions))
272
+ return 0 if value is None else value
273
+
274
+ def update_task(self, queue: str, task_id: str, changes: TaskUpdate) -> Task:
275
+ queue = validate_identifier(queue, kind="Queue")
276
+ task_id = validate_task_id(task_id)
277
+ with self.database.write_session() as session:
278
+ row = _require_task_row(session, queue, task_id)
279
+ if row.status == "running":
280
+ raise conflict(
281
+ "task_running",
282
+ "Running Tasks cannot be updated.",
283
+ task_id=task_id,
284
+ )
285
+ prepared = _prepare_update(row, changes)
286
+ if prepared.changed:
287
+ _apply_prepared_update(row, prepared)
288
+ row.updated_at_us = self.now_us()
289
+ session.flush()
290
+ return task_from_row(row)
291
+
292
+ def update_tasks(
293
+ self,
294
+ queue: str,
295
+ *,
296
+ filter_expression: str,
297
+ changes: TaskUpdate,
298
+ ) -> BulkUpdateResult:
299
+ queue = validate_identifier(queue, kind="Queue")
300
+ if not filter_expression.strip():
301
+ raise invalid("invalid_filter", "Batch update filter must not be empty.")
302
+ predicate = compile_filter(filter_expression)
303
+ with self.database.write_session() as session:
304
+ if session.get(QueueRow, queue) is None:
305
+ raise not_found("queue_not_found", "Queue does not exist.", queue=queue)
306
+ rows = session.scalars(
307
+ select(TaskRow)
308
+ .options(selectinload(TaskRow.routes))
309
+ .where(
310
+ TaskRow.queue_name == queue,
311
+ TaskRow.status != "running",
312
+ predicate,
313
+ )
314
+ ).all()
315
+ prepared = [(row, _prepare_update(row, changes)) for row in rows]
316
+ now = self.now_us()
317
+ updated_count = 0
318
+ for row, update_values in prepared:
319
+ if not update_values.changed:
320
+ continue
321
+ _apply_prepared_update(row, update_values)
322
+ row.updated_at_us = now
323
+ updated_count += 1
324
+ return BulkUpdateResult(matched=len(rows), updated=updated_count)
325
+
326
+ def claim(self, queue: str, route: str, run_id: str) -> ClaimResponse | None:
327
+ queue = validate_identifier(queue, kind="Queue")
328
+ route = validate_identifier(route, kind="Route")
329
+ run_id = validate_run_id(run_id)
330
+
331
+ with self.database.write_session() as session:
332
+ if session.get(QueueRow, queue) is None:
333
+ raise not_found("queue_not_found", "Queue does not exist.", queue=queue)
334
+
335
+ active = session.scalar(
336
+ select(TaskRow)
337
+ .options(selectinload(TaskRow.routes))
338
+ .where(TaskRow.active_run_id == run_id)
339
+ )
340
+ now = self.now_us()
341
+ if active is not None:
342
+ if active.queue_name != queue or active.last_route != route:
343
+ raise conflict(
344
+ "run_id_conflict",
345
+ "Run ID is already active for a different claim request.",
346
+ run_id=run_id,
347
+ )
348
+ if active.lease_expires_at_us is None or active.lease_expires_at_us <= now:
349
+ raise conflict("stale_run", "This run is no longer active.", run_id=run_id)
350
+ return _claim_response(active, run_id)
351
+
352
+ finalized = session.scalar(
353
+ select(TaskRow.task_id).where(TaskRow.last_terminal_run_id == run_id).limit(1)
354
+ )
355
+ if finalized is not None:
356
+ raise conflict("stale_run", "This run is no longer active.", run_id=run_id)
357
+
358
+ candidate = (
359
+ select(TaskRow.task_id)
360
+ .join(
361
+ TaskRouteRow,
362
+ (TaskRouteRow.queue_name == TaskRow.queue_name)
363
+ & (TaskRouteRow.task_id == TaskRow.task_id),
364
+ )
365
+ .where(
366
+ TaskRow.queue_name == queue,
367
+ TaskRow.status == "pending",
368
+ TaskRow.attempt < TaskRow.max_attempts,
369
+ TaskRouteRow.route == route,
370
+ )
371
+ .order_by(TaskRow.priority.desc(), TaskRow.pending_at_us, TaskRow.task_id)
372
+ .limit(1)
373
+ .scalar_subquery()
374
+ )
375
+ lease_expires_at_us = now + HEARTBEAT_TIMEOUT_US
376
+ claimed_id = session.scalar(
377
+ update(TaskRow)
378
+ .where(
379
+ TaskRow.queue_name == queue,
380
+ TaskRow.task_id == candidate,
381
+ TaskRow.status == "pending",
382
+ TaskRow.attempt < TaskRow.max_attempts,
383
+ )
384
+ .values(
385
+ status="running",
386
+ attempt=TaskRow.attempt + 1,
387
+ updated_at_us=now,
388
+ last_route=route,
389
+ started_at_us=now,
390
+ finished_at_us=None,
391
+ active_run_id=run_id,
392
+ lease_expires_at_us=lease_expires_at_us,
393
+ pending_at_us=None,
394
+ )
395
+ .returning(TaskRow.task_id)
396
+ )
397
+ if claimed_id is None:
398
+ return None
399
+ row = _require_task_row(session, queue, claimed_id)
400
+ return _claim_response(row, run_id)
401
+
402
+ def heartbeat(self, queue: str, task_id: str, run_id: str) -> HeartbeatResponse:
403
+ queue, task_id, run_id = _validated_execution_ids(queue, task_id, run_id)
404
+ finalized_action: str | None = None
405
+ response: HeartbeatResponse | None = None
406
+ with self.database.write_session() as session:
407
+ row = _require_task_row(session, queue, task_id)
408
+ now = self.now_us()
409
+ if row.status == "running" and row.active_run_id == run_id:
410
+ if row.lease_expires_at_us is None or row.lease_expires_at_us <= now:
411
+ _expire_row(row, now)
412
+ finalized_action = "heartbeat_expired"
413
+ else:
414
+ row.lease_expires_at_us = now + HEARTBEAT_TIMEOUT_US
415
+ response = HeartbeatResponse(
416
+ lease_expires_at=datetime_from_us(row.lease_expires_at_us)
417
+ )
418
+ elif row.last_terminal_run_id == run_id:
419
+ finalized_action = row.last_terminal_action
420
+ else:
421
+ raise _stale_run(run_id)
422
+
423
+ if finalized_action is not None:
424
+ raise _run_finalized(finalized_action)
425
+ if response is None:
426
+ raise AssertionError("Heartbeat produced neither a response nor a conflict.")
427
+ return response
428
+
429
+ def complete(
430
+ self,
431
+ queue: str,
432
+ task_id: str,
433
+ run_id: str,
434
+ result: dict[str, JSONValue],
435
+ ) -> None:
436
+ queue, task_id, run_id = _validated_execution_ids(queue, task_id, run_id)
437
+ finalized_action: str | None = None
438
+ with self.database.write_session() as session:
439
+ row = _require_task_row(session, queue, task_id)
440
+ now = self.now_us()
441
+ guard = _terminal_guard(row, run_id, "complete", now)
442
+ if guard == "expired":
443
+ finalized_action = "heartbeat_expired"
444
+ elif guard == "duplicate":
445
+ return
446
+ else:
447
+ _validate_row_stored_size(row, result=result)
448
+ row.status = "succeeded"
449
+ row.result_json = canonical_json(result)
450
+ _finish_run(row, run_id, "complete", now)
451
+ if finalized_action is not None:
452
+ raise _run_finalized(finalized_action)
453
+
454
+ def fail(
455
+ self,
456
+ queue: str,
457
+ task_id: str,
458
+ run_id: str,
459
+ error: FailureReport,
460
+ ) -> None:
461
+ queue, task_id, run_id = _validated_execution_ids(queue, task_id, run_id)
462
+ finalized_action: str | None = None
463
+ with self.database.write_session() as session:
464
+ row = _require_task_row(session, queue, task_id)
465
+ now = self.now_us()
466
+ guard = _terminal_guard(row, run_id, "fail", now)
467
+ if guard == "expired":
468
+ finalized_action = "heartbeat_expired"
469
+ elif guard == "duplicate":
470
+ return
471
+ else:
472
+ row.last_error_json = canonical_json(
473
+ {
474
+ **error.model_dump(mode="json"),
475
+ "occurred_at_us": now,
476
+ "attempt": row.attempt,
477
+ "run_id": run_id,
478
+ }
479
+ )
480
+ row.status = "pending" if row.attempt < row.max_attempts else "failed"
481
+ row.pending_at_us = now if row.status == "pending" else None
482
+ _finish_run(row, run_id, "fail", now)
483
+ if finalized_action is not None:
484
+ raise _run_finalized(finalized_action)
485
+
486
+ def unclaim(self, queue: str, task_id: str, run_id: str) -> None:
487
+ queue, task_id, run_id = _validated_execution_ids(queue, task_id, run_id)
488
+ finalized_action: str | None = None
489
+ with self.database.write_session() as session:
490
+ row = _require_task_row(session, queue, task_id)
491
+ now = self.now_us()
492
+ guard = _terminal_guard(row, run_id, "unclaim", now)
493
+ if guard == "expired":
494
+ finalized_action = "heartbeat_expired"
495
+ elif guard == "duplicate":
496
+ return
497
+ else:
498
+ row.status = "pending"
499
+ row.attempt -= 1
500
+ row.pending_at_us = now
501
+ _finish_run(row, run_id, "unclaim", now)
502
+ if finalized_action is not None:
503
+ raise _run_finalized(finalized_action)
504
+
505
+ def cancel(self, queue: str, task_id: str) -> Task:
506
+ queue = validate_identifier(queue, kind="Queue")
507
+ task_id = validate_task_id(task_id)
508
+ with self.database.write_session() as session:
509
+ row = _require_task_row(session, queue, task_id)
510
+ if row.status == "cancelled":
511
+ return task_from_row(row)
512
+ if row.status not in {"pending", "running"}:
513
+ raise conflict(
514
+ "task_state_conflict",
515
+ "Only pending or running Tasks can be cancelled.",
516
+ task_id=task_id,
517
+ status=row.status,
518
+ )
519
+ now = self.now_us()
520
+ if row.status == "running":
521
+ if row.active_run_id is None:
522
+ raise AssertionError("Running Task has no active run ID.")
523
+ _finish_run(row, row.active_run_id, "cancel", now)
524
+ else:
525
+ row.updated_at_us = now
526
+ row.pending_at_us = None
527
+ row.status = "cancelled"
528
+ session.flush()
529
+ return task_from_row(row)
530
+
531
+ def requeue(self, queue: str, task_id: str) -> Task:
532
+ queue = validate_identifier(queue, kind="Queue")
533
+ task_id = validate_task_id(task_id)
534
+ with self.database.write_session() as session:
535
+ row = _require_task_row(session, queue, task_id)
536
+ if row.status not in {"pending", "failed", "cancelled"}:
537
+ raise conflict(
538
+ "task_state_conflict",
539
+ "Only pending, failed or cancelled Tasks can be requeued.",
540
+ task_id=task_id,
541
+ status=row.status,
542
+ )
543
+ now = self.now_us()
544
+ row.status = "pending"
545
+ row.attempt = 0
546
+ row.last_error_json = None
547
+ row.updated_at_us = now
548
+ row.pending_at_us = now
549
+ session.flush()
550
+ return task_from_row(row)
551
+
552
+ def delete(self, queue: str, task_id: str) -> None:
553
+ queue = validate_identifier(queue, kind="Queue")
554
+ task_id = validate_task_id(task_id)
555
+ with self.database.write_session() as session:
556
+ if session.get(QueueRow, queue) is None:
557
+ raise not_found("queue_not_found", "Queue does not exist.", queue=queue)
558
+ row = session.get(TaskRow, (queue, task_id))
559
+ if row is None:
560
+ return
561
+ if row.status == "running":
562
+ raise conflict(
563
+ "task_running",
564
+ "Running Tasks must be cancelled before deletion.",
565
+ task_id=task_id,
566
+ )
567
+ session.delete(row)
568
+
569
+ def expire_leases(self) -> int:
570
+ now = self.now_us()
571
+ with self.database.write_session() as session:
572
+ rows = session.scalars(
573
+ select(TaskRow).where(
574
+ TaskRow.status == "running",
575
+ TaskRow.lease_expires_at_us <= now,
576
+ )
577
+ ).all()
578
+ for row in rows:
579
+ _expire_row(row, now)
580
+ return len(rows)
581
+
582
+
583
+ def task_from_row(row: TaskRow) -> Task:
584
+ last_error = None
585
+ if row.last_error_json is not None:
586
+ raw_error = json.loads(row.last_error_json)
587
+ raw_error["occurred_at"] = datetime_from_us(raw_error["occurred_at_us"])
588
+ del raw_error["occurred_at_us"]
589
+ last_error = LastError.model_validate(raw_error)
590
+ return Task(
591
+ id=row.task_id,
592
+ queue=row.queue_name,
593
+ status=cast(TaskStatus, row.status),
594
+ name=row.name,
595
+ args=json.loads(row.args_json),
596
+ metadata=json.loads(row.metadata_json),
597
+ priority=row.priority,
598
+ attempt=row.attempt,
599
+ max_attempts=row.max_attempts,
600
+ routes=sorted(route.route for route in row.routes),
601
+ result=json.loads(row.result_json),
602
+ last_error=last_error,
603
+ last_route=row.last_route,
604
+ created_at=datetime_from_us(row.created_at_us),
605
+ updated_at=datetime_from_us(row.updated_at_us),
606
+ started_at=datetime_from_us(row.started_at_us),
607
+ finished_at=datetime_from_us(row.finished_at_us),
608
+ )
609
+
610
+
611
+ def _claim_response(row: TaskRow, run_id: str) -> ClaimResponse:
612
+ if row.lease_expires_at_us is None:
613
+ raise AssertionError("Claimed Task has no lease deadline.")
614
+ return ClaimResponse(
615
+ task=task_from_row(row),
616
+ run_id=run_id,
617
+ lease_expires_at=datetime_from_us(row.lease_expires_at_us),
618
+ )
619
+
620
+
621
+ def _selection_conditions(
622
+ queue: str,
623
+ *,
624
+ status: TaskStatus | None,
625
+ name: str | None,
626
+ filter_expression: str | None,
627
+ ) -> list[Any]:
628
+ conditions: list[Any] = [TaskRow.queue_name == queue]
629
+ if status is not None:
630
+ conditions.append(TaskRow.status == status)
631
+ if name is not None:
632
+ conditions.append(TaskRow.name == name)
633
+ if filter_expression is not None:
634
+ conditions.append(compile_filter(filter_expression))
635
+ return conditions
636
+
637
+
638
+ def _validate_list_inputs(
639
+ status: str | None,
640
+ order_by: str,
641
+ descending: bool,
642
+ limit: int,
643
+ ) -> None:
644
+ if status is not None and status not in {
645
+ "pending",
646
+ "running",
647
+ "succeeded",
648
+ "failed",
649
+ "cancelled",
650
+ }:
651
+ raise invalid("invalid_request", "Status is not a valid Task status.", field="status")
652
+ if order_by not in TASK_ORDER_COLUMNS:
653
+ raise invalid("invalid_request", "Order field is not supported.", field="order_by")
654
+ if not isinstance(descending, bool):
655
+ raise invalid("invalid_request", "Descending must be a Boolean.", field="descending")
656
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000:
657
+ raise invalid("invalid_request", "Limit must be an integer from 1 through 1000.")
658
+
659
+
660
+ def _after_cursor(
661
+ order_by: TaskOrderField,
662
+ descending: bool,
663
+ position: CursorPosition,
664
+ ) -> Any:
665
+ column = TASK_ORDER_COLUMNS[order_by]
666
+ id_after = (
667
+ TaskRow.task_id < position.task_id if descending else TaskRow.task_id > position.task_id
668
+ )
669
+ if order_by == "id":
670
+ return id_after
671
+ if position.value is None:
672
+ return and_(column.is_(None), id_after)
673
+ value_after = column < position.value if descending else column > position.value
674
+ same_value_after = and_(column == position.value, id_after)
675
+ if order_by in NULLABLE_ORDER_FIELDS:
676
+ return or_(value_after, same_value_after, column.is_(None))
677
+ return or_(value_after, same_value_after)
678
+
679
+
680
+ def _order_value(row: TaskRow, order_by: TaskOrderField) -> str | int | None:
681
+ values: dict[str, str | int | None] = {
682
+ "id": row.task_id,
683
+ "name": row.name,
684
+ "status": row.status,
685
+ "priority": row.priority,
686
+ "attempt": row.attempt,
687
+ "max_attempts": row.max_attempts,
688
+ "last_route": row.last_route,
689
+ "created_at": row.created_at_us,
690
+ "updated_at": row.updated_at_us,
691
+ "started_at": row.started_at_us,
692
+ "finished_at": row.finished_at_us,
693
+ }
694
+ return values[order_by]
695
+
696
+
697
+ def _prepare_update(row: TaskRow, changes: TaskUpdate) -> PreparedUpdate:
698
+ supplied = changes.model_dump(mode="python", exclude_unset=True)
699
+ current = {
700
+ "name": row.name,
701
+ "args": json.loads(row.args_json),
702
+ "metadata": json.loads(row.metadata_json),
703
+ "priority": row.priority,
704
+ "max_attempts": row.max_attempts,
705
+ "routes": sorted(route.route for route in row.routes),
706
+ "result": json.loads(row.result_json),
707
+ }
708
+ resulting = {**current, **supplied}
709
+ max_attempts = cast(int, resulting["max_attempts"])
710
+ if row.status == "pending" and max_attempts <= row.attempt:
711
+ raise conflict(
712
+ "update_conflict",
713
+ "Pending Task must retain at least one remaining attempt.",
714
+ task_id=row.task_id,
715
+ field="max_attempts",
716
+ attempt=row.attempt,
717
+ )
718
+ _validate_stored_size(resulting, result=resulting["result"])
719
+ return PreparedUpdate(
720
+ name=cast(str | None, resulting["name"]),
721
+ args_json=canonical_json(resulting["args"]),
722
+ metadata_json=canonical_json(resulting["metadata"]),
723
+ priority=cast(int, resulting["priority"]),
724
+ max_attempts=max_attempts,
725
+ routes=tuple(cast(list[str], resulting["routes"])),
726
+ result_json=canonical_json(resulting["result"]),
727
+ changed=canonical_json(current) != canonical_json(resulting),
728
+ )
729
+
730
+
731
+ def _apply_prepared_update(row: TaskRow, prepared: PreparedUpdate) -> None:
732
+ row.name = prepared.name
733
+ row.args_json = prepared.args_json
734
+ row.metadata_json = prepared.metadata_json
735
+ row.priority = prepared.priority
736
+ row.max_attempts = prepared.max_attempts
737
+ row.result_json = prepared.result_json
738
+ existing_routes = {route.route: route for route in row.routes}
739
+ current_routes = tuple(sorted(existing_routes))
740
+ if current_routes != prepared.routes:
741
+ row.routes = [
742
+ existing_routes.get(route)
743
+ or TaskRouteRow(queue_name=row.queue_name, task_id=row.task_id, route=route)
744
+ for route in prepared.routes
745
+ ]
746
+
747
+
748
+ def _require_task_row(session: Session, queue: str, task_id: str) -> TaskRow:
749
+ row = session.scalar(
750
+ select(TaskRow)
751
+ .options(selectinload(TaskRow.routes))
752
+ .where(TaskRow.queue_name == queue, TaskRow.task_id == task_id)
753
+ )
754
+ if row is None:
755
+ raise not_found(
756
+ "task_not_found",
757
+ "Task does not exist.",
758
+ queue=queue,
759
+ task_id=task_id,
760
+ )
761
+ return row
762
+
763
+
764
+ def _validated_execution_ids(queue: str, task_id: str, run_id: str) -> tuple[str, str, str]:
765
+ return (
766
+ validate_identifier(queue, kind="Queue"),
767
+ validate_task_id(task_id),
768
+ validate_run_id(run_id),
769
+ )
770
+
771
+
772
+ def _terminal_guard(
773
+ row: TaskRow,
774
+ run_id: str,
775
+ action: TerminalAction,
776
+ now: int,
777
+ ) -> Literal["active", "duplicate", "expired"]:
778
+ if row.status == "running" and row.active_run_id == run_id:
779
+ if row.lease_expires_at_us is None or row.lease_expires_at_us <= now:
780
+ _expire_row(row, now)
781
+ return "expired"
782
+ return "active"
783
+ if row.last_terminal_run_id == run_id:
784
+ if row.last_terminal_action == action:
785
+ return "duplicate"
786
+ raise _run_finalized(row.last_terminal_action)
787
+ raise _stale_run(run_id)
788
+
789
+
790
+ def _finish_run(row: TaskRow, run_id: str, action: str, now: int) -> None:
791
+ row.updated_at_us = now
792
+ row.finished_at_us = now
793
+ row.active_run_id = None
794
+ row.lease_expires_at_us = None
795
+ row.last_terminal_run_id = run_id
796
+ row.last_terminal_action = action
797
+
798
+
799
+ def _expire_row(row: TaskRow, now: int) -> None:
800
+ if row.active_run_id is None:
801
+ raise AssertionError("Cannot expire a Task without an active run ID.")
802
+ run_id = row.active_run_id
803
+ row.last_error_json = canonical_json(
804
+ {
805
+ "type": "HeartbeatTimeout",
806
+ "message": "Heartbeat lease expired.",
807
+ "traceback": None,
808
+ "occurred_at_us": now,
809
+ "attempt": row.attempt,
810
+ "run_id": run_id,
811
+ }
812
+ )
813
+ row.status = "pending" if row.attempt < row.max_attempts else "failed"
814
+ row.pending_at_us = now if row.status == "pending" else None
815
+ _finish_run(row, run_id, "heartbeat_expired", now)
816
+
817
+
818
+ def _run_finalized(action: str | None) -> Exception:
819
+ return conflict(
820
+ "run_finalized",
821
+ "This run has already been finalized.",
822
+ action=action,
823
+ )
824
+
825
+
826
+ def _stale_run(run_id: str) -> Exception:
827
+ return conflict("stale_run", "This run is no longer active.", run_id=run_id)
828
+
829
+
830
+ def _validate_row_stored_size(row: TaskRow, *, result: dict[str, JSONValue]) -> None:
831
+ _validate_stored_size(
832
+ {
833
+ "name": row.name,
834
+ "args": json.loads(row.args_json),
835
+ "metadata": json.loads(row.metadata_json),
836
+ "priority": row.priority,
837
+ "max_attempts": row.max_attempts,
838
+ "routes": sorted(route.route for route in row.routes),
839
+ },
840
+ result=result,
841
+ )
842
+
843
+
844
+ def _validate_stored_size(normalized: dict[str, object], *, result: object) -> None:
845
+ stored = {
846
+ "name": normalized["name"],
847
+ "args": normalized["args"],
848
+ "metadata": normalized["metadata"],
849
+ "priority": normalized["priority"],
850
+ "max_attempts": normalized["max_attempts"],
851
+ "routes": normalized["routes"],
852
+ "result": result,
853
+ }
854
+ if len(canonical_json(stored).encode("utf-8")) > MAX_TASK_DATA_BYTES:
855
+ raise invalid(
856
+ "task_data_too_large",
857
+ "Stored Task data exceeds the 1 MiB limit.",
858
+ max_bytes=MAX_TASK_DATA_BYTES,
859
+ )