pythonhere 0.3.1__py3-none-any.whl → 0.4.1__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.
- pythonhere/.agents/skills/pythonhere/SKILL.md +5 -1
- pythonhere/.agents/skills/pythonhere/references/litert.md +121 -0
- pythonhere/.agents/skills/pythonhere/references/tasks.md +58 -0
- pythonhere/main.py +3 -0
- pythonhere/ml_here/__init__.py +17 -0
- pythonhere/ml_here/huggingface.py +998 -0
- pythonhere/ml_here/storage.py +145 -0
- pythonhere/server_here.py +1 -0
- pythonhere/tasks_here.py +1292 -0
- pythonhere/ui_here/connection_address_here.kv +40 -0
- pythonhere/version_here.py +1 -1
- {pythonhere-0.3.1.dist-info → pythonhere-0.4.1.dist-info}/METADATA +6 -2
- {pythonhere-0.3.1.dist-info → pythonhere-0.4.1.dist-info}/RECORD +16 -10
- {pythonhere-0.3.1.dist-info → pythonhere-0.4.1.dist-info}/WHEEL +0 -0
- {pythonhere-0.3.1.dist-info → pythonhere-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {pythonhere-0.3.1.dist-info → pythonhere-0.4.1.dist-info}/top_level.txt +0 -0
pythonhere/tasks_here.py
ADDED
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
"""Durable sequential task execution for PythonHere.
|
|
2
|
+
|
|
3
|
+
TaskRunner provides persistent named runs with retries, progress inspection,
|
|
4
|
+
and at-least-once recovery after process interruption.
|
|
5
|
+
|
|
6
|
+
An optional setup callback can create runtime state once per worker session::
|
|
7
|
+
|
|
8
|
+
def setup():
|
|
9
|
+
return create_runtime()
|
|
10
|
+
|
|
11
|
+
def execute_task(task, runtime):
|
|
12
|
+
return runtime.process(task)
|
|
13
|
+
|
|
14
|
+
runner.start(execute_task, setup=setup)
|
|
15
|
+
|
|
16
|
+
Without setup, the executor still receives a second argument, whose value is
|
|
17
|
+
``None``::
|
|
18
|
+
|
|
19
|
+
def execute_task(task, context):
|
|
20
|
+
return task["value"] * 2
|
|
21
|
+
|
|
22
|
+
runner.start(execute_task)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import copy
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import shutil
|
|
33
|
+
import sys
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
import traceback
|
|
37
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from persistqueue import SQLiteAckQueue
|
|
43
|
+
from persistqueue.exceptions import Empty
|
|
44
|
+
|
|
45
|
+
# pylint: disable=too-many-lines
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"TaskRunner",
|
|
49
|
+
"TaskRunnerError",
|
|
50
|
+
"TaskRunExistsError",
|
|
51
|
+
"TaskRunNotFoundError",
|
|
52
|
+
"DuplicateTaskError",
|
|
53
|
+
"TaskSubmissionError",
|
|
54
|
+
"TaskRunnerAlreadyRunningError",
|
|
55
|
+
"TaskRunnerBusyError",
|
|
56
|
+
"TaskRunnerClosedError",
|
|
57
|
+
"task_runs_directory",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
log = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
_SCHEMA_VERSION = 1
|
|
64
|
+
_RUN_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
65
|
+
|
|
66
|
+
# persist-queue SQLiteAckQueue status values in 1.1.0.
|
|
67
|
+
# Keep these local rather than importing a private implementation class.
|
|
68
|
+
_PQ_INITED = "0"
|
|
69
|
+
_PQ_READY = "1"
|
|
70
|
+
_PQ_UNACK = "2"
|
|
71
|
+
_PQ_ACKED = "5"
|
|
72
|
+
_PQ_FAILED = "9"
|
|
73
|
+
|
|
74
|
+
_REGISTRY_LOCK = threading.RLock()
|
|
75
|
+
_INSTANCES: dict[str, TaskRunner] = {}
|
|
76
|
+
|
|
77
|
+
# Runs are exclusive process-wide by default so separate workloads cannot
|
|
78
|
+
# accidentally compete for a shared device or another constrained resource.
|
|
79
|
+
# Set exclusive=False when parallel independent runners are explicitly desired.
|
|
80
|
+
_EXECUTION_OWNER: dict[str, str] = {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TaskRunnerError(RuntimeError):
|
|
84
|
+
"""Base error for TaskRunner."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TaskRunExistsError(TaskRunnerError):
|
|
88
|
+
"""A run with this name already exists."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TaskRunNotFoundError(TaskRunnerError):
|
|
92
|
+
"""The requested run does not exist."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class DuplicateTaskError(TaskRunnerError):
|
|
96
|
+
"""A task id already exists in the run."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TaskSubmissionError(TaskRunnerError):
|
|
100
|
+
"""Storage failed during submission; inspect the run before resubmitting."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, submitted_ids: list[str]):
|
|
103
|
+
self.submitted_ids = submitted_ids
|
|
104
|
+
super().__init__(
|
|
105
|
+
f"Submission failed after {len(submitted_ids)} confirmed writes; "
|
|
106
|
+
"inspect get(id) before resubmitting missing tasks"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TaskRunnerAlreadyRunningError(TaskRunnerError):
|
|
111
|
+
"""This runner already has an active worker."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TaskRunnerBusyError(TaskRunnerError):
|
|
115
|
+
"""Another exclusive TaskRunner is already executing."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TaskRunnerClosedError(TaskRunnerError):
|
|
119
|
+
"""Operation attempted on a closed runner."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _utc_now() -> str:
|
|
123
|
+
return datetime.now(timezone.utc).isoformat()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def task_runs_directory() -> Path:
|
|
127
|
+
"""
|
|
128
|
+
Return the default persistent TaskRunner directory.
|
|
129
|
+
|
|
130
|
+
Override with PYTHONHERE_TASKS_DIR when needed. Android uses the current
|
|
131
|
+
application's private files directory; desktop uses its user data directory.
|
|
132
|
+
"""
|
|
133
|
+
configured = os.environ.get("PYTHONHERE_TASKS_DIR")
|
|
134
|
+
if configured:
|
|
135
|
+
return Path(configured).expanduser().resolve()
|
|
136
|
+
|
|
137
|
+
if sys.platform == "android" or "ANDROID_ARGUMENT" in os.environ:
|
|
138
|
+
from android.storage import app_storage_path
|
|
139
|
+
|
|
140
|
+
return Path(app_storage_path()) / "task-runs"
|
|
141
|
+
data_home = os.environ.get("XDG_DATA_HOME")
|
|
142
|
+
base = Path(data_home).expanduser() if data_home else Path.home() / ".local/share"
|
|
143
|
+
return base / "pythonhere/task-runs"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _plain_value(value: Any) -> Any:
|
|
147
|
+
"""Validate and snapshot portable values before writing or invoking user code."""
|
|
148
|
+
|
|
149
|
+
def check(item):
|
|
150
|
+
if item is None or isinstance(item, (bool, int, float, str)):
|
|
151
|
+
return
|
|
152
|
+
if isinstance(item, list):
|
|
153
|
+
for child in item:
|
|
154
|
+
check(child)
|
|
155
|
+
return
|
|
156
|
+
if isinstance(item, dict) and all(isinstance(key, str) for key in item):
|
|
157
|
+
for child in item.values():
|
|
158
|
+
check(child)
|
|
159
|
+
return
|
|
160
|
+
raise TypeError("Tasks and results must contain plain JSON-compatible values")
|
|
161
|
+
|
|
162
|
+
check(value)
|
|
163
|
+
return json.loads(json.dumps(value, allow_nan=False))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _normalize_root(root: str | os.PathLike[str] | None) -> Path:
|
|
167
|
+
if root is None:
|
|
168
|
+
return task_runs_directory()
|
|
169
|
+
return Path(root).expanduser().resolve()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _validate_name(name: str) -> str:
|
|
173
|
+
if not isinstance(name, str) or not _RUN_NAME_RE.fullmatch(name):
|
|
174
|
+
raise ValueError(r"TaskRunner name must match [A-Za-z0-9][A-Za-z0-9._-]*")
|
|
175
|
+
if name in {".", ".."}:
|
|
176
|
+
raise ValueError("Invalid TaskRunner name")
|
|
177
|
+
return name
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _run_path(name: str, root: Path) -> Path:
|
|
181
|
+
return root / _validate_name(name)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _registry_key(path: Path) -> str:
|
|
185
|
+
return str(path.resolve())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _atomic_write_json(
|
|
189
|
+
path: Path, value: Mapping[str, Any] | list[dict[str, Any]]
|
|
190
|
+
) -> None:
|
|
191
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
|
|
193
|
+
tmp = path.with_name(f".{path.name}.tmp-{os.getpid()}-{threading.get_ident()}")
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
197
|
+
json.dump(
|
|
198
|
+
value,
|
|
199
|
+
f,
|
|
200
|
+
indent=2,
|
|
201
|
+
sort_keys=True,
|
|
202
|
+
ensure_ascii=False,
|
|
203
|
+
)
|
|
204
|
+
f.write("\n")
|
|
205
|
+
f.flush()
|
|
206
|
+
os.fsync(f.fileno())
|
|
207
|
+
|
|
208
|
+
os.replace(tmp, path)
|
|
209
|
+
finally:
|
|
210
|
+
try:
|
|
211
|
+
tmp.unlink()
|
|
212
|
+
except FileNotFoundError:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
217
|
+
try:
|
|
218
|
+
with path.open("r", encoding="utf-8") as f:
|
|
219
|
+
value = json.load(f)
|
|
220
|
+
except FileNotFoundError as exc:
|
|
221
|
+
raise TaskRunNotFoundError(f"Task run metadata not found: {path}") from exc
|
|
222
|
+
|
|
223
|
+
if not isinstance(value, dict):
|
|
224
|
+
raise TaskRunnerError(f"Invalid TaskRunner metadata: {path}")
|
|
225
|
+
|
|
226
|
+
return value
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _status_name(status: Any) -> str:
|
|
230
|
+
status = str(status)
|
|
231
|
+
|
|
232
|
+
if status in {_PQ_INITED, _PQ_READY}:
|
|
233
|
+
return "pending"
|
|
234
|
+
if status == _PQ_UNACK:
|
|
235
|
+
return "running"
|
|
236
|
+
if status == _PQ_ACKED:
|
|
237
|
+
return "done"
|
|
238
|
+
if status == _PQ_FAILED:
|
|
239
|
+
return "failed"
|
|
240
|
+
|
|
241
|
+
return f"unknown:{status}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class TaskRunner:
|
|
245
|
+
"""
|
|
246
|
+
Durable single-worker task runner.
|
|
247
|
+
|
|
248
|
+
Use create() and open() instead of constructing this class directly.
|
|
249
|
+
|
|
250
|
+
A run stores each task, its attempt history, result/error information, and
|
|
251
|
+
queue status on disk. SQLiteAckQueue provides durable acknowledgement and
|
|
252
|
+
recovery of tasks that were in-flight when the process died.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def __init__(
|
|
256
|
+
self,
|
|
257
|
+
*,
|
|
258
|
+
name: str,
|
|
259
|
+
root: Path,
|
|
260
|
+
metadata: dict[str, Any],
|
|
261
|
+
auto_resume: bool,
|
|
262
|
+
) -> None:
|
|
263
|
+
self.name = name
|
|
264
|
+
self.root = root
|
|
265
|
+
self.path = _run_path(name, root)
|
|
266
|
+
self._meta_path = self.path / "meta.json"
|
|
267
|
+
self._queue_path = self.path / "queue"
|
|
268
|
+
|
|
269
|
+
self.max_attempts = int(metadata["max_attempts"])
|
|
270
|
+
self.exclusive = bool(metadata.get("exclusive", True))
|
|
271
|
+
self._user_metadata = copy.deepcopy(metadata.get("metadata") or {})
|
|
272
|
+
|
|
273
|
+
self._lock = threading.RLock()
|
|
274
|
+
self._pause_requested = threading.Event()
|
|
275
|
+
self._thread: threading.Thread | None = None
|
|
276
|
+
self._current_task_id: str | None = None
|
|
277
|
+
self._current_pqid: int | None = None
|
|
278
|
+
self._closed = False
|
|
279
|
+
self._worker_error: dict[str, Any] | None = None
|
|
280
|
+
|
|
281
|
+
self._state = str(metadata.get("state", "idle"))
|
|
282
|
+
self._created_at = str(metadata.get("created_at", _utc_now()))
|
|
283
|
+
|
|
284
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
|
|
286
|
+
self._queue = SQLiteAckQueue(
|
|
287
|
+
str(self._queue_path),
|
|
288
|
+
auto_resume=auto_resume,
|
|
289
|
+
multithreading=True,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# There are two small commit windows:
|
|
293
|
+
#
|
|
294
|
+
# 1. result persisted, process dies before ack()
|
|
295
|
+
# 2. final error persisted, process dies before ack_failed()
|
|
296
|
+
#
|
|
297
|
+
# auto_resume makes both rows READY. Repair their queue status from the
|
|
298
|
+
# durable record so already committed work is not needlessly rerun.
|
|
299
|
+
self._recover_committed_records()
|
|
300
|
+
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
# Lifecycle
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def create( # pylint: disable=too-many-arguments
|
|
307
|
+
cls,
|
|
308
|
+
name: str,
|
|
309
|
+
*,
|
|
310
|
+
max_attempts: int = 3,
|
|
311
|
+
metadata: Mapping[str, Any] | None = None,
|
|
312
|
+
root: str | os.PathLike[str] | None = None,
|
|
313
|
+
exclusive: bool = True,
|
|
314
|
+
) -> TaskRunner:
|
|
315
|
+
"""
|
|
316
|
+
Create a new durable named run.
|
|
317
|
+
|
|
318
|
+
Raises TaskRunExistsError when the name already exists.
|
|
319
|
+
"""
|
|
320
|
+
name = _validate_name(name)
|
|
321
|
+
root_path = _normalize_root(root)
|
|
322
|
+
path = _run_path(name, root_path)
|
|
323
|
+
key = _registry_key(path)
|
|
324
|
+
|
|
325
|
+
if (
|
|
326
|
+
not isinstance(max_attempts, int)
|
|
327
|
+
or isinstance(max_attempts, bool)
|
|
328
|
+
or max_attempts < 1
|
|
329
|
+
):
|
|
330
|
+
raise ValueError("max_attempts must be a positive integer")
|
|
331
|
+
|
|
332
|
+
# Use the same portable value contract for user metadata.
|
|
333
|
+
user_metadata = _plain_value(dict(metadata or {}))
|
|
334
|
+
with _REGISTRY_LOCK:
|
|
335
|
+
if key in _INSTANCES or path.exists():
|
|
336
|
+
raise TaskRunExistsError(f"Task run {name!r} already exists")
|
|
337
|
+
|
|
338
|
+
path.mkdir(parents=True, exist_ok=False)
|
|
339
|
+
|
|
340
|
+
created_at = _utc_now()
|
|
341
|
+
initial_meta = {
|
|
342
|
+
"schema_version": _SCHEMA_VERSION,
|
|
343
|
+
"name": name,
|
|
344
|
+
"created_at": created_at,
|
|
345
|
+
"updated_at": created_at,
|
|
346
|
+
"max_attempts": int(max_attempts),
|
|
347
|
+
"exclusive": bool(exclusive),
|
|
348
|
+
"state": "idle",
|
|
349
|
+
"metadata": user_metadata,
|
|
350
|
+
"summary": {
|
|
351
|
+
"total": 0,
|
|
352
|
+
"pending": 0,
|
|
353
|
+
"running": 0,
|
|
354
|
+
"completed": 0,
|
|
355
|
+
"failed": 0,
|
|
356
|
+
"progress": 0.0,
|
|
357
|
+
},
|
|
358
|
+
"worker_error": None,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
_atomic_write_json(path / "meta.json", initial_meta)
|
|
363
|
+
runner = cls(
|
|
364
|
+
name=name,
|
|
365
|
+
root=root_path,
|
|
366
|
+
metadata=initial_meta,
|
|
367
|
+
auto_resume=False,
|
|
368
|
+
)
|
|
369
|
+
except Exception:
|
|
370
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
371
|
+
raise
|
|
372
|
+
|
|
373
|
+
_INSTANCES[key] = runner
|
|
374
|
+
|
|
375
|
+
runner._refresh_meta(state="idle")
|
|
376
|
+
return runner
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
def open(
|
|
380
|
+
cls,
|
|
381
|
+
name: str,
|
|
382
|
+
*,
|
|
383
|
+
root: str | os.PathLike[str] | None = None,
|
|
384
|
+
) -> TaskRunner:
|
|
385
|
+
"""
|
|
386
|
+
Open an existing run.
|
|
387
|
+
|
|
388
|
+
If this Python process already has the run open, the existing object is
|
|
389
|
+
returned. This is important: creating a second SQLiteAckQueue with
|
|
390
|
+
auto_resume=True while the first runner is actively executing could
|
|
391
|
+
reset its in-flight row.
|
|
392
|
+
"""
|
|
393
|
+
name = _validate_name(name)
|
|
394
|
+
root_path = _normalize_root(root)
|
|
395
|
+
path = _run_path(name, root_path)
|
|
396
|
+
key = _registry_key(path)
|
|
397
|
+
|
|
398
|
+
with _REGISTRY_LOCK:
|
|
399
|
+
existing = _INSTANCES.get(key)
|
|
400
|
+
if existing is not None and not existing._closed:
|
|
401
|
+
return existing
|
|
402
|
+
|
|
403
|
+
meta_path = path / "meta.json"
|
|
404
|
+
if not meta_path.exists():
|
|
405
|
+
raise TaskRunNotFoundError(f"Task run {name!r} does not exist")
|
|
406
|
+
|
|
407
|
+
metadata = _read_json(meta_path)
|
|
408
|
+
|
|
409
|
+
if metadata.get("schema_version") != _SCHEMA_VERSION:
|
|
410
|
+
raise TaskRunnerError(
|
|
411
|
+
f"Unsupported TaskRunner schema version: "
|
|
412
|
+
f"{metadata.get('schema_version')!r}"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if metadata.get("name") != name:
|
|
416
|
+
raise TaskRunnerError(
|
|
417
|
+
f"TaskRunner metadata name mismatch in {meta_path}"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
runner = cls(
|
|
421
|
+
name=name,
|
|
422
|
+
root=root_path,
|
|
423
|
+
metadata=metadata,
|
|
424
|
+
auto_resume=True,
|
|
425
|
+
)
|
|
426
|
+
_INSTANCES[key] = runner
|
|
427
|
+
|
|
428
|
+
counts = runner._counts()
|
|
429
|
+
previous_state = str(metadata.get("state", "idle"))
|
|
430
|
+
|
|
431
|
+
if counts["total"] and (
|
|
432
|
+
counts["completed"] + counts["failed"] == counts["total"]
|
|
433
|
+
):
|
|
434
|
+
state = "completed"
|
|
435
|
+
elif previous_state == "paused":
|
|
436
|
+
state = "paused"
|
|
437
|
+
elif previous_state == "idle":
|
|
438
|
+
state = "idle"
|
|
439
|
+
else:
|
|
440
|
+
# A previous process said it was running (or was already marked
|
|
441
|
+
# interrupted), but this process has no worker for it.
|
|
442
|
+
state = "interrupted"
|
|
443
|
+
|
|
444
|
+
runner._refresh_meta(state=state)
|
|
445
|
+
return runner
|
|
446
|
+
|
|
447
|
+
@classmethod
|
|
448
|
+
def list(
|
|
449
|
+
cls,
|
|
450
|
+
*,
|
|
451
|
+
root: str | os.PathLike[str] | None = None,
|
|
452
|
+
) -> list[dict[str, Any]]:
|
|
453
|
+
"""
|
|
454
|
+
List persisted runs without reopening their queues.
|
|
455
|
+
|
|
456
|
+
Reading meta.json rather than constructing SQLiteAckQueue instances is
|
|
457
|
+
intentional: list() must never trigger auto-resume on a live run.
|
|
458
|
+
"""
|
|
459
|
+
root_path = _normalize_root(root)
|
|
460
|
+
if not root_path.exists():
|
|
461
|
+
return []
|
|
462
|
+
|
|
463
|
+
result: list[dict[str, Any]] = []
|
|
464
|
+
|
|
465
|
+
for path in root_path.iterdir():
|
|
466
|
+
if not path.is_dir():
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
meta_path = path / "meta.json"
|
|
470
|
+
if not meta_path.is_file():
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
meta = _read_json(meta_path)
|
|
475
|
+
except Exception:
|
|
476
|
+
log.exception(
|
|
477
|
+
"TaskRunner: could not read metadata from %s",
|
|
478
|
+
meta_path,
|
|
479
|
+
)
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
key = _registry_key(path)
|
|
483
|
+
with _REGISTRY_LOCK:
|
|
484
|
+
live = _INSTANCES.get(key)
|
|
485
|
+
|
|
486
|
+
if live is not None and not live._closed:
|
|
487
|
+
try:
|
|
488
|
+
result.append(live.status())
|
|
489
|
+
continue
|
|
490
|
+
except Exception:
|
|
491
|
+
log.exception(
|
|
492
|
+
"TaskRunner: could not inspect live run %s",
|
|
493
|
+
path.name,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
summary = dict(meta.get("summary") or {})
|
|
497
|
+
state = str(meta.get("state", "idle"))
|
|
498
|
+
|
|
499
|
+
# Active states in persisted metadata cannot still be active in
|
|
500
|
+
# this process if no registered live instance exists.
|
|
501
|
+
if state in {"running", "setting_up"}:
|
|
502
|
+
state = "interrupted"
|
|
503
|
+
|
|
504
|
+
result.append(
|
|
505
|
+
{
|
|
506
|
+
"name": meta.get("name", path.name),
|
|
507
|
+
"state": state,
|
|
508
|
+
"max_attempts": meta.get("max_attempts"),
|
|
509
|
+
"exclusive": meta.get("exclusive", True),
|
|
510
|
+
"created_at": meta.get("created_at"),
|
|
511
|
+
"updated_at": meta.get("updated_at"),
|
|
512
|
+
"metadata": copy.deepcopy(meta.get("metadata") or {}),
|
|
513
|
+
**summary,
|
|
514
|
+
}
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
result.sort(
|
|
518
|
+
key=lambda item: str(item.get("updated_at") or ""),
|
|
519
|
+
reverse=True,
|
|
520
|
+
)
|
|
521
|
+
return result
|
|
522
|
+
|
|
523
|
+
def close(self) -> None:
|
|
524
|
+
"""
|
|
525
|
+
Close local SQLite resources.
|
|
526
|
+
|
|
527
|
+
A running worker cannot be closed. Closing does not delete the run.
|
|
528
|
+
"""
|
|
529
|
+
with self._lock:
|
|
530
|
+
self._ensure_open()
|
|
531
|
+
if self.is_running():
|
|
532
|
+
raise TaskRunnerAlreadyRunningError("Cannot close a running TaskRunner")
|
|
533
|
+
|
|
534
|
+
self._queue.close()
|
|
535
|
+
self._closed = True
|
|
536
|
+
|
|
537
|
+
key = _registry_key(self.path)
|
|
538
|
+
with _REGISTRY_LOCK:
|
|
539
|
+
if _INSTANCES.get(key) is self:
|
|
540
|
+
_INSTANCES.pop(key, None)
|
|
541
|
+
|
|
542
|
+
def delete(self) -> None:
|
|
543
|
+
"""
|
|
544
|
+
Permanently delete this run and all stored tasks/results.
|
|
545
|
+
|
|
546
|
+
The runner must not be active.
|
|
547
|
+
"""
|
|
548
|
+
with self._lock:
|
|
549
|
+
self._ensure_open()
|
|
550
|
+
if self.is_running():
|
|
551
|
+
raise TaskRunnerAlreadyRunningError(
|
|
552
|
+
"Cannot delete a running TaskRunner"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
path = self.path
|
|
556
|
+
self.close()
|
|
557
|
+
shutil.rmtree(path)
|
|
558
|
+
|
|
559
|
+
def __enter__(self) -> TaskRunner:
|
|
560
|
+
self._ensure_open()
|
|
561
|
+
return self
|
|
562
|
+
|
|
563
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
564
|
+
self.close()
|
|
565
|
+
|
|
566
|
+
# ------------------------------------------------------------------
|
|
567
|
+
# Submission and execution
|
|
568
|
+
# ------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
def add(self, tasks: Iterable[Mapping[str, Any]]) -> int:
|
|
571
|
+
"""
|
|
572
|
+
Durably add tasks.
|
|
573
|
+
|
|
574
|
+
Each task must be a mapping containing a non-empty string ``id``.
|
|
575
|
+
Duplicate ids are rejected across the whole run.
|
|
576
|
+
|
|
577
|
+
Validation failures write nothing. Storage failures may commit a prefix;
|
|
578
|
+
TaskSubmissionError reports confirmed IDs. Inspect get(id) before retrying.
|
|
579
|
+
Returns the number of tasks added.
|
|
580
|
+
"""
|
|
581
|
+
with self._lock:
|
|
582
|
+
self._ensure_open()
|
|
583
|
+
|
|
584
|
+
if self.is_running():
|
|
585
|
+
raise TaskRunnerAlreadyRunningError(
|
|
586
|
+
"Cannot add tasks while the runner is active"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
existing_ids = {
|
|
590
|
+
record["task_id"]
|
|
591
|
+
for record in self._all_queue_records()
|
|
592
|
+
if isinstance(record.get("task_id"), str)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
prepared: list[dict[str, Any]] = []
|
|
596
|
+
submitted_ids: set[str] = set()
|
|
597
|
+
|
|
598
|
+
for original in tasks:
|
|
599
|
+
if not isinstance(original, Mapping):
|
|
600
|
+
raise TypeError(
|
|
601
|
+
f"Task must be a mapping, got {type(original).__name__}"
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
task = _plain_value(dict(original))
|
|
605
|
+
task_id = task.get("id")
|
|
606
|
+
|
|
607
|
+
if not isinstance(task_id, str) or not task_id:
|
|
608
|
+
raise ValueError("Each task must contain a non-empty string 'id'")
|
|
609
|
+
|
|
610
|
+
if task_id in existing_ids or task_id in submitted_ids:
|
|
611
|
+
raise DuplicateTaskError(f"Duplicate task id: {task_id!r}")
|
|
612
|
+
|
|
613
|
+
prepared.append(
|
|
614
|
+
{
|
|
615
|
+
"schema_version": _SCHEMA_VERSION,
|
|
616
|
+
"task_id": task_id,
|
|
617
|
+
"task": task,
|
|
618
|
+
"created_at": _utc_now(),
|
|
619
|
+
"attempts": [],
|
|
620
|
+
"result_present": False,
|
|
621
|
+
"result": None,
|
|
622
|
+
"completed_at": None,
|
|
623
|
+
}
|
|
624
|
+
)
|
|
625
|
+
submitted_ids.add(task_id)
|
|
626
|
+
|
|
627
|
+
confirmed_ids = []
|
|
628
|
+
try:
|
|
629
|
+
for record in prepared:
|
|
630
|
+
self._queue.put(record)
|
|
631
|
+
confirmed_ids.append(record["task_id"])
|
|
632
|
+
|
|
633
|
+
if prepared:
|
|
634
|
+
self._pause_requested.clear()
|
|
635
|
+
self._worker_error = None
|
|
636
|
+
self._refresh_meta(state="idle")
|
|
637
|
+
except Exception as exc:
|
|
638
|
+
raise TaskSubmissionError(confirmed_ids) from exc
|
|
639
|
+
|
|
640
|
+
return len(prepared)
|
|
641
|
+
|
|
642
|
+
def start(
|
|
643
|
+
self,
|
|
644
|
+
execute: Callable[[dict[str, Any], Any], Any],
|
|
645
|
+
*,
|
|
646
|
+
setup: Callable[[], Any] | None = None,
|
|
647
|
+
) -> TaskRunner:
|
|
648
|
+
"""
|
|
649
|
+
Start/resume sequential execution in a daemon background thread.
|
|
650
|
+
|
|
651
|
+
The method returns immediately.
|
|
652
|
+
|
|
653
|
+
``setup()``, when supplied, runs once in the worker thread before any
|
|
654
|
+
task is claimed. Its return value is passed unchanged to every
|
|
655
|
+
``execute(task, context)`` call in that worker session. Without setup,
|
|
656
|
+
context is ``None``. Executors must return plain JSON-compatible data.
|
|
657
|
+
Executor and result-validation errors consume the max_attempts failure
|
|
658
|
+
budget. Interrupted executions do not consume it. Retries run after
|
|
659
|
+
later ready rows, wrapping to the beginning when needed.
|
|
660
|
+
|
|
661
|
+
The runner does not retain or clean up context after the worker exits.
|
|
662
|
+
Applications remain responsible for deterministic resource cleanup.
|
|
663
|
+
"""
|
|
664
|
+
if not callable(execute):
|
|
665
|
+
raise TypeError("execute must be callable")
|
|
666
|
+
if setup is not None and not callable(setup):
|
|
667
|
+
raise TypeError("setup must be callable or None")
|
|
668
|
+
|
|
669
|
+
with self._lock:
|
|
670
|
+
self._ensure_open()
|
|
671
|
+
|
|
672
|
+
if self.is_running():
|
|
673
|
+
raise TaskRunnerAlreadyRunningError(
|
|
674
|
+
f"Task run {self.name!r} is already running"
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# Repair a stale unack row left by an unexpected worker-thread
|
|
678
|
+
# failure in the same process, then repair any committed-but-not-
|
|
679
|
+
# acked records.
|
|
680
|
+
self._queue.resume_unack_tasks()
|
|
681
|
+
self._recover_committed_records()
|
|
682
|
+
|
|
683
|
+
counts = self._counts()
|
|
684
|
+
if counts["pending"] == 0:
|
|
685
|
+
state = "completed" if counts["total"] > 0 else "idle"
|
|
686
|
+
self._refresh_meta(state=state)
|
|
687
|
+
return self
|
|
688
|
+
|
|
689
|
+
self._claim_execution()
|
|
690
|
+
|
|
691
|
+
try:
|
|
692
|
+
self._pause_requested.clear()
|
|
693
|
+
self._worker_error = None
|
|
694
|
+
self._current_task_id = None
|
|
695
|
+
self._current_pqid = None
|
|
696
|
+
initial_state = "setting_up" if setup is not None else "running"
|
|
697
|
+
self._refresh_meta(state=initial_state)
|
|
698
|
+
|
|
699
|
+
thread = threading.Thread(
|
|
700
|
+
target=self._run_loop,
|
|
701
|
+
args=(execute, setup),
|
|
702
|
+
name=f"TaskRunner[{self.name}]",
|
|
703
|
+
daemon=True,
|
|
704
|
+
)
|
|
705
|
+
self._thread = thread
|
|
706
|
+
|
|
707
|
+
thread.start()
|
|
708
|
+
except Exception:
|
|
709
|
+
self._thread = None
|
|
710
|
+
self._release_execution()
|
|
711
|
+
self._state = "interrupted"
|
|
712
|
+
try:
|
|
713
|
+
self._refresh_meta(state="interrupted")
|
|
714
|
+
except Exception:
|
|
715
|
+
log.exception(
|
|
716
|
+
"TaskRunner %s: could not persist start failure",
|
|
717
|
+
self.name,
|
|
718
|
+
)
|
|
719
|
+
raise
|
|
720
|
+
|
|
721
|
+
return self
|
|
722
|
+
|
|
723
|
+
def pause(self) -> None:
|
|
724
|
+
"""
|
|
725
|
+
Request a graceful pause.
|
|
726
|
+
|
|
727
|
+
The current execute() call is not cancelled. The worker finishes the
|
|
728
|
+
current attempt and stops before taking another task.
|
|
729
|
+
"""
|
|
730
|
+
with self._lock:
|
|
731
|
+
self._ensure_open()
|
|
732
|
+
|
|
733
|
+
if self.is_running():
|
|
734
|
+
self._pause_requested.set()
|
|
735
|
+
else:
|
|
736
|
+
counts = self._counts()
|
|
737
|
+
if counts["pending"] > 0:
|
|
738
|
+
self._refresh_meta(state="paused")
|
|
739
|
+
|
|
740
|
+
def wait(self, timeout: float | None = None) -> bool:
|
|
741
|
+
"""
|
|
742
|
+
Wait for the current worker thread.
|
|
743
|
+
|
|
744
|
+
Returns True if it is no longer running, False on timeout.
|
|
745
|
+
"""
|
|
746
|
+
with self._lock:
|
|
747
|
+
self._ensure_open()
|
|
748
|
+
thread = self._thread
|
|
749
|
+
|
|
750
|
+
if thread is None:
|
|
751
|
+
return True
|
|
752
|
+
|
|
753
|
+
thread.join(timeout)
|
|
754
|
+
return not thread.is_alive()
|
|
755
|
+
|
|
756
|
+
def is_running(self) -> bool:
|
|
757
|
+
thread = self._thread
|
|
758
|
+
return thread is not None and thread.is_alive()
|
|
759
|
+
|
|
760
|
+
# ------------------------------------------------------------------
|
|
761
|
+
# Inspection
|
|
762
|
+
# ------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
@property
|
|
765
|
+
def metadata(self) -> dict[str, Any]:
|
|
766
|
+
return copy.deepcopy(self._user_metadata)
|
|
767
|
+
|
|
768
|
+
def status(self) -> dict[str, Any]:
|
|
769
|
+
"""Return a small plain dictionary suitable for ``%there get``."""
|
|
770
|
+
with self._lock:
|
|
771
|
+
self._ensure_open()
|
|
772
|
+
|
|
773
|
+
counts = self._counts()
|
|
774
|
+
|
|
775
|
+
if self.is_running():
|
|
776
|
+
state = "setting_up" if self._state == "setting_up" else "running"
|
|
777
|
+
elif (
|
|
778
|
+
counts["total"] > 0
|
|
779
|
+
and counts["completed"] + counts["failed"] == counts["total"]
|
|
780
|
+
):
|
|
781
|
+
state = "completed"
|
|
782
|
+
else:
|
|
783
|
+
state = self._state
|
|
784
|
+
if state in {"running", "setting_up"}:
|
|
785
|
+
state = "interrupted"
|
|
786
|
+
|
|
787
|
+
return {
|
|
788
|
+
"name": self.name,
|
|
789
|
+
"state": state,
|
|
790
|
+
**counts,
|
|
791
|
+
"current": self._current_task_id,
|
|
792
|
+
"pause_requested": self._pause_requested.is_set(),
|
|
793
|
+
"max_attempts": self.max_attempts,
|
|
794
|
+
"exclusive": self.exclusive,
|
|
795
|
+
"created_at": self._created_at,
|
|
796
|
+
"updated_at": self._meta_updated_at(),
|
|
797
|
+
"metadata": copy.deepcopy(self._user_metadata),
|
|
798
|
+
"worker_error": copy.deepcopy(self._worker_error),
|
|
799
|
+
"path": str(self.path),
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
def results(self) -> list[dict[str, Any]]:
|
|
803
|
+
"""
|
|
804
|
+
Return all task records in submission order.
|
|
805
|
+
|
|
806
|
+
This intentionally includes pending/running/failed tasks so the caller
|
|
807
|
+
can inspect a run before it is complete.
|
|
808
|
+
"""
|
|
809
|
+
with self._lock:
|
|
810
|
+
self._ensure_open()
|
|
811
|
+
rows = sorted(self._queue.queue(), key=lambda row: row["id"])
|
|
812
|
+
return [self._public_record(row) for row in rows]
|
|
813
|
+
|
|
814
|
+
def export_results(self, path: str | os.PathLike[str]) -> Path:
|
|
815
|
+
"""Atomically save a results snapshot as UTF-8 JSON and return its path.
|
|
816
|
+
|
|
817
|
+
Includes all task records in submission order, just like results().
|
|
818
|
+
Relative paths use the current working directory (PythonHere's upload
|
|
819
|
+
directory by default), for retrieval with ``%there download``.
|
|
820
|
+
Parent directories are created and an existing file is replaced only
|
|
821
|
+
after a successful write. Run with ``%%there --worker`` for large runs.
|
|
822
|
+
|
|
823
|
+
This avoids a large ``%there get`` response, but still materializes the
|
|
824
|
+
complete results snapshot in device memory.
|
|
825
|
+
"""
|
|
826
|
+
snapshot = self.results()
|
|
827
|
+
destination = Path(path)
|
|
828
|
+
_atomic_write_json(destination, snapshot)
|
|
829
|
+
return destination
|
|
830
|
+
|
|
831
|
+
def failed(self) -> list[dict[str, Any]]:
|
|
832
|
+
"""Return permanently failed task records."""
|
|
833
|
+
return [record for record in self.results() if record["status"] == "failed"]
|
|
834
|
+
|
|
835
|
+
def get(self, task_id: str) -> dict[str, Any] | None:
|
|
836
|
+
"""Return one task record by logical id, or None if not found."""
|
|
837
|
+
if not isinstance(task_id, str):
|
|
838
|
+
raise TypeError("task_id must be a string")
|
|
839
|
+
|
|
840
|
+
with self._lock:
|
|
841
|
+
self._ensure_open()
|
|
842
|
+
|
|
843
|
+
for row in self._queue.queue():
|
|
844
|
+
data = row.get("data")
|
|
845
|
+
if isinstance(data, dict) and data.get("task_id") == task_id:
|
|
846
|
+
return self._public_record(row)
|
|
847
|
+
|
|
848
|
+
return None
|
|
849
|
+
|
|
850
|
+
# ------------------------------------------------------------------
|
|
851
|
+
# Worker internals
|
|
852
|
+
# ------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
def _run_loop( # noqa: PLR0912, PLR0915 # pylint: disable=too-many-locals,too-many-branches,too-many-statements
|
|
855
|
+
self,
|
|
856
|
+
execute: Callable[[dict[str, Any], Any], Any],
|
|
857
|
+
setup: Callable[[], Any] | None,
|
|
858
|
+
) -> None:
|
|
859
|
+
final_state = "interrupted"
|
|
860
|
+
|
|
861
|
+
previous_id = None
|
|
862
|
+
phase = "setup"
|
|
863
|
+
try:
|
|
864
|
+
context = setup() if setup is not None else None
|
|
865
|
+
phase = "execution"
|
|
866
|
+
|
|
867
|
+
if setup is not None:
|
|
868
|
+
with self._lock:
|
|
869
|
+
self._refresh_meta(state="running")
|
|
870
|
+
|
|
871
|
+
while True:
|
|
872
|
+
with self._lock:
|
|
873
|
+
if self._pause_requested.is_set():
|
|
874
|
+
break
|
|
875
|
+
try:
|
|
876
|
+
item = self._queue.get(
|
|
877
|
+
block=False,
|
|
878
|
+
raw=True,
|
|
879
|
+
id=previous_id,
|
|
880
|
+
next_in_order=previous_id is not None,
|
|
881
|
+
)
|
|
882
|
+
except Empty:
|
|
883
|
+
final_state = "completed"
|
|
884
|
+
break
|
|
885
|
+
self._current_pqid = int(item["pqid"])
|
|
886
|
+
|
|
887
|
+
pqid = int(item["pqid"])
|
|
888
|
+
previous_id = pqid
|
|
889
|
+
record = item["data"]
|
|
890
|
+
|
|
891
|
+
if not isinstance(record, dict):
|
|
892
|
+
# This should never happen for a TaskRunner-created queue.
|
|
893
|
+
self._queue.ack_failed(id=pqid)
|
|
894
|
+
log.error(
|
|
895
|
+
"TaskRunner %s: invalid queue record %r",
|
|
896
|
+
self.name,
|
|
897
|
+
record,
|
|
898
|
+
)
|
|
899
|
+
continue
|
|
900
|
+
|
|
901
|
+
task = record.get("task")
|
|
902
|
+
task_id = record.get("task_id")
|
|
903
|
+
|
|
904
|
+
if not isinstance(task, dict) or not isinstance(task_id, str):
|
|
905
|
+
self._queue.ack_failed(id=pqid)
|
|
906
|
+
log.error(
|
|
907
|
+
"TaskRunner %s: invalid task record pqid=%s",
|
|
908
|
+
self.name,
|
|
909
|
+
pqid,
|
|
910
|
+
)
|
|
911
|
+
continue
|
|
912
|
+
|
|
913
|
+
attempt_number = len(record.get("attempts") or []) + 1
|
|
914
|
+
attempt = {
|
|
915
|
+
"number": attempt_number,
|
|
916
|
+
"started_at": _utc_now(),
|
|
917
|
+
"finished_at": None,
|
|
918
|
+
"duration": None,
|
|
919
|
+
"error": None,
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
record.setdefault("attempts", []).append(attempt)
|
|
923
|
+
|
|
924
|
+
with self._lock:
|
|
925
|
+
self._current_task_id = task_id
|
|
926
|
+
self._current_pqid = pqid
|
|
927
|
+
# Commit "attempt started" before entering arbitrary user
|
|
928
|
+
# code. A process kill from this point onward is visible
|
|
929
|
+
# during recovery.
|
|
930
|
+
self._queue.update({"pqid": pqid, "data": record})
|
|
931
|
+
self._refresh_meta(state="running")
|
|
932
|
+
|
|
933
|
+
started = time.monotonic()
|
|
934
|
+
log.info(
|
|
935
|
+
"TaskRunner %s: starting %s attempt %d/%d",
|
|
936
|
+
self.name,
|
|
937
|
+
task_id,
|
|
938
|
+
attempt_number,
|
|
939
|
+
self.max_attempts,
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
try:
|
|
943
|
+
result = _plain_value(execute(copy.deepcopy(task), context))
|
|
944
|
+
except Exception as exc:
|
|
945
|
+
duration = time.monotonic() - started
|
|
946
|
+
|
|
947
|
+
with self._lock:
|
|
948
|
+
attempt["finished_at"] = _utc_now()
|
|
949
|
+
attempt["duration"] = duration
|
|
950
|
+
attempt["error"] = {
|
|
951
|
+
"type": type(exc).__name__,
|
|
952
|
+
"message": str(exc),
|
|
953
|
+
"traceback": traceback.format_exc(),
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
record["last_error"] = copy.deepcopy(attempt["error"])
|
|
957
|
+
self._queue.update({"pqid": pqid, "data": record})
|
|
958
|
+
|
|
959
|
+
failure_count = self._failure_count(record)
|
|
960
|
+
|
|
961
|
+
if failure_count >= self.max_attempts:
|
|
962
|
+
self._queue.ack_failed(id=pqid)
|
|
963
|
+
log.exception(
|
|
964
|
+
"TaskRunner %s: task %s failed "
|
|
965
|
+
"permanently after %d failures",
|
|
966
|
+
self.name,
|
|
967
|
+
task_id,
|
|
968
|
+
failure_count,
|
|
969
|
+
)
|
|
970
|
+
else:
|
|
971
|
+
self._queue.nack(id=pqid)
|
|
972
|
+
log.warning(
|
|
973
|
+
"TaskRunner %s: task %s failed "
|
|
974
|
+
"(failure %d/%d, execution attempt %d): %s",
|
|
975
|
+
self.name,
|
|
976
|
+
task_id,
|
|
977
|
+
failure_count,
|
|
978
|
+
self.max_attempts,
|
|
979
|
+
attempt_number,
|
|
980
|
+
exc,
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
self._current_task_id = None
|
|
984
|
+
self._current_pqid = None
|
|
985
|
+
self._refresh_meta(state="running")
|
|
986
|
+
|
|
987
|
+
else:
|
|
988
|
+
duration = time.monotonic() - started
|
|
989
|
+
|
|
990
|
+
with self._lock:
|
|
991
|
+
attempt["finished_at"] = _utc_now()
|
|
992
|
+
attempt["duration"] = duration
|
|
993
|
+
record["result"] = result
|
|
994
|
+
record["result_present"] = True
|
|
995
|
+
record["completed_at"] = _utc_now()
|
|
996
|
+
record.pop("last_error", None)
|
|
997
|
+
|
|
998
|
+
# Commit result before ack. If the process dies in the
|
|
999
|
+
# tiny window before ack(), open() recognizes this
|
|
1000
|
+
# durable completion and repairs the queue status.
|
|
1001
|
+
self._queue.update({"pqid": pqid, "data": record})
|
|
1002
|
+
self._queue.ack(id=pqid)
|
|
1003
|
+
|
|
1004
|
+
self._current_task_id = None
|
|
1005
|
+
self._current_pqid = None
|
|
1006
|
+
self._refresh_meta(state="running")
|
|
1007
|
+
|
|
1008
|
+
log.info(
|
|
1009
|
+
"TaskRunner %s: completed %s in %.3fs",
|
|
1010
|
+
self.name,
|
|
1011
|
+
task_id,
|
|
1012
|
+
duration,
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
if self._pause_requested.is_set():
|
|
1016
|
+
final_state = "paused"
|
|
1017
|
+
|
|
1018
|
+
except BaseException as exc:
|
|
1019
|
+
# Unexpected failures of runner machinery should not lose the
|
|
1020
|
+
# currently claimed queue item. Return it to READY when possible.
|
|
1021
|
+
if phase == "setup":
|
|
1022
|
+
log.exception("TaskRunner %s: setup failed", self.name)
|
|
1023
|
+
else:
|
|
1024
|
+
log.exception(
|
|
1025
|
+
"TaskRunner %s: worker stopped unexpectedly",
|
|
1026
|
+
self.name,
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
with self._lock:
|
|
1030
|
+
self._worker_error = {
|
|
1031
|
+
"type": type(exc).__name__,
|
|
1032
|
+
"message": str(exc),
|
|
1033
|
+
"traceback": traceback.format_exc(),
|
|
1034
|
+
"time": _utc_now(),
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if self._current_pqid is not None:
|
|
1038
|
+
try:
|
|
1039
|
+
self._queue.nack(id=self._current_pqid)
|
|
1040
|
+
except Exception:
|
|
1041
|
+
log.exception(
|
|
1042
|
+
"TaskRunner %s: could not nack interrupted pqid=%s",
|
|
1043
|
+
self.name,
|
|
1044
|
+
self._current_pqid,
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
self._current_task_id = None
|
|
1048
|
+
self._current_pqid = None
|
|
1049
|
+
|
|
1050
|
+
final_state = "interrupted"
|
|
1051
|
+
|
|
1052
|
+
finally:
|
|
1053
|
+
with self._lock:
|
|
1054
|
+
self._current_task_id = None
|
|
1055
|
+
self._current_pqid = None
|
|
1056
|
+
|
|
1057
|
+
# If no runnable work is left, terminal status wins over
|
|
1058
|
+
# "paused".
|
|
1059
|
+
try:
|
|
1060
|
+
counts = self._counts()
|
|
1061
|
+
if (
|
|
1062
|
+
counts["total"] > 0
|
|
1063
|
+
and counts["completed"] + counts["failed"] == counts["total"]
|
|
1064
|
+
):
|
|
1065
|
+
final_state = "completed"
|
|
1066
|
+
except Exception:
|
|
1067
|
+
log.exception(
|
|
1068
|
+
"TaskRunner %s: could not compute final status",
|
|
1069
|
+
self.name,
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
try:
|
|
1073
|
+
self._refresh_meta(state=final_state)
|
|
1074
|
+
except Exception:
|
|
1075
|
+
log.exception(
|
|
1076
|
+
"TaskRunner %s: could not persist final metadata",
|
|
1077
|
+
self.name,
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
self._release_execution()
|
|
1081
|
+
|
|
1082
|
+
# ------------------------------------------------------------------
|
|
1083
|
+
# Persistence helpers
|
|
1084
|
+
# ------------------------------------------------------------------
|
|
1085
|
+
|
|
1086
|
+
def _recover_committed_records(self) -> None:
|
|
1087
|
+
"""
|
|
1088
|
+
Repair READY rows whose result/final failure was already persisted.
|
|
1089
|
+
|
|
1090
|
+
SQLiteAckQueue auto-resume intentionally turns old UNACK rows back to
|
|
1091
|
+
READY. Usually they should be rerun. The exception is when TaskRunner
|
|
1092
|
+
already committed a successful result or the final failed attempt and
|
|
1093
|
+
the old process died immediately before ack()/ack_failed().
|
|
1094
|
+
"""
|
|
1095
|
+
for row in self._queue.queue():
|
|
1096
|
+
if str(row.get("status")) not in {
|
|
1097
|
+
_PQ_INITED,
|
|
1098
|
+
_PQ_READY,
|
|
1099
|
+
}:
|
|
1100
|
+
continue
|
|
1101
|
+
|
|
1102
|
+
pqid = int(row["id"])
|
|
1103
|
+
record = row.get("data")
|
|
1104
|
+
|
|
1105
|
+
if not isinstance(record, dict):
|
|
1106
|
+
continue
|
|
1107
|
+
|
|
1108
|
+
if record.get("result_present") is True and record.get("completed_at"):
|
|
1109
|
+
self._queue.ack(id=pqid)
|
|
1110
|
+
continue
|
|
1111
|
+
|
|
1112
|
+
attempts = record.get("attempts")
|
|
1113
|
+
if not isinstance(attempts, list) or not attempts:
|
|
1114
|
+
continue
|
|
1115
|
+
|
|
1116
|
+
last_attempt = attempts[-1]
|
|
1117
|
+
if not isinstance(last_attempt, dict):
|
|
1118
|
+
continue
|
|
1119
|
+
|
|
1120
|
+
# A process kill is an interrupted execution attempt, not an
|
|
1121
|
+
# executor failure. Preserve it in history, but do not consume the
|
|
1122
|
+
# max_attempts failure budget.
|
|
1123
|
+
if (
|
|
1124
|
+
last_attempt.get("started_at")
|
|
1125
|
+
and not last_attempt.get("finished_at")
|
|
1126
|
+
and last_attempt.get("error") is None
|
|
1127
|
+
and not last_attempt.get("interrupted_at")
|
|
1128
|
+
):
|
|
1129
|
+
last_attempt["interrupted_at"] = _utc_now()
|
|
1130
|
+
last_attempt["interrupted"] = True
|
|
1131
|
+
self._queue.update({"pqid": pqid, "data": record})
|
|
1132
|
+
|
|
1133
|
+
if self._failure_count(record) >= self.max_attempts:
|
|
1134
|
+
self._queue.ack_failed(id=pqid)
|
|
1135
|
+
|
|
1136
|
+
# ack() and ack_failed() normally follow get(), which already removed
|
|
1137
|
+
# the item from qsize(). Recovered items were put back into READY by
|
|
1138
|
+
# auto-resume, so recalculate the public pending counter after repair.
|
|
1139
|
+
self._queue.resume_unack_tasks()
|
|
1140
|
+
|
|
1141
|
+
@staticmethod
|
|
1142
|
+
def _failure_count(record: Mapping[str, Any]) -> int:
|
|
1143
|
+
attempts = record.get("attempts")
|
|
1144
|
+
if not isinstance(attempts, list):
|
|
1145
|
+
return 0
|
|
1146
|
+
|
|
1147
|
+
return sum(
|
|
1148
|
+
1
|
|
1149
|
+
for attempt in attempts
|
|
1150
|
+
if isinstance(attempt, dict)
|
|
1151
|
+
and attempt.get("finished_at")
|
|
1152
|
+
and attempt.get("error") is not None
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
def _all_queue_records(self) -> list[dict[str, Any]]:
|
|
1156
|
+
records: list[dict[str, Any]] = []
|
|
1157
|
+
|
|
1158
|
+
for row in self._queue.queue():
|
|
1159
|
+
data = row.get("data")
|
|
1160
|
+
if isinstance(data, dict):
|
|
1161
|
+
records.append(data)
|
|
1162
|
+
|
|
1163
|
+
return records
|
|
1164
|
+
|
|
1165
|
+
def _counts(self) -> dict[str, Any]:
|
|
1166
|
+
# Public count helpers do not deserialize prompts or results. qsize()
|
|
1167
|
+
# includes persist-queue's newly inserted and retry-ready rows.
|
|
1168
|
+
pending = self._queue.qsize()
|
|
1169
|
+
running = self._queue.unack_count()
|
|
1170
|
+
completed = self._queue.acked_count()
|
|
1171
|
+
failed = self._queue.ack_failed_count()
|
|
1172
|
+
|
|
1173
|
+
total = pending + running + completed + failed
|
|
1174
|
+
terminal = completed + failed
|
|
1175
|
+
|
|
1176
|
+
return {
|
|
1177
|
+
"total": total,
|
|
1178
|
+
"pending": pending,
|
|
1179
|
+
"running": running,
|
|
1180
|
+
"completed": completed,
|
|
1181
|
+
"failed": failed,
|
|
1182
|
+
"progress": (terminal / total) if total else 0.0,
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
def _public_record(
|
|
1186
|
+
self,
|
|
1187
|
+
row: Mapping[str, Any],
|
|
1188
|
+
) -> dict[str, Any]:
|
|
1189
|
+
record = row.get("data")
|
|
1190
|
+
|
|
1191
|
+
if not isinstance(record, dict):
|
|
1192
|
+
return {
|
|
1193
|
+
"id": None,
|
|
1194
|
+
"status": _status_name(row.get("status")),
|
|
1195
|
+
"task": None,
|
|
1196
|
+
"result": None,
|
|
1197
|
+
"attempt_count": 0,
|
|
1198
|
+
"attempts": [],
|
|
1199
|
+
"error": {
|
|
1200
|
+
"type": "TaskRunnerRecordError",
|
|
1201
|
+
"message": "Invalid persisted task record",
|
|
1202
|
+
},
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
attempts = copy.deepcopy(record.get("attempts") or [])
|
|
1206
|
+
|
|
1207
|
+
durations = [
|
|
1208
|
+
attempt.get("duration")
|
|
1209
|
+
for attempt in attempts
|
|
1210
|
+
if isinstance(attempt, dict)
|
|
1211
|
+
and isinstance(attempt.get("duration"), (int, float))
|
|
1212
|
+
]
|
|
1213
|
+
|
|
1214
|
+
result_present = bool(record.get("result_present"))
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
"id": record.get("task_id"),
|
|
1218
|
+
"status": _status_name(row.get("status")),
|
|
1219
|
+
"task": copy.deepcopy(record.get("task")),
|
|
1220
|
+
"result": (copy.deepcopy(record.get("result")) if result_present else None),
|
|
1221
|
+
"result_present": result_present,
|
|
1222
|
+
"attempt_count": len(attempts),
|
|
1223
|
+
"failure_count": self._failure_count(record),
|
|
1224
|
+
"attempts": attempts,
|
|
1225
|
+
"duration": durations[-1] if durations else None,
|
|
1226
|
+
"total_duration": sum(durations),
|
|
1227
|
+
"error": copy.deepcopy(record.get("last_error")),
|
|
1228
|
+
"created_at": record.get("created_at"),
|
|
1229
|
+
"completed_at": record.get("completed_at"),
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
def _refresh_meta(self, *, state: str | None = None) -> None:
|
|
1233
|
+
if state is not None:
|
|
1234
|
+
self._state = state
|
|
1235
|
+
|
|
1236
|
+
now = _utc_now()
|
|
1237
|
+
counts = self._counts()
|
|
1238
|
+
|
|
1239
|
+
meta = {
|
|
1240
|
+
"schema_version": _SCHEMA_VERSION,
|
|
1241
|
+
"name": self.name,
|
|
1242
|
+
"created_at": self._created_at,
|
|
1243
|
+
"updated_at": now,
|
|
1244
|
+
"max_attempts": self.max_attempts,
|
|
1245
|
+
"exclusive": self.exclusive,
|
|
1246
|
+
"state": self._state,
|
|
1247
|
+
"metadata": copy.deepcopy(self._user_metadata),
|
|
1248
|
+
"summary": counts,
|
|
1249
|
+
"worker_error": copy.deepcopy(self._worker_error),
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
_atomic_write_json(self._meta_path, meta)
|
|
1253
|
+
|
|
1254
|
+
def _meta_updated_at(self) -> str | None:
|
|
1255
|
+
try:
|
|
1256
|
+
return _read_json(self._meta_path).get("updated_at")
|
|
1257
|
+
except Exception:
|
|
1258
|
+
return None
|
|
1259
|
+
|
|
1260
|
+
# ------------------------------------------------------------------
|
|
1261
|
+
# Process-wide execution ownership
|
|
1262
|
+
# ------------------------------------------------------------------
|
|
1263
|
+
|
|
1264
|
+
def _claim_execution(self) -> None:
|
|
1265
|
+
if not self.exclusive:
|
|
1266
|
+
return
|
|
1267
|
+
|
|
1268
|
+
key = _registry_key(self.path)
|
|
1269
|
+
|
|
1270
|
+
with _REGISTRY_LOCK:
|
|
1271
|
+
owner = _EXECUTION_OWNER.get("path")
|
|
1272
|
+
if owner is not None and owner != key:
|
|
1273
|
+
owner_name = Path(owner).name
|
|
1274
|
+
raise TaskRunnerBusyError(
|
|
1275
|
+
f"Another exclusive task run is active: {owner_name!r}"
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
_EXECUTION_OWNER["path"] = key
|
|
1279
|
+
|
|
1280
|
+
def _release_execution(self) -> None:
|
|
1281
|
+
if not self.exclusive:
|
|
1282
|
+
return
|
|
1283
|
+
|
|
1284
|
+
key = _registry_key(self.path)
|
|
1285
|
+
|
|
1286
|
+
with _REGISTRY_LOCK:
|
|
1287
|
+
if _EXECUTION_OWNER.get("path") == key:
|
|
1288
|
+
_EXECUTION_OWNER.pop("path")
|
|
1289
|
+
|
|
1290
|
+
def _ensure_open(self) -> None:
|
|
1291
|
+
if self._closed:
|
|
1292
|
+
raise TaskRunnerClosedError(f"Task run {self.name!r} is closed")
|