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.
- labtasker_server/__init__.py +3 -0
- labtasker_server/__main__.py +3 -0
- labtasker_server/app.py +416 -0
- labtasker_server/cli.py +371 -0
- labtasker_server/config.py +42 -0
- labtasker_server/database.py +176 -0
- labtasker_server/errors.py +27 -0
- labtasker_server/filtering.py +528 -0
- labtasker_server/local.py +453 -0
- labtasker_server/logging.py +47 -0
- labtasker_server/middleware.py +73 -0
- labtasker_server/migrations/__init__.py +1 -0
- labtasker_server/migrations/env.py +20 -0
- labtasker_server/migrations/versions/0001_initial.py +135 -0
- labtasker_server/migrations/versions/__init__.py +1 -0
- labtasker_server/models.py +139 -0
- labtasker_server/pagination.py +126 -0
- labtasker_server/py.typed +1 -0
- labtasker_server/schemas.py +265 -0
- labtasker_server/services/__init__.py +1 -0
- labtasker_server/services/queues.py +66 -0
- labtasker_server/services/tasks.py +859 -0
- labtasker_server/validation.py +150 -0
- labtasker_server-2.0.0.dist-info/METADATA +16 -0
- labtasker_server-2.0.0.dist-info/RECORD +28 -0
- labtasker_server-2.0.0.dist-info/WHEEL +4 -0
- labtasker_server-2.0.0.dist-info/entry_points.txt +2 -0
- labtasker_server-2.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|
+
)
|