pygpu-scheduling 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.5
2
+ Name: pygpu-scheduling
3
+ Version: 0.1.0
4
+ Summary: Reusable local CPU and NVIDIA GPU task scheduling with dependency graphs and recovery hooks
5
+ Project-URL: Source, https://github.com/AllanJinYosoro/PyGPUScheduling
6
+ Project-URL: Issues, https://github.com/AllanJinYosoro/PyGPUScheduling/issues
7
+ Project-URL: Changelog, https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/CHANGELOG.md
8
+ Author: PyGPUScheduling contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: gpu,nvidia,scheduler,subprocess,workflow
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: POSIX
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.11
18
+ Provides-Extra: progress
19
+ Requires-Dist: tqdm>=4.66; extra == 'progress'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # PyGPUScheduling
23
+
24
+ 单机 CPU 与 NVIDIA GPU 任务调度库。业务注册任务、依赖和生命周期处理器,调度器管理资源准入、子进程、重试及恢复。运行时仅依赖 Python 标准库,交互式进度条可选安装 `tqdm`。
25
+
26
+ 分发包名为 `pygpu-scheduling`,Python 导入名为 `task_scheduling`。发布状态见[维护指南](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/docs/maintaining.md#当前交接状态)。
27
+
28
+ ## 环境与安装
29
+
30
+ 需要 Python 3.11 或更新版本及 POSIX 系统。GPU 功能通过 `nvidia-smi` 工作,需要 NVIDIA 驱动;调度库不要求安装 CUDA Toolkit、Torch 或 Optuna。每个 GPU 任务使用一张卡,CPU 任务不查询 GPU。
31
+
32
+ 在本仓库开发:
33
+
34
+ ```bash
35
+ uv sync --locked
36
+ uv run pytest
37
+ uv run python examples/basic.py
38
+ ```
39
+
40
+ 需要交互式进度条时:
41
+
42
+ ```bash
43
+ uv sync --locked --extra progress
44
+ ```
45
+
46
+ 在其他项目中安装:
47
+
48
+ ```bash
49
+ uv add "pygpu-scheduling>=0.1.0,<0.2.0"
50
+ ```
51
+
52
+ GitHub 固定 tag 接入及本地联调方式见[维护指南](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/docs/maintaining.md#业务仓库接入)。
53
+
54
+ ## 注册任务
55
+
56
+ ```python
57
+ import sys
58
+
59
+ from task_scheduling import Scheduler, Scheduling, TaskSpec
60
+
61
+ scheduler = Scheduler("runs/example", Scheduling(max_parallel_tasks=2))
62
+ scheduler.submit_many([
63
+ TaskSpec("prepare", "command", payload={"args": [sys.executable, "prepare.py"]}),
64
+ TaskSpec("train", "command", dependencies=("prepare",),
65
+ payload={"args": [sys.executable, "train.py"]}),
66
+ ])
67
+ records = scheduler.run()
68
+ ```
69
+
70
+ `command` 是按退出码判定结果的内置处理器。`run()` 返回全部任务记录,调用方检查 `complete`、`failed`、`blocked` 等状态并决定业务退出码。
71
+
72
+ GPU 任务通过 `resources=ResourceRequest("cuda", 4096)` 声明 4096 MiB 预算。预算用于启动准入,不是显存硬限制。调度器不解释模型、训练轮数、业务参数或产物文件。
73
+
74
+ ## 功能与边界
75
+
76
+ - 静态依赖图与动态追加任务,拒绝缺失依赖、重复定义和依赖环。
77
+ - 按优先级、显存预算和提交顺序选择任务,支持共享或独占 GPU。
78
+ - 启动前预留显存,结合实际进程占用更新预算,保留每卡安全余量。
79
+ - 子进程日志、超时、有限重试、GPU 冷却和故障隔离。
80
+ - 注册 `prepare`、`assess`、`commit`、`recover`,由业务提供校验及产物流程。
81
+ - 持久化任务状态,完成事件按至少一次语义重放。
82
+
83
+ 当前提供单机本地子进程执行,不包含多机、抢占或单任务多卡调度。预算账本属于单个调度实例;跨实例不会合并预算。更完整的约束见[API 与恢复协议](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/docs/api.md)。
84
+
85
+ ## 文档与示例
86
+
87
+ - [API、调度策略和恢复协议](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/docs/api.md)
88
+ - [开发与发布](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/docs/maintaining.md)
89
+ - [变更记录](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/CHANGELOG.md)
90
+ - [依赖顺序示例](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/examples/basic.py)
91
+ - [业务产物处理示例](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/examples/artifacts.py)
92
+
93
+ 采用 [MIT 许可证](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/LICENSE)。
@@ -0,0 +1,13 @@
1
+ task_scheduling/__init__.py,sha256=E3Sk8j90tz1ny5EN1XbLTJVsTkxcnhhg8SBXYTeGhsU,513
2
+ task_scheduling/gpu.py,sha256=3rX6tfD0aLviDbdDx1osBTzDFSHDHeUSPGmPgnoC84w,3679
3
+ task_scheduling/memory.py,sha256=JTF-B5HtmnISzrmv_Cc91FljCtix4nAkKkl1cGgPROc,1940
4
+ task_scheduling/policy.py,sha256=-sMVPieIVx5qPxq6fhM-pT3ZyH6jHUF4mJn5IKeYZk0,2087
5
+ task_scheduling/progress.py,sha256=kS4x7ZDMwZSxwGrnh_x_9SR_HX0GQyKcfWhTcPqy3p4,5555
6
+ task_scheduling/runtime.py,sha256=YnHPljoZL-XaWBJWGvTQ2eZ5k-ma-0-q0Ilf-Y2JFI4,1332
7
+ task_scheduling/scheduler.py,sha256=Y3gzKyD64DlubsMnAqcWnQ0cMOtewEjIX6QvV9GMwmc,21607
8
+ task_scheduling/storage.py,sha256=q3gk8zt2uChTOPRlACQC0Z3fd8FAGl4GP7R0QD1f03g,1988
9
+ task_scheduling/task.py,sha256=Mo0pxnImk12_S_1pnGOX0vdWxM3BalcgfXjKHwHz6MI,6848
10
+ pygpu_scheduling-0.1.0.dist-info/METADATA,sha256=pADOEm7CSc3beNY5fwH5-kyZ14uTTVvODWVUbrzUJNM,4357
11
+ pygpu_scheduling-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ pygpu_scheduling-0.1.0.dist-info/licenses/LICENSE,sha256=Ni80rrOgBgnqG6v0XpmDABF5_g-w05-_v7mvlwrv6Hs,1085
13
+ pygpu_scheduling-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PyGPUScheduling contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ from .policy import Scheduling
2
+ from .runtime import LocalRuntime
3
+ from .scheduler import Scheduler
4
+ from .task import (
5
+ Assessment, Attempt, Command, CommandHandler, Recovery, ResourceRequest,
6
+ TaskContext, TaskEvent, TaskHandler, TaskOutcome, TaskRecord, TaskSpec,
7
+ )
8
+
9
+
10
+ __all__ = [
11
+ "Assessment", "Attempt", "Command", "CommandHandler", "LocalRuntime", "Recovery",
12
+ "ResourceRequest", "Scheduler", "Scheduling", "TaskContext", "TaskEvent", "TaskHandler",
13
+ "TaskOutcome", "TaskRecord", "TaskSpec",
14
+ ]
task_scheduling/gpu.py ADDED
@@ -0,0 +1,90 @@
1
+ from dataclasses import dataclass, field
2
+ import csv
3
+ import io
4
+ import os
5
+ import subprocess
6
+
7
+ from .policy import Scheduling
8
+
9
+
10
+ class GPUQueryError(RuntimeError):
11
+ pass
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class GPU:
16
+ index: int
17
+ uuid: str
18
+ free_mib: int
19
+ busy: bool = False
20
+ process_memory_mib: dict[int, int] = field(default_factory=dict)
21
+
22
+
23
+ def _query(fields: str, kind: str) -> str:
24
+ try:
25
+ result = subprocess.run(
26
+ ["nvidia-smi", f"--query-{kind}={fields}", "--format=csv,noheader,nounits"],
27
+ capture_output=True, text=True, timeout=5, check=False,
28
+ )
29
+ except (OSError, subprocess.TimeoutExpired) as error:
30
+ raise GPUQueryError(f"GPU query unavailable: {error}") from error
31
+ if result.returncode and (kind != "gpu" or not result.stdout.strip()):
32
+ raise GPUQueryError(result.stderr.strip() or "nvidia-smi query failed")
33
+ return result.stdout
34
+
35
+
36
+ def discover() -> list[GPU]:
37
+ devices = []
38
+ for row in csv.reader(io.StringIO(_query("index,uuid,memory.free", "gpu"))):
39
+ try:
40
+ index, uuid, free = [part.strip() for part in row]
41
+ if uuid.startswith("GPU-"):
42
+ devices.append(GPU(int(index), uuid, int(free)))
43
+ except (ValueError, TypeError):
44
+ continue
45
+ if not devices:
46
+ raise GPUQueryError("nvidia-smi returned no healthy GPU records")
47
+ processes = {}
48
+ for row in csv.reader(io.StringIO(_query("gpu_uuid,pid,used_gpu_memory", "compute-apps"))):
49
+ if len(row) == 3 and row[0].strip().startswith("GPU-"):
50
+ try:
51
+ uuid, pid, memory = [part.strip() for part in row]
52
+ used = 0 if memory in {"N/A", "[N/A]", "[Not Supported]"} else int(memory)
53
+ if int(pid) <= 0 or used < 0:
54
+ raise ValueError
55
+ processes.setdefault(uuid, {})[int(pid)] = used
56
+ except ValueError:
57
+ raise GPUQueryError("could not determine GPU process memory") from None
58
+ elif row and any(part.strip() for part in row) and row[0].strip() != "No running processes found":
59
+ raise GPUQueryError("could not parse GPU compute-process occupancy")
60
+ return [GPU(g.index, g.uuid, g.free_mib, g.uuid in processes, processes.get(g.uuid, {})) for g in devices]
61
+
62
+
63
+ def _matches(gpu: GPU, token: int | str) -> bool:
64
+ return str(gpu.index) == str(token) or (
65
+ isinstance(token, str) and token.startswith("GPU-") and gpu.uuid.startswith(token)
66
+ )
67
+
68
+
69
+ def visible_devices(devices: list[GPU], policy: Scheduling) -> list[GPU]:
70
+ visible = os.environ.get("CUDA_VISIBLE_DEVICES")
71
+ visible_tokens = None if visible is None else [part.strip() for part in visible.split(",")]
72
+ result = []
73
+ for gpu in devices:
74
+ if visible_tokens is not None and not any(_matches(gpu, token) for token in visible_tokens):
75
+ continue
76
+ if policy.allowed_gpus is not None and not any(_matches(gpu, token) for token in policy.allowed_gpus):
77
+ continue
78
+ result.append(gpu)
79
+ return result
80
+
81
+
82
+ def eligible(devices: list[GPU], policy: Scheduling, reserved: set[str],
83
+ cooldown: dict[str, float], quarantined: set[str], now: float,
84
+ memory_mib: int | None = None) -> list[GPU]:
85
+ minimum = policy.min_free_memory_mib if memory_mib is None else memory_mib
86
+ result = [gpu for gpu in visible_devices(devices, policy)
87
+ if gpu.uuid not in quarantined
88
+ and cooldown.get(gpu.uuid, 0) <= now and gpu.free_mib > minimum
89
+ and not (policy.require_idle and (gpu.busy or gpu.uuid in reserved))]
90
+ return sorted(result, key=lambda gpu: (-gpu.free_mib, gpu.index))
@@ -0,0 +1,45 @@
1
+ from dataclasses import dataclass, replace
2
+
3
+ from .gpu import GPU, eligible, visible_devices
4
+ from .policy import Scheduling
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Reservation:
9
+ gpu_uuid: str
10
+ memory_mib: int
11
+ pid: int | None = None
12
+
13
+
14
+ class MemoryPool:
15
+ def __init__(self, policy: Scheduling):
16
+ self.policy = policy
17
+ self.devices: list[GPU] = []
18
+ self.reservations: dict[int, Reservation] = {}
19
+
20
+ def refresh(self, devices: list[GPU]) -> None:
21
+ self.devices = visible_devices(devices, self.policy)
22
+
23
+ def available(self, gpu: GPU) -> int:
24
+ reserved = sum(max(0, reservation.memory_mib - gpu.process_memory_mib.get(reservation.pid, 0))
25
+ for reservation in self.reservations.values() if reservation.gpu_uuid == gpu.uuid)
26
+ return max(0, gpu.free_mib - self.policy.memory_margin_mib - reserved)
27
+
28
+ def candidates(self, memory_mib: int, cooldown: dict[str, float], quarantined: set[str],
29
+ now: float, last_gpu: str | None = None) -> list[GPU]:
30
+ reserved = {reservation.gpu_uuid for reservation in self.reservations.values()}
31
+ devices = eligible(self.devices, self.policy, reserved, cooldown, quarantined, now, memory_mib=0)
32
+ return sorted((gpu for gpu in devices if self.available(gpu) >= memory_mib),
33
+ key=lambda gpu: (gpu.uuid == last_gpu, self.available(gpu), gpu.index))
34
+
35
+ def reserve(self, identifier: int, gpu: GPU, memory_mib: int) -> None:
36
+ self.reservations[identifier] = Reservation(gpu.uuid, memory_mib)
37
+
38
+ def started(self, identifier: int, pid: int) -> None:
39
+ self.reservations[identifier] = replace(self.reservations[identifier], pid=pid)
40
+
41
+ def release(self, identifier: int) -> None:
42
+ self.reservations.pop(identifier, None)
43
+
44
+ def summary(self) -> str:
45
+ return ", ".join(f"GPU{gpu.index} available {self.available(gpu) / 1024:g}GiB" for gpu in self.devices)
@@ -0,0 +1,46 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ def integer(value: int, name: str, minimum: int = 0) -> None:
5
+ if type(value) is not int or value < minimum:
6
+ raise ValueError(f"{name} must be an integer >= {minimum}")
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Scheduling:
11
+ allowed_gpus: tuple[int | str, ...] | None = None
12
+ min_free_memory_mib: int = 4096
13
+ memory_margin_mib: int = 0
14
+ max_parallel_tasks: int = 3
15
+ require_idle: bool = False
16
+ max_retries: int = 3
17
+ poll_interval_seconds: int = 10
18
+ gpu_cooldown_seconds: int = 60
19
+ task_timeout_seconds: int = 0
20
+ wait_seconds: int = 3600
21
+
22
+ def __post_init__(self) -> None:
23
+ for name in ("min_free_memory_mib", "memory_margin_mib", "max_retries",
24
+ "gpu_cooldown_seconds", "task_timeout_seconds"):
25
+ integer(getattr(self, name), name)
26
+ for name in ("max_parallel_tasks", "poll_interval_seconds", "wait_seconds"):
27
+ integer(getattr(self, name), name, 1)
28
+ if type(self.require_idle) is not bool:
29
+ raise ValueError("require_idle must be a boolean")
30
+ if self.allowed_gpus is not None:
31
+ allowed = self.allowed_gpus
32
+ if not isinstance(allowed, (list, tuple)) or not allowed:
33
+ raise ValueError("allowed_gpus must be null or a nonempty list of GPU indices/UUIDs")
34
+ if any(not ((type(gpu) is int and gpu >= 0) or
35
+ (isinstance(gpu, str) and gpu.startswith("GPU-") and len(gpu) > 4))
36
+ for gpu in allowed):
37
+ raise ValueError("allowed_gpus entries must be nonnegative indices or GPU- UUIDs")
38
+ if len(set(allowed)) != len(allowed):
39
+ raise ValueError("allowed_gpus must not contain duplicates")
40
+ object.__setattr__(self, "allowed_gpus", tuple(allowed))
41
+
42
+ @classmethod
43
+ def from_settings(cls, settings: dict) -> "Scheduling":
44
+ if not isinstance(settings, dict) or set(settings) - cls.__dataclass_fields__.keys():
45
+ raise ValueError("Unknown scheduling settings")
46
+ return cls(**settings)
@@ -0,0 +1,131 @@
1
+ from contextlib import ExitStack
2
+ from dataclasses import dataclass
3
+ import shutil
4
+ import sys
5
+ import time
6
+ from typing import TextIO
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class WorkerStatus:
11
+ identifier: int
12
+ pid: int
13
+ device: str
14
+ attempt: int
15
+ current: int
16
+ total: int
17
+ phase: str
18
+ started: float
19
+ unit: str = "epoch"
20
+ memory_mib: int = 0
21
+
22
+
23
+ def duration(seconds: float) -> str:
24
+ seconds = max(0, int(seconds))
25
+ minutes, seconds = divmod(seconds, 60)
26
+ hours, minutes = divmod(minutes, 60)
27
+ days, hours = divmod(hours, 24)
28
+ if days:
29
+ return f"{days}d{hours:02d}h"
30
+ if hours:
31
+ return f"{hours}h{minutes:02d}m"
32
+ if minutes:
33
+ return f"{minutes}m{seconds:02d}s"
34
+ return f"{seconds}s"
35
+
36
+
37
+ class TaskProgress:
38
+
39
+ def __init__(self, records: list[dict], slots: int, max_retries: int,
40
+ *, label: str = "Tasks", stream: TextIO | None = None):
41
+ self.label = label
42
+ self.records, self.slots, self.max_attempts = records, slots, max_retries + 1
43
+ self.stream = sys.stderr if stream is None else stream
44
+ self.interactive = self.stream.isatty()
45
+ self.started = time.monotonic()
46
+ self.initial_completed = sum(record["status"] == "complete" for record in records)
47
+ self.slot_ids: list[int | None] = [None] * slots
48
+ self.lines: list = []
49
+ self.last_signature = None
50
+ self.last_printed = float("-inf")
51
+ self.stack = ExitStack()
52
+
53
+ def __enter__(self):
54
+ try:
55
+ from tqdm.auto import tqdm
56
+ except ImportError:
57
+ self.interactive = False
58
+ if self.interactive:
59
+ try:
60
+ for position in range(self.slots + 2):
61
+ self.lines.append(self.stack.enter_context(tqdm(
62
+ total=0, bar_format="{desc}", position=position,
63
+ leave=position == 0, dynamic_ncols=True, file=self.stream,
64
+ )))
65
+ except BaseException:
66
+ self.stack.close()
67
+ raise
68
+ return self
69
+
70
+ def __exit__(self, *exception):
71
+ return self.stack.__exit__(*exception)
72
+
73
+ def snapshot(self, workers: list[WorkerStatus], event: str, *, now: float,
74
+ width: int) -> list[str]:
75
+ by_id = {worker.identifier: worker for worker in workers}
76
+ self.slot_ids = [identifier if identifier in by_id else None for identifier in self.slot_ids]
77
+ for worker in workers:
78
+ if worker.identifier not in self.slot_ids:
79
+ self.slot_ids[self.slot_ids.index(None)] = worker.identifier
80
+
81
+ completed = sum(record["status"] == "complete" for record in self.records)
82
+ failed = sum(record["status"] in {"failed", "blocked"} for record in self.records)
83
+ pending = sum(record["status"] == "pending" for record in self.records)
84
+ retries = sum(max(0, len(record.get("attempts", [])) - 1) for record in self.records)
85
+ total = len(self.records)
86
+ elapsed = max(0, now - self.started)
87
+ fresh_completed = completed - self.initial_completed
88
+ remaining = total - completed - failed
89
+ if remaining == 0:
90
+ eta = "0s"
91
+ elif fresh_completed > 0 and elapsed > 0:
92
+ eta = "~" + duration(remaining * elapsed / fresh_completed)
93
+ else:
94
+ eta = "estimating"
95
+ count = f"{self.label} {completed}/{total}"
96
+ fraction = completed / total if total else 1
97
+ tail = (f" {fraction:.0%} | run{len(workers)}/{self.slots} wait{pending} fail{failed} retry{retries}"
98
+ f" | t{duration(elapsed)} ETA {eta}")
99
+ filled = int(20 * fraction)
100
+ bar = " [" + "#" * filled + "-" * (20 - filled) + "]"
101
+ header = count + (bar if len(count + bar + tail) <= width else "") + tail
102
+ result = [header]
103
+ for index, identifier in enumerate(self.slot_ids, start=1):
104
+ if identifier is None:
105
+ result.append(f"Slot {index} | idle")
106
+ continue
107
+ worker = by_id[identifier]
108
+ budget = f" budget{worker.memory_mib / 1024:g}GiB" if worker.memory_mib else ""
109
+ result.append(
110
+ f"Slot {index} | {worker.device} PID{worker.pid}{budget} | {self.label} {identifier:03d} | "
111
+ f"try{worker.attempt}/{self.max_attempts} | {worker.unit}{worker.current}/{worker.total} | "
112
+ f"{worker.phase} | elapsed {duration(now - worker.started)}"
113
+ )
114
+ result.append("Latest: " + " ".join(event.split()))
115
+ return [line if len(line) <= width else line[:max(0, width - 3)] + "..." for line in result]
116
+
117
+ def render(self, workers: list[WorkerStatus], event: str) -> None:
118
+ now = time.monotonic()
119
+ width = max(20, shutil.get_terminal_size(fallback=(120, 24)).columns) if self.interactive else 160
120
+ frame = self.snapshot(workers, event, now=now, width=width)
121
+ if self.interactive:
122
+ for line, text in zip(self.lines, frame):
123
+ line.set_description_str(text, refresh=False)
124
+ line.refresh()
125
+ else:
126
+ signature = (tuple((r["status"], len(r.get("attempts", []))) for r in self.records),
127
+ tuple((w.identifier, w.pid, w.current, w.phase) for w in workers), event)
128
+ if signature != self.last_signature or now - self.last_printed >= 60:
129
+ self.stream.write("\n".join(frame) + "\n")
130
+ self.stream.flush()
131
+ self.last_printed, self.last_signature = now, signature
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass
2
+ import os
3
+ from pathlib import Path
4
+ import signal
5
+ import subprocess
6
+ from typing import Callable
7
+
8
+ from .gpu import GPU, discover
9
+ from .task import Command
10
+
11
+
12
+ def stop_process_tree(process: subprocess.Popen) -> None:
13
+ for sig in (signal.SIGTERM, signal.SIGKILL):
14
+ try:
15
+ os.killpg(process.pid, sig)
16
+ except ProcessLookupError:
17
+ pass
18
+ if sig == signal.SIGTERM:
19
+ try:
20
+ process.wait(timeout=5)
21
+ except subprocess.TimeoutExpired:
22
+ pass
23
+ process.wait()
24
+
25
+
26
+ @dataclass
27
+ class LocalRuntime:
28
+ discover: Callable[[], list[GPU]] = discover
29
+ stop: Callable[[subprocess.Popen], None] = stop_process_tree
30
+
31
+ def launch(self, command: Command, directory: Path, gpu: GPU | None, inherited_fds: tuple[int, ...]):
32
+ environment = {**os.environ, **command.env, "PYTHONUNBUFFERED": "1",
33
+ "CUDA_VISIBLE_DEVICES": "" if gpu is None else gpu.uuid,
34
+ "TASK_PROGRESS_FILE": str(directory / "progress.json")}
35
+ with (directory / "worker.log").open("w") as log:
36
+ return subprocess.Popen(command.args, cwd=command.cwd, env=environment, stdout=log,
37
+ stderr=subprocess.STDOUT, start_new_session=True, pass_fds=inherited_fds)
@@ -0,0 +1,461 @@
1
+ from collections import deque
2
+ from contextlib import ExitStack
3
+ from copy import deepcopy
4
+ from dataclasses import asdict, dataclass
5
+ import fcntl
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+ from typing import Callable, Iterable, TextIO
14
+
15
+ from .gpu import GPU, GPUQueryError, visible_devices
16
+ from .memory import MemoryPool
17
+ from .policy import Scheduling
18
+ from .progress import TaskProgress, WorkerStatus
19
+ from .runtime import LocalRuntime
20
+ from .storage import StateStore
21
+ from .task import (
22
+ Assessment, Attempt, CommandHandler, TaskContext, TaskEvent, TaskHandler,
23
+ TaskOutcome, TaskRecord, TaskSpec,
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class RunningTask:
29
+ process: subprocess.Popen
30
+ gpu: GPU | None
31
+ lock: TextIO | None
32
+ directory: Path
33
+ started: float
34
+ missing: int = 0
35
+
36
+
37
+ class Scheduler:
38
+ def __init__(self, root: str | Path, policy: Scheduling | None = None, *,
39
+ runtime: LocalRuntime | None = None, inherited_fds: tuple[int, ...] = (),
40
+ lock_namespace: str = "task-scheduling", label: str = "Tasks"):
41
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", lock_namespace):
42
+ raise ValueError("Lock namespace must contain only letters, digits, underscores and hyphens")
43
+ self.root = Path(root).resolve()
44
+ self.policy = policy or Scheduling()
45
+ self.runtime = runtime or LocalRuntime()
46
+ self.label = label
47
+ self.lock_namespace = lock_namespace
48
+ self.inherited_fds = inherited_fds
49
+ self.store = StateStore(self.root)
50
+ self.memory = MemoryPool(self.policy)
51
+ self._records: dict[str, TaskRecord] = {}
52
+ self._handlers: dict[str, TaskHandler] = {"command": CommandHandler()}
53
+ self._listeners: dict[str, Callable[[TaskEvent], None]] = {}
54
+ self._observers: list[Callable[[tuple[TaskRecord, ...]], None]] = []
55
+ self._running: dict[str, RunningTask] = {}
56
+ self._cooldown: dict[str, float] = {}
57
+ self._quarantined: set[str] = set()
58
+ self._known: set[str] = set()
59
+ self._reconciled: set[str] = set()
60
+ self._session: ExitStack | None = None
61
+ self._lock_fd: int | None = None
62
+ self._event = "Preparing tasks"
63
+ self._query_error = ""
64
+ self._next_scan = 0.0
65
+
66
+ @property
67
+ def tasks(self) -> tuple[TaskRecord, ...]:
68
+ return tuple(deepcopy(record) for record in self._records.values())
69
+
70
+ @property
71
+ def has_saved_state(self) -> bool:
72
+ return self.store.path.exists()
73
+
74
+ def __enter__(self) -> "Scheduler":
75
+ if self._session is not None:
76
+ raise RuntimeError("Scheduler session is already open")
77
+ session = ExitStack()
78
+ try:
79
+ self._lock_fd = session.enter_context(self.store.lock())
80
+ submitted = [record.spec for record in self._records.values()]
81
+ self._records = self.store.load()
82
+ self.memory = MemoryPool(self.policy)
83
+ self._cooldown.clear()
84
+ self._quarantined.clear()
85
+ self._known.clear()
86
+ self._next_scan = 0.0
87
+ self._session = session
88
+ self.submit_many(submitted)
89
+ except BaseException:
90
+ self._session = None
91
+ session.close()
92
+ raise
93
+ return self
94
+
95
+ def __exit__(self, *exception) -> None:
96
+ try:
97
+ if self._running:
98
+ self._interrupt_running()
99
+ self._save()
100
+ finally:
101
+ session, self._session = self._session, None
102
+ self._lock_fd = None
103
+ self._reconciled.clear()
104
+ if session is not None:
105
+ session.__exit__(*exception)
106
+
107
+ def register_handler(self, name: str, handler: TaskHandler) -> None:
108
+ if not isinstance(name, str) or not name.strip():
109
+ raise ValueError("Handler name must be a nonempty string")
110
+ if name in self._handlers:
111
+ raise ValueError(f"Handler already registered: {name}")
112
+ if any(not callable(getattr(handler, method, None)) for method in ("prepare", "assess", "commit", "recover")):
113
+ raise TypeError("Handlers must implement prepare, assess, commit and recover")
114
+ self._handlers[name] = handler
115
+
116
+ def subscribe(self, name: str, listener: Callable[[TaskEvent], None]) -> None:
117
+ if not isinstance(name, str) or not name.strip() or name in self._listeners:
118
+ raise ValueError(f"Listener name is empty or already registered: {name}")
119
+ if not callable(listener):
120
+ raise TypeError("Event listeners must be callable")
121
+ self._listeners[name] = listener
122
+
123
+ def observe(self, observer: Callable[[tuple[TaskRecord, ...]], None]) -> None:
124
+ if not callable(observer):
125
+ raise TypeError("State observers must be callable")
126
+ self._observers.append(observer)
127
+
128
+ def submit(self, task: TaskSpec) -> None:
129
+ self.submit_many([task])
130
+
131
+ def submit_many(self, tasks: Iterable[TaskSpec]) -> None:
132
+ submitted = [TaskSpec.from_dict(json.loads(json.dumps(asdict(task), allow_nan=False))) for task in tasks]
133
+ specs = {identifier: record.spec for identifier, record in self._records.items()}
134
+ for task in submitted:
135
+ if task.id in specs and specs[task.id].identity() != task.identity():
136
+ raise ValueError(f"Task definition changed: {task.id}")
137
+ existing = self._records.get(task.id)
138
+ if existing is not None and task.id in self._running and existing.spec != task:
139
+ raise ValueError(f"Cannot update an executing task: {task.id}")
140
+ specs[task.id] = task
141
+ self._validate_graph(specs)
142
+ for task in submitted:
143
+ if task.id in self._records:
144
+ self._records[task.id].spec = task
145
+ else:
146
+ self._records[task.id] = TaskRecord(task, len(self._records))
147
+ if submitted:
148
+ self._save()
149
+
150
+ def import_records(self, records: Iterable[TaskRecord]) -> None:
151
+ self._require_session()
152
+ if self._records or self.store.path.exists():
153
+ raise ValueError("Recovery records can only be imported into a new scheduler")
154
+ imported = deepcopy(list(records))
155
+ if len({record.spec.id for record in imported}) != len(imported):
156
+ raise ValueError("Duplicate task ids in recovery records")
157
+ if [record.sequence for record in imported] != list(range(len(imported))):
158
+ raise ValueError("Recovery records must have contiguous sequence numbers")
159
+ if any(record.status not in {"pending", "running", "committing", "complete", "failed", "blocked"}
160
+ for record in imported):
161
+ raise ValueError("Unknown task status in recovery records")
162
+ self._validate_graph({record.spec.id: record.spec for record in imported})
163
+ self._records = {record.spec.id: record for record in imported}
164
+ self._save()
165
+
166
+ def reconcile(self) -> None:
167
+ self._require_session()
168
+ self._validate_graph({identifier: record.spec for identifier, record in self._records.items()})
169
+ for record in list(self._records.values()):
170
+ if record.spec.id in self._reconciled:
171
+ continue
172
+ handler = self._handler(record)
173
+ recovery = handler.recover(self._context(record))
174
+ if recovery is not None:
175
+ record.status = recovery.status
176
+ record.result = deepcopy(recovery.result)
177
+ if record.status == "complete" and record.attempts:
178
+ record.attempts[-1].status = "complete"
179
+ elif record.status == "complete":
180
+ raise ValueError(f"Completed task could not be recovered: {record.spec.id}")
181
+ elif record.status == "committing":
182
+ raise ValueError(f"Task publication could not be recovered: {record.spec.id}")
183
+ else:
184
+ for attempt in record.attempts:
185
+ if attempt.status == "running":
186
+ attempt.status = "interrupted"
187
+ record.status = "pending" if record.can_retry(self.policy.max_retries) else "failed"
188
+ self._reconciled.add(record.spec.id)
189
+ self._save()
190
+ if record.status == "committing":
191
+ self._commit(record)
192
+
193
+ def run(self) -> tuple[TaskRecord, ...]:
194
+ if self._session is None:
195
+ with self:
196
+ return self.run()
197
+ self.reconcile()
198
+ self._deliver_events()
199
+ deadline = time.monotonic() + self.policy.wait_seconds
200
+ display_records = [{"status": record.status, "attempts": record.attempts}
201
+ for record in self._records.values()]
202
+ with TaskProgress(display_records, self.policy.max_parallel_tasks, self.policy.max_retries,
203
+ label=self.label) as progress:
204
+ try:
205
+ while self._has_work():
206
+ now = time.monotonic()
207
+ devices = self._scan(now)
208
+ if self._finish_tasks(devices, now):
209
+ deadline = now + self.policy.wait_seconds
210
+ self._block_dependents()
211
+ self._deliver_events()
212
+ self._start_tasks(devices, now)
213
+ if self._pending() and not self._running:
214
+ self._check_wait(now, deadline)
215
+ display_records[:] = [{"status": record.status,
216
+ "attempts": record.attempts} for record in self._records.values()]
217
+ self._render(progress)
218
+ if self._has_work():
219
+ time.sleep(1)
220
+ finally:
221
+ self._interrupt_running()
222
+ self._save()
223
+ self._deliver_events()
224
+ return self.tasks
225
+
226
+ @staticmethod
227
+ def _validate_graph(specs: dict[str, TaskSpec]) -> None:
228
+ remaining = {identifier: len(spec.dependencies) for identifier, spec in specs.items()}
229
+ dependents = {identifier: [] for identifier in specs}
230
+ for identifier, spec in specs.items():
231
+ for dependency in spec.dependencies:
232
+ if dependency not in specs:
233
+ raise ValueError(f"Unknown dependency: {dependency}")
234
+ dependents[dependency].append(identifier)
235
+ ready = deque(identifier for identifier, count in remaining.items() if count == 0)
236
+ visited = 0
237
+ while ready:
238
+ identifier = ready.popleft()
239
+ visited += 1
240
+ for dependent in dependents[identifier]:
241
+ remaining[dependent] -= 1
242
+ if remaining[dependent] == 0:
243
+ ready.append(dependent)
244
+ if visited != len(specs):
245
+ raise ValueError("Task dependency cycle")
246
+
247
+ def _require_session(self) -> None:
248
+ if self._session is None:
249
+ raise RuntimeError("Open a scheduler session before recovering tasks")
250
+
251
+ def _save(self) -> None:
252
+ if self._session is None:
253
+ return
254
+ self.store.save(self._records)
255
+ for observer in self._observers:
256
+ observer(self.tasks)
257
+
258
+ def _handler(self, record: TaskRecord) -> TaskHandler:
259
+ if record.spec.handler not in self._handlers:
260
+ raise ValueError(f"Handler is not registered: {record.spec.handler}")
261
+ return self._handlers[record.spec.handler]
262
+
263
+ def _context(self, record: TaskRecord) -> TaskContext:
264
+ return TaskContext(deepcopy(record.spec), self.root, record.status, tuple(deepcopy(record.attempts)))
265
+
266
+ def _has_work(self) -> bool:
267
+ return bool(self._running) or any(record.status in {"pending", "committing"} for record in self._records.values())
268
+
269
+ def _pending(self) -> list[TaskRecord]:
270
+ return [record for record in self._records.values() if record.status == "pending"]
271
+
272
+ def _ready(self) -> list[TaskRecord]:
273
+ return sorted((record for record in self._pending()
274
+ if all(self._records[dependency].status == "complete" for dependency in record.spec.dependencies)),
275
+ key=lambda record: (-record.spec.priority, -record.spec.resources.memory_mib, record.sequence))
276
+
277
+ def _scan(self, now: float) -> list[GPU] | None:
278
+ if now < self._next_scan or not any(record.spec.resources.device == "cuda" for record in self._records.values()):
279
+ return None
280
+ self._next_scan = now + self.policy.poll_interval_seconds
281
+ try:
282
+ devices = self.runtime.discover()
283
+ self.memory.refresh(devices)
284
+ self._known.update(gpu.uuid for gpu in visible_devices(devices, self.policy))
285
+ self._query_error = ""
286
+ return devices
287
+ except GPUQueryError as error:
288
+ self._query_error = str(error)
289
+ return None
290
+
291
+ def _finish_tasks(self, devices: list[GPU] | None, now: float) -> bool:
292
+ finished = False
293
+ for identifier, running in list(self._running.items()):
294
+ if devices is not None and running.gpu is not None:
295
+ running.missing = 0 if any(gpu.uuid == running.gpu.uuid for gpu in devices) else running.missing + 1
296
+ lost = running.missing >= 2
297
+ timeout = self.policy.task_timeout_seconds > 0 and now - running.started > self.policy.task_timeout_seconds
298
+ code = running.process.poll()
299
+ if code is None and not lost and not timeout:
300
+ continue
301
+ self.runtime.stop(running.process)
302
+ self._release(identifier, running)
303
+ record = self._records[identifier]
304
+ if lost or timeout:
305
+ assessment = Assessment(False, True, "device" if lost else "timeout",
306
+ "GPU disappeared" if lost else "task timed out")
307
+ else:
308
+ assessment = self._handler(record).assess(self._context(record), TaskOutcome(code))
309
+ if assessment.success:
310
+ record.status = "committing"
311
+ self._save()
312
+ self._commit(record)
313
+ else:
314
+ self._fail(record, assessment, running.gpu, now)
315
+ finished = True
316
+ return finished
317
+
318
+ def _commit(self, record: TaskRecord) -> None:
319
+ result = self._handler(record).commit(self._context(record))
320
+ json.dumps(result, allow_nan=False)
321
+ if not isinstance(result, dict):
322
+ raise TypeError("Task commit must return a JSON object")
323
+ record.result = deepcopy(result)
324
+ record.status = "complete"
325
+ if record.attempts:
326
+ record.attempts[-1].status = "complete"
327
+ self._event = f"{record.spec.name} completed"
328
+ self._save()
329
+
330
+ def _fail(self, record: TaskRecord, assessment: Assessment, gpu: GPU | None, now: float) -> None:
331
+ attempt = record.attempts[-1]
332
+ attempt.status, attempt.kind, attempt.message = "failed", assessment.kind, assessment.message
333
+ attempt.retryable = assessment.retryable
334
+ retry = record.can_retry(self.policy.max_retries)
335
+ record.status = "pending" if retry else "failed"
336
+ if gpu is not None:
337
+ if assessment.kind == "device":
338
+ self._quarantined.add(gpu.uuid)
339
+ else:
340
+ self._cooldown[gpu.uuid] = now + self.policy.gpu_cooldown_seconds
341
+ self._event = f"{record.spec.name}: {assessment.message}; {'retry queued' if retry else 'failed'}"
342
+ self._save()
343
+
344
+ def _acquire_gpu(self, record: TaskRecord, now: float) -> tuple[GPU | None, TextIO | None]:
345
+ last_gpu = record.attempts[-1].gpu if record.attempts else None
346
+ for gpu in self.memory.candidates(record.spec.resources.memory_mib, self._cooldown,
347
+ self._quarantined, now, last_gpu):
348
+ path = Path(tempfile.gettempdir()) / f"{self.lock_namespace}-{os.getuid()}-{gpu.uuid}.lock"
349
+ handle = path.open("a")
350
+ try:
351
+ fcntl.flock(handle, (fcntl.LOCK_EX if self.policy.require_idle else fcntl.LOCK_SH) | fcntl.LOCK_NB)
352
+ except BlockingIOError:
353
+ handle.close()
354
+ continue
355
+ self.memory.reserve(record.sequence, gpu, record.spec.resources.memory_mib)
356
+ return gpu, handle
357
+ return None, None
358
+
359
+ def _start_tasks(self, devices: list[GPU] | None, now: float) -> None:
360
+ for record in self._ready():
361
+ if len(self._running) >= self.policy.max_parallel_tasks:
362
+ break
363
+ gpu, lock = None, None
364
+ if record.spec.resources.device == "cuda":
365
+ if devices is None:
366
+ continue
367
+ gpu, lock = self._acquire_gpu(record, now)
368
+ if gpu is None:
369
+ continue
370
+ self._launch(record, gpu, lock, now)
371
+
372
+ def _launch(self, record: TaskRecord, gpu: GPU | None, lock: TextIO | None, now: float) -> None:
373
+ name = re.sub(r"[^A-Za-z0-9_.-]", "_", record.spec.name)[:80]
374
+ directory = self.root / f"{record.sequence:03d}-{name}" / f"{len(record.attempts) + 1:04d}"
375
+ process = None
376
+ try:
377
+ record.status = "running"
378
+ record.attempts.append(Attempt(str(directory), gpu=None if gpu is None else gpu.uuid,
379
+ memory_mib=record.spec.resources.memory_mib))
380
+ self._save()
381
+ directory.mkdir(parents=True)
382
+ command = self._handler(record).prepare(self._context(record))
383
+ inherited = (*self.inherited_fds, self._lock_fd)
384
+ if lock is not None:
385
+ inherited = (*inherited, lock.fileno())
386
+ process = self.runtime.launch(command, directory, gpu, inherited)
387
+ record.attempts[-1].pid = process.pid
388
+ if gpu is not None:
389
+ self.memory.started(record.sequence, process.pid)
390
+ self._running[record.spec.id] = RunningTask(process, gpu, lock, directory, now)
391
+ except BaseException:
392
+ if process is not None:
393
+ self.runtime.stop(process)
394
+ self.memory.release(record.sequence)
395
+ if lock is not None:
396
+ lock.close()
397
+ record.status = "pending"
398
+ record.attempts[-1].status = "interrupted"
399
+ raise
400
+ self._event = (f"{record.spec.name} started on {'CPU' if gpu is None else 'GPU' + str(gpu.index)} "
401
+ f"PID{process.pid} budget {record.spec.resources.memory_mib / 1024:g}GiB")
402
+ self._save()
403
+
404
+ def _release(self, identifier: str, running: RunningTask) -> None:
405
+ self.memory.release(self._records[identifier].sequence)
406
+ if running.lock is not None:
407
+ running.lock.close()
408
+ del self._running[identifier]
409
+
410
+ def _interrupt_running(self) -> None:
411
+ for identifier, running in list(self._running.items()):
412
+ self.runtime.stop(running.process)
413
+ self._release(identifier, running)
414
+ record = self._records[identifier]
415
+ record.status = "pending"
416
+ record.attempts[-1].status = "interrupted"
417
+
418
+ def _block_dependents(self) -> None:
419
+ changed = True
420
+ while changed:
421
+ changed = False
422
+ for record in self._pending():
423
+ if any(self._records[dependency].status in {"failed", "blocked"} for dependency in record.spec.dependencies):
424
+ record.status = "blocked"
425
+ changed = True
426
+ self._save()
427
+
428
+ def _deliver_events(self) -> None:
429
+ for record in list(self._records.values()):
430
+ if record.status not in {"complete", "failed", "blocked"}:
431
+ continue
432
+ event_id = f"{record.spec.id}:{len(record.attempts)}:{record.status}"
433
+ for name, listener in self._listeners.items():
434
+ if record.acknowledged.get(name) == event_id:
435
+ continue
436
+ listener(TaskEvent(event_id, deepcopy(record.spec), record.status,
437
+ tuple(deepcopy(record.attempts)), deepcopy(record.result)))
438
+ record.acknowledged[name] = event_id
439
+ self._save()
440
+
441
+ def _check_wait(self, now: float, deadline: float) -> None:
442
+ self._event = "Waiting for eligible GPUs" + (f": {self._query_error}" if self._query_error else "")
443
+ if self._known and self._known <= self._quarantined:
444
+ raise RuntimeError("All allowed GPUs quarantined; repair GPUs before rerunning")
445
+ if now > deadline:
446
+ raise TimeoutError("No eligible GPU became available before timeout")
447
+
448
+ def _render(self, progress: TaskProgress) -> None:
449
+ workers = []
450
+ for identifier, running in self._running.items():
451
+ state = {"current": 0, "total": 0, "phase": "initializing", "unit": "step"}
452
+ try:
453
+ state.update(json.loads((running.directory / "progress.json").read_text()))
454
+ except (OSError, ValueError):
455
+ pass
456
+ record = self._records[identifier]
457
+ workers.append(WorkerStatus(record.sequence, running.process.pid,
458
+ "CPU" if running.gpu is None else f"GPU{running.gpu.index}", len(record.attempts),
459
+ state["current"], state["total"], f"{record.spec.name} {state['phase']}", running.started,
460
+ state["unit"], record.spec.resources.memory_mib))
461
+ progress.render(workers, f"{self.memory.summary()}; {self._event}" if self.memory.devices else self._event)
@@ -0,0 +1,49 @@
1
+ from contextlib import contextmanager
2
+ from dataclasses import asdict
3
+ import fcntl
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .task import TaskRecord
8
+
9
+
10
+ def atomic_json(path: str | Path, value: object) -> None:
11
+ path = Path(path)
12
+ temporary = path.with_suffix(".tmp")
13
+ temporary.write_text(json.dumps(value, indent=2, allow_nan=False))
14
+ temporary.replace(path)
15
+
16
+
17
+ class StateStore:
18
+ def __init__(self, root: Path):
19
+ self.root = root
20
+ self.path = root / "scheduler.json"
21
+
22
+ @contextmanager
23
+ def lock(self):
24
+ self.root.mkdir(parents=True, exist_ok=True)
25
+ with (self.root / "scheduler.lock").open("a") as handle:
26
+ try:
27
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
28
+ except BlockingIOError:
29
+ raise RuntimeError(f"Scheduler is still running: {self.root}") from None
30
+ yield handle.fileno()
31
+
32
+ def load(self) -> dict[str, TaskRecord]:
33
+ if not self.path.exists():
34
+ return {}
35
+ document = json.loads(self.path.read_text())
36
+ if document["version"] != 1:
37
+ raise ValueError("Unsupported scheduler state version")
38
+ records = [TaskRecord.from_dict(record) for record in document["tasks"]]
39
+ if len({record.spec.id for record in records}) != len(records):
40
+ raise ValueError("Duplicate task ids in scheduler state")
41
+ if [record.sequence for record in records] != list(range(len(records))):
42
+ raise ValueError("Invalid task sequence numbers in scheduler state")
43
+ if any(record.status not in {"pending", "running", "committing", "complete", "failed", "blocked"}
44
+ for record in records):
45
+ raise ValueError("Unknown task status in scheduler state")
46
+ return {record.spec.id: record for record in records}
47
+
48
+ def save(self, records: dict[str, TaskRecord]) -> None:
49
+ atomic_json(self.path, {"version": 1, "tasks": [asdict(record) for record in records.values()]})
@@ -0,0 +1,197 @@
1
+ from dataclasses import dataclass, field
2
+ import json
3
+ from pathlib import Path
4
+ from typing import Literal, Protocol
5
+
6
+ from .policy import integer
7
+
8
+
9
+ TaskStatus = Literal["pending", "running", "committing", "complete", "failed", "blocked"]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ResourceRequest:
14
+ device: Literal["cpu", "cuda"] = "cpu"
15
+ memory_mib: int = 0
16
+
17
+ def __post_init__(self) -> None:
18
+ if self.device not in {"cpu", "cuda"}:
19
+ raise ValueError("Task devices must be cpu or cuda")
20
+ integer(self.memory_mib, "memory_mib")
21
+ if self.device == "cpu" and self.memory_mib:
22
+ raise ValueError("CPU tasks cannot reserve GPU memory")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class TaskSpec:
27
+ id: str
28
+ handler: str
29
+ name: str = "task"
30
+ payload: dict = field(default_factory=dict)
31
+ dependencies: tuple[str, ...] = ()
32
+ priority: int = 0
33
+ resources: ResourceRequest = field(default_factory=ResourceRequest)
34
+
35
+ def __post_init__(self) -> None:
36
+ if any(not isinstance(value, str) or not value.strip() for value in (self.id, self.handler, self.name)):
37
+ raise ValueError("Task id, handler and name must be nonempty strings")
38
+ if type(self.priority) is not int:
39
+ raise ValueError("Task priority must be an integer")
40
+ if not isinstance(self.dependencies, (list, tuple)) or any(
41
+ not isinstance(value, str) or not value for value in self.dependencies
42
+ ):
43
+ raise ValueError("Task dependencies must contain task ids")
44
+ if self.id in self.dependencies or len(set(self.dependencies)) != len(self.dependencies):
45
+ raise ValueError("Task dependencies must be unique and cannot reference the task itself")
46
+ object.__setattr__(self, "dependencies", tuple(self.dependencies))
47
+ if not isinstance(self.payload, dict):
48
+ raise ValueError("Task payload must be a JSON object")
49
+ if not isinstance(self.resources, ResourceRequest):
50
+ raise TypeError("Task resources must be a ResourceRequest")
51
+ json.dumps(self.payload, allow_nan=False)
52
+
53
+ def identity(self) -> dict:
54
+ return {"id": self.id, "handler": self.handler, "payload": self.payload,
55
+ "dependencies": list(self.dependencies)}
56
+
57
+ @classmethod
58
+ def from_dict(cls, values: dict) -> "TaskSpec":
59
+ return cls(**{**values, "resources": ResourceRequest(**values["resources"])})
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Command:
64
+ args: tuple[str, ...]
65
+ cwd: str | Path | None = None
66
+ env: dict[str, str] = field(default_factory=dict)
67
+
68
+ def __post_init__(self) -> None:
69
+ if not isinstance(self.args, (list, tuple)) or not self.args or any(
70
+ not isinstance(value, str) for value in self.args
71
+ ):
72
+ raise ValueError("Command args must be a nonempty list of strings")
73
+ if any(not isinstance(key, str) or not isinstance(value, str) for key, value in self.env.items()):
74
+ raise ValueError("Command environment must map strings to strings")
75
+ object.__setattr__(self, "args", tuple(self.args))
76
+
77
+
78
+ @dataclass
79
+ class Attempt:
80
+ directory: str
81
+ status: str = "running"
82
+ gpu: str | None = None
83
+ pid: int | None = None
84
+ memory_mib: int = 0
85
+ kind: str = ""
86
+ message: str = ""
87
+ retryable: bool = False
88
+
89
+
90
+ @dataclass
91
+ class TaskRecord:
92
+ spec: TaskSpec
93
+ sequence: int
94
+ status: TaskStatus = "pending"
95
+ attempts: list[Attempt] = field(default_factory=list)
96
+ result: dict = field(default_factory=dict)
97
+ acknowledged: dict[str, str] = field(default_factory=dict)
98
+
99
+ def can_retry(self, max_retries: int) -> bool:
100
+ failures = [attempt for attempt in self.attempts if attempt.status == "failed"]
101
+ return all(attempt.retryable for attempt in failures) and len(failures) <= max_retries
102
+
103
+ @classmethod
104
+ def from_dict(cls, values: dict) -> "TaskRecord":
105
+ return cls(**{**values, "spec": TaskSpec.from_dict(values["spec"]),
106
+ "attempts": [Attempt(**attempt) for attempt in values["attempts"]]})
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class TaskContext:
111
+ task: TaskSpec
112
+ root: Path
113
+ status: TaskStatus
114
+ attempts: tuple[Attempt, ...]
115
+
116
+ @property
117
+ def directory(self) -> Path:
118
+ return Path(self.attempts[-1].directory) if self.attempts else self.root
119
+
120
+ @property
121
+ def gpu(self) -> str | None:
122
+ return self.attempts[-1].gpu if self.attempts else None
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class TaskOutcome:
127
+ returncode: int | None
128
+ timed_out: bool = False
129
+ device_lost: bool = False
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Assessment:
134
+ success: bool
135
+ retryable: bool = False
136
+ kind: str = "process"
137
+ message: str = ""
138
+
139
+ def __post_init__(self) -> None:
140
+ if type(self.success) is not bool or type(self.retryable) is not bool:
141
+ raise TypeError("Assessment success and retryable must be booleans")
142
+ if not isinstance(self.kind, str) or not isinstance(self.message, str):
143
+ raise TypeError("Assessment kind and message must be strings")
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class Recovery:
148
+ status: Literal["complete", "failed", "committing"]
149
+ result: dict = field(default_factory=dict)
150
+
151
+ def __post_init__(self) -> None:
152
+ if self.status not in {"complete", "failed", "committing"}:
153
+ raise ValueError("Recovery status must be complete, failed or committing")
154
+ if not isinstance(self.result, dict):
155
+ raise TypeError("Recovery result must be a JSON object")
156
+ json.dumps(self.result, allow_nan=False)
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class TaskEvent:
161
+ id: str
162
+ task: TaskSpec
163
+ status: TaskStatus
164
+ attempts: tuple[Attempt, ...]
165
+ result: dict
166
+
167
+
168
+ class TaskHandler(Protocol):
169
+ def prepare(self, context: TaskContext) -> Command: ...
170
+
171
+ def assess(self, context: TaskContext, outcome: TaskOutcome) -> Assessment: ...
172
+
173
+ def commit(self, context: TaskContext) -> dict: ...
174
+
175
+ def recover(self, context: TaskContext) -> Recovery | None: ...
176
+
177
+
178
+ class CommandHandler:
179
+ def prepare(self, context: TaskContext) -> Command:
180
+ return Command(**context.task.payload)
181
+
182
+ def assess(self, context: TaskContext, outcome: TaskOutcome) -> Assessment:
183
+ if outcome.device_lost:
184
+ return Assessment(False, True, "device", "GPU disappeared")
185
+ if outcome.timed_out:
186
+ return Assessment(False, True, "timeout", "task timed out")
187
+ return Assessment(outcome.returncode == 0, True, "process", f"worker exited {outcome.returncode}")
188
+
189
+ def commit(self, context: TaskContext) -> dict:
190
+ return {}
191
+
192
+ def recover(self, context: TaskContext) -> Recovery | None:
193
+ if context.status == "committing":
194
+ return Recovery("committing")
195
+ if context.status == "complete":
196
+ return Recovery("complete")
197
+ return None