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.
@@ -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")