ppkit 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.
- pp/__init__.py +4 -0
- pp/__main__.py +5 -0
- pp/cli.py +363 -0
- pp/commands/__init__.py +1 -0
- pp/commands/batch.py +848 -0
- pp/commands/cmd.py +545 -0
- pp/commands/completion.py +55 -0
- pp/commands/http.py +1499 -0
- pp/commands/kill.py +293 -0
- pp/commands/promote.py +945 -0
- pp/commands/queue.py +243 -0
- pp/lib/__init__.py +1 -0
- pp/lib/config.py +110 -0
- pp/lib/jobq.py +3106 -0
- pp/lib/lock.py +338 -0
- pp/lib/paths.py +163 -0
- pp/lib/proc.py +706 -0
- pp/lib/session.py +31 -0
- pp/lib/ui.py +189 -0
- ppkit-0.1.0.dist-info/METADATA +330 -0
- ppkit-0.1.0.dist-info/RECORD +24 -0
- ppkit-0.1.0.dist-info/WHEEL +4 -0
- ppkit-0.1.0.dist-info/entry_points.txt +3 -0
- ppkit-0.1.0.dist-info/licenses/LICENSE +202 -0
pp/lib/jobq.py
ADDED
|
@@ -0,0 +1,3106 @@
|
|
|
1
|
+
"""The job-queue engine behind `pp queue`, and its CLI (run_cli).
|
|
2
|
+
|
|
3
|
+
Named queues, one directory each: ~/.local/state/pp/queue/<name>/
|
|
4
|
+
jobs/<id>.json one file per job, holding every field, its `state` among them (queued, held, running, done,
|
|
5
|
+
failed): a change of state replaces that one file, and nothing else moves
|
|
6
|
+
jobs/<id>.run the claim one run of that job holds, from its own wrapper, for exactly as long as it runs
|
|
7
|
+
rc/<id>.<attempt>.<token> the exit code, written by the job's own `sh` under the name its record names,
|
|
8
|
+
so ANY worker can tell how it ended, and no attempt can read another's code
|
|
9
|
+
logs/<id>.log hooks/<id>-<event>.log worker.lock (the worker's claim, and its record) worker.log
|
|
10
|
+
FORMAT the number this layout is, written by the first writer that reaches the queue: a queue
|
|
11
|
+
whose jobs another pp wrote is refused where it is opened (open_queue), never read
|
|
12
|
+
and, shared by every queue, ~/.local/state/pp/queue/{counter,.lock,probes/,watchers/}: job ids are global (an id
|
|
13
|
+
names one job machine-wide), and the shared resource pools ([resources]) count every queue's running jobs.
|
|
14
|
+
|
|
15
|
+
One worker per queue reaps ended jobs, carries out the stops asked of them, re-queues retries, starts what
|
|
16
|
+
fits, and fires the job's on_fail / on_done hook on its FINAL outcome. Admission is strict head-of-line by
|
|
17
|
+
priority (a big job is never starved by small ones) unless the worker runs with backfill; a job waiting out its
|
|
18
|
+
retry delay is skipped, not waited for.
|
|
19
|
+
|
|
20
|
+
A queue's worker is the one actor that signals that queue's jobs, and only a worker ever holds the queue's
|
|
21
|
+
worker claim. `pp queue kill` writes a stop on the job ({by, t, grace}) and waits for the record of how the job
|
|
22
|
+
ended. It makes sure the queue has a worker: it wakes the one that holds the claim, or starts one where nobody
|
|
23
|
+
does. A timeout is the worker writing a stop on the job itself. So a kill, a timeout and a reap never cross,
|
|
24
|
+
and one actor records how a job ended.
|
|
25
|
+
|
|
26
|
+
One construct makes sure a queue has a worker and says what that worker is (`worker_up`, and `worker_now` for a
|
|
27
|
+
verb that only reports). It is the one place that reads the claim's holder. Every verb prints from the one
|
|
28
|
+
answer it gets back, so no verb acts on a look it took outside the scope that acted.
|
|
29
|
+
|
|
30
|
+
One function says what is to be done about a running job: `decide(job, facts)`, from the job's record and the
|
|
31
|
+
facts read with it, giving back the one action to carry out. It is pure. `Worker.carry_out` is the one pass
|
|
32
|
+
over the queue's running jobs that gathers those facts, asks it, and carries the action out. So a timeout, a
|
|
33
|
+
kill and a reap are facts about one job and one decision made from all of them.
|
|
34
|
+
|
|
35
|
+
Being alive is holding a claim (pp/lib/lock.py), never a pid in a file: the queue's worker holds the claim on
|
|
36
|
+
worker.lock while it runs, one run of a job is held by that run's own wrapper, and one run of a probe is held by
|
|
37
|
+
whoever runs it. The kernel lets a claim go when its holder ends, however it ends, so a worker or a job that was
|
|
38
|
+
killed leaves nothing to clean up and nothing that reads as alive.
|
|
39
|
+
|
|
40
|
+
The caller's session identity (pp.lib.session) is recorded as the job's owner, so `wait --mine` finds the jobs a
|
|
41
|
+
session queued, and stripped from the worker, the jobs and the hooks.
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import re
|
|
48
|
+
import secrets
|
|
49
|
+
import select
|
|
50
|
+
import shlex
|
|
51
|
+
import shutil
|
|
52
|
+
import signal
|
|
53
|
+
import sys
|
|
54
|
+
import time
|
|
55
|
+
import traceback
|
|
56
|
+
from contextlib import contextmanager
|
|
57
|
+
from dataclasses import dataclass
|
|
58
|
+
from datetime import datetime
|
|
59
|
+
|
|
60
|
+
from pp.lib import config, lock, paths, proc, session, ui
|
|
61
|
+
|
|
62
|
+
STATES = ("queued", "held", "running", "done", "failed")
|
|
63
|
+
LIVE = ("queued", "held", "running")
|
|
64
|
+
ENDED = ("done", "failed")
|
|
65
|
+
CAPTURED_ENV = ("PATH", "VIRTUAL_ENV", "CONDA_PREFIX", "CONDA_DEFAULT_ENV")
|
|
66
|
+
# What a job runs as. Its stdin is the job's run claim, which this `sh` holds for exactly as long as the attempt
|
|
67
|
+
# lives; it writes its own pid in there first, so the claim names it even if the worker that started it died
|
|
68
|
+
# before recording it. The command itself reads /dev/null, so neither it nor anything it leaves behind holds the
|
|
69
|
+
# claim: the attempt is over when this `sh` is over. The exit code goes to another name and is renamed into
|
|
70
|
+
# place, so whoever reads it finds a whole code or none.
|
|
71
|
+
WRAP = ('printf \'{"pid": %d}\' "$$" >&0; "$@" 0</dev/null; r=$?; '
|
|
72
|
+
'echo "$r" > "$PP_QUEUE_RCFILE.part" && mv "$PP_QUEUE_RCFILE.part" "$PP_QUEUE_RCFILE"')
|
|
73
|
+
ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
|
74
|
+
LAUNCH_BANNER = re.compile(r"\[pp queue\] \S+ \S+ job \d+ \(") # the line _spawn writes before each attempt
|
|
75
|
+
NAME_RE = re.compile(r"[a-z0-9][a-z0-9_-]*")
|
|
76
|
+
RECORD = re.compile(r"\d+\.json") # what a job record is called: jobs/<id>.json, and nothing else is one
|
|
77
|
+
RESERVED = ("probes", "watchers") # directories beside the queues
|
|
78
|
+
FORMAT = 1 # what a queue's FORMAT file holds: the layout of everything under queue/<name>/
|
|
79
|
+
TIMEOUT_GRACE = 10.0 # seconds between a timeout's SIGTERM and its SIGKILL
|
|
80
|
+
WAKE = signal.SIGUSR1 # what a verb sends a worker to end its wait between ticks, so a stop is served now
|
|
81
|
+
STOP_PERIOD = 0.1 # how long a woken worker waits between ticks while a stop of its queue is in flight
|
|
82
|
+
KILL_POLL = 0.1 # how often `kill` looks for the outcome that is recorded
|
|
83
|
+
# how long `kill` waits for that outcome, past the grace it asked for. Every pass of that wait wakes the
|
|
84
|
+
# worker that is up, so its next tick is that pass. What this bounds is the job's own exit time, and not the
|
|
85
|
+
# queue's poll period, whatever that is set to. Where no worker is up, a pass starts a round of them and
|
|
86
|
+
# waits for each start in it to say it is ready, so such a pass can carry the wait past this bound by that
|
|
87
|
+
# round's ready waits; `kill` says how long it really waited.
|
|
88
|
+
KILL_WAIT = 120.0
|
|
89
|
+
# how often one process starts a worker again for a queue whose last round of starts brought none up
|
|
90
|
+
START_EVERY = 5.0
|
|
91
|
+
RETRY_DELAY = 30.0
|
|
92
|
+
RETRY_DELAY_CAP = 3600.0
|
|
93
|
+
STOP_WAIT = 300.0 # how long `worker stop` waits for a worker inside a slow tick
|
|
94
|
+
CLEAN_BATCH = 100 # job records `clean` removes in one transaction, so a reader never waits long
|
|
95
|
+
|
|
96
|
+
_PROG = "queue" # the command running the CLI: names it in messages and starts its workers
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---- store ------------------------------------------------------------------------------------------
|
|
100
|
+
#
|
|
101
|
+
# One file per job: jobs/<id>.json, with every field in it, `state` among them. A change of state replaces that
|
|
102
|
+
# one file, so a reader sees the job as it was before the change or as it is after it, never in two states and
|
|
103
|
+
# never in none. Reading and writing a record goes through txn() and listing(), or through find() for one.
|
|
104
|
+
|
|
105
|
+
def _top(*sub: str, create: bool = True) -> str:
|
|
106
|
+
return paths.state_dir("queue", *sub, create=create)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _qroot(q: str, *sub: str, create: bool = True) -> str:
|
|
110
|
+
return paths.state_dir("queue", q, *sub, create=create)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def queues() -> list[str]:
|
|
114
|
+
"""Every queue that exists on disk, each one opened through open_queue."""
|
|
115
|
+
top = _top(create=False)
|
|
116
|
+
try:
|
|
117
|
+
names = os.listdir(top)
|
|
118
|
+
except OSError:
|
|
119
|
+
return []
|
|
120
|
+
found = sorted(n for n in names if NAME_RE.fullmatch(n) and os.path.isdir(os.path.join(top, n, "jobs")))
|
|
121
|
+
for n in found:
|
|
122
|
+
open_queue(n)
|
|
123
|
+
return found
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _format_file(q: str, create: bool = False) -> str:
|
|
127
|
+
return os.path.join(_qroot(q, create=create), "FORMAT")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _format_of(q: str) -> int | None:
|
|
131
|
+
"""The number in queue q's FORMAT file, or None when it holds none."""
|
|
132
|
+
try:
|
|
133
|
+
with open(_format_file(q)) as f:
|
|
134
|
+
return int(f.read().strip())
|
|
135
|
+
except (OSError, ValueError):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _write_format(q: str) -> None:
|
|
140
|
+
"""Say what this queue's store looks like, where it does not say yet. Every writer that reaches a queue
|
|
141
|
+
does it (writing_queue), so a queue that exists names the format its records are in."""
|
|
142
|
+
path = _format_file(q, create=True)
|
|
143
|
+
if not os.path.exists(path):
|
|
144
|
+
paths.write_json(path, FORMAT)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def open_queue(q: str) -> str:
|
|
148
|
+
"""Queue q's directory, with the format of its store checked. The one place a queue is opened, so the one
|
|
149
|
+
place that refuses a queue this pp cannot read.
|
|
150
|
+
|
|
151
|
+
Every path that opens a queue comes through here: queues() for each queue it finds, the CLI for a queue it
|
|
152
|
+
is given by name, and work() and `worker start` for the queue they fall back to. A queue whose jobs another
|
|
153
|
+
pp wrote is refused rather than read, since this pp would report records it cannot see as gone, and launch
|
|
154
|
+
jobs beside ones that are still running."""
|
|
155
|
+
qdir = _qroot(q, create=False)
|
|
156
|
+
got = _format_of(q)
|
|
157
|
+
if got == FORMAT:
|
|
158
|
+
return qdir
|
|
159
|
+
# a job is a record, and a record is jobs/<id>.json wherever under jobs/ it lies, so the walk goes down:
|
|
160
|
+
# a store laid out another way, with a directory per state, is refused like any other foreign format. A
|
|
161
|
+
# run's claim and the name a write stopped halfway leaves are not records, so a queue whose records have
|
|
162
|
+
# all been cleaned holds nothing this pp cannot read.
|
|
163
|
+
has_jobs = any(RECORD.fullmatch(n) for _d, _subs, names in os.walk(os.path.join(qdir, "jobs"))
|
|
164
|
+
for n in names)
|
|
165
|
+
if got is None and not has_jobs:
|
|
166
|
+
return qdir # no record yet: a writer says what format this queue is in (writing_queue)
|
|
167
|
+
says = f"its FORMAT file says {got}" if got is not None else "it has no FORMAT file"
|
|
168
|
+
ui.die(f"{_PROG}: queue '{q}' holds jobs this pp cannot read: {says}. This pp reads format {FORMAT}. "
|
|
169
|
+
f"Drain that queue with the pp that wrote it, or remove {qdir}.", 2)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def writing_queue(q: str) -> str:
|
|
173
|
+
"""Queue q's directory, opened and ready to be written in: it exists, and it says what format its store is
|
|
174
|
+
in. Every writer that may be the one creating a queue's directory comes through here, so a queue that
|
|
175
|
+
exists says its format whether or not it holds a job."""
|
|
176
|
+
qdir = open_queue(q)
|
|
177
|
+
_write_format(q)
|
|
178
|
+
return qdir
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _store_lock() -> str:
|
|
182
|
+
return os.path.join(_top(), ".lock")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _counter_file() -> str:
|
|
186
|
+
return os.path.join(_top(), "counter")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _jpath(q: str, jid: int, create: bool = False) -> str:
|
|
190
|
+
"""Where job jid's record is in queue q. create: make the queue's directories, which only a writer does, so
|
|
191
|
+
a read of a job that is not there leaves nothing behind."""
|
|
192
|
+
return os.path.join(_qroot(q, "jobs", create=create), f"{jid}.json")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _job_ids(q: str) -> list[int]:
|
|
196
|
+
"""The ids in queue q, from one pass over its directory."""
|
|
197
|
+
try:
|
|
198
|
+
names = os.listdir(_qroot(q, "jobs", create=False))
|
|
199
|
+
except OSError:
|
|
200
|
+
return []
|
|
201
|
+
return sorted(int(n[:-5]) for n in names if RECORD.fullmatch(n))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _read_job(q: str, jid: int) -> dict | None:
|
|
205
|
+
"""Queue q's job jid, or None when it holds no readable record. Every write replaces the whole file, so a
|
|
206
|
+
read sees one whole record. The queue and the id come from the path, which names them."""
|
|
207
|
+
try:
|
|
208
|
+
with open(_jpath(q, jid)) as f:
|
|
209
|
+
j = json.load(f)
|
|
210
|
+
except (OSError, ValueError):
|
|
211
|
+
return None
|
|
212
|
+
if not isinstance(j, dict) or j.get("state") not in STATES:
|
|
213
|
+
return None
|
|
214
|
+
j.update(id=jid, queue=q)
|
|
215
|
+
return j
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _job_json(job: dict) -> dict:
|
|
219
|
+
return {k: v for k, v in job.items() if not k.startswith("_")}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _write_job(job: dict) -> None:
|
|
223
|
+
paths.write_json(_jpath(job["queue"], job["id"], create=True), _job_json(job))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _stage_job(job: dict, path: str) -> None:
|
|
227
|
+
"""The whole record, written under a name no other writer picks, ready to be linked to the name that takes
|
|
228
|
+
its id."""
|
|
229
|
+
paths.write_json(path, _job_json(job))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _remove_job(job: dict) -> None:
|
|
233
|
+
os.remove(_jpath(job["queue"], job["id"]))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _counter() -> int:
|
|
237
|
+
"""Where to start looking for a free job id. Never the authority: a lost counter costs a few looks, never
|
|
238
|
+
an id handed out twice."""
|
|
239
|
+
try:
|
|
240
|
+
with open(_counter_file()) as f:
|
|
241
|
+
return int(f.read().strip())
|
|
242
|
+
except (OSError, ValueError):
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class Txn:
|
|
247
|
+
"""The store, for the length of one scope: the only way pp reads or writes a job record, apart from find().
|
|
248
|
+
|
|
249
|
+
txn() holds the store lock, so nothing else reads or writes a record while the scope runs; listing() holds
|
|
250
|
+
it shared, for a pass that writes nothing. Either way the scope hands out the records it read under the
|
|
251
|
+
lock and takes back only those, so a copy read outside it can never be written over what another actor
|
|
252
|
+
recorded meanwhile.
|
|
253
|
+
|
|
254
|
+
with txn() as s:
|
|
255
|
+
j = s.job(jid, must_be=("running",)) # that job in one of those states, else None
|
|
256
|
+
if j:
|
|
257
|
+
j.update(rc=0, state="done")
|
|
258
|
+
s.save(j) # one replace of one file
|
|
259
|
+
|
|
260
|
+
s.jobs(state=, queue=) lists records, s.add(record, state) stores a new one, s.remove(j) drops one. A verb
|
|
261
|
+
that reports the state it found asks without must_be and reads j["state"].
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
def __init__(self, write: bool):
|
|
265
|
+
self.write = write
|
|
266
|
+
self._mine: dict[int, dict] = {} # id -> the one record object this scope hands out for it
|
|
267
|
+
self._ids: dict[str, list[int]] = {} # queue -> its ids, for the queues this scope has read
|
|
268
|
+
|
|
269
|
+
# -- reading
|
|
270
|
+
|
|
271
|
+
def job(self, jid: int, must_be: tuple = ()) -> dict | None:
|
|
272
|
+
"""The job with this id, in whichever queue it is. None when there is none, and when must_be names the
|
|
273
|
+
states it may be in and it is in another."""
|
|
274
|
+
j = self._mine.get(jid)
|
|
275
|
+
if j is None:
|
|
276
|
+
for q in queues():
|
|
277
|
+
j = self._keep(_read_job(q, jid))
|
|
278
|
+
if j is not None:
|
|
279
|
+
break
|
|
280
|
+
return None if j is None or (must_be and j["state"] not in must_be) else j
|
|
281
|
+
|
|
282
|
+
def jobs(self, state=(), queue=None) -> list[dict]:
|
|
283
|
+
"""The jobs in these states (every state when state is empty), in run order (prio, id). One pass over
|
|
284
|
+
each queue's directory, so a job is listed once, whole, and in one state."""
|
|
285
|
+
want = (state,) if isinstance(state, str) else tuple(state)
|
|
286
|
+
qs = queues() if queue is None else [queue] if isinstance(queue, str) else list(queue)
|
|
287
|
+
out = [j for q in qs for j in (self._mine.get(i) for i in self._pass(q))
|
|
288
|
+
if j is not None and (not want or j["state"] in want)]
|
|
289
|
+
out.sort(key=lambda j: (j.get("prio", 10), j["id"]))
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
# -- writing
|
|
293
|
+
|
|
294
|
+
def save(self, job: dict) -> None:
|
|
295
|
+
"""Write the record back: one replace of its file."""
|
|
296
|
+
self._writing(job)
|
|
297
|
+
_write_job(job)
|
|
298
|
+
|
|
299
|
+
def remove(self, job: dict) -> None:
|
|
300
|
+
self._writing(job)
|
|
301
|
+
_remove_job(job)
|
|
302
|
+
self._mine.pop(job["id"], None)
|
|
303
|
+
|
|
304
|
+
def add(self, job: dict, state: str) -> dict:
|
|
305
|
+
"""Store a new job of job["queue"], and hand it back with its id, state, log and added filled in.
|
|
306
|
+
|
|
307
|
+
The whole record is written under a name of this writer's own and then linked to jobs/<id>.json, which
|
|
308
|
+
fails if that name is there. So the id and the record that holds it land together: two adds at once
|
|
309
|
+
never choose the same id, and an add stopped in between leaves no job file, never an empty one.
|
|
310
|
+
|
|
311
|
+
An id names one job machine-wide, so one any queue holds is taken; `counter` says where to start
|
|
312
|
+
looking and is never the authority, so a lost one costs a few looks, never an id handed out twice."""
|
|
313
|
+
self._writing(job, new=True)
|
|
314
|
+
q = job["queue"]
|
|
315
|
+
writing_queue(q) # the queue is opened, so a record never lands in one this pp cannot read
|
|
316
|
+
others = [x for x in queues() if x != q]
|
|
317
|
+
tmp = paths.staging(_qroot(q, "jobs"), "job")
|
|
318
|
+
n = _counter()
|
|
319
|
+
try:
|
|
320
|
+
while True:
|
|
321
|
+
n += 1
|
|
322
|
+
if any(os.path.exists(_jpath(x, n)) for x in others):
|
|
323
|
+
continue
|
|
324
|
+
job.update(id=n, state=state, log=os.path.join(_qroot(q, "logs"), f"{n}.log"), added=time.time())
|
|
325
|
+
_stage_job(job, tmp)
|
|
326
|
+
try:
|
|
327
|
+
os.link(tmp, _jpath(q, n))
|
|
328
|
+
except FileExistsError:
|
|
329
|
+
continue
|
|
330
|
+
break
|
|
331
|
+
finally:
|
|
332
|
+
try:
|
|
333
|
+
os.remove(tmp)
|
|
334
|
+
except OSError:
|
|
335
|
+
pass
|
|
336
|
+
paths.write_json(_counter_file(), n)
|
|
337
|
+
self._mine[n] = job
|
|
338
|
+
if q in self._ids:
|
|
339
|
+
self._ids[q].append(n)
|
|
340
|
+
return job
|
|
341
|
+
|
|
342
|
+
# -- what the scope read, and what it may write
|
|
343
|
+
|
|
344
|
+
def _keep(self, j: dict | None) -> dict | None:
|
|
345
|
+
"""One record object per id for the scope's lifetime: the one save() takes back."""
|
|
346
|
+
if j is None:
|
|
347
|
+
return None
|
|
348
|
+
return self._mine.setdefault(j["id"], j)
|
|
349
|
+
|
|
350
|
+
def _pass(self, q: str) -> list[int]:
|
|
351
|
+
"""Queue q's ids, read once per scope."""
|
|
352
|
+
if q not in self._ids:
|
|
353
|
+
self._ids[q] = [jid for jid in _job_ids(q)
|
|
354
|
+
if jid in self._mine or self._keep(_read_job(q, jid)) is not None]
|
|
355
|
+
return self._ids[q]
|
|
356
|
+
|
|
357
|
+
def _writing(self, job: dict, new: bool = False) -> None:
|
|
358
|
+
if not self.write:
|
|
359
|
+
raise RuntimeError("the store is open for reading: a listing writes nothing")
|
|
360
|
+
if not new and self._mine.get(job.get("id")) is not job:
|
|
361
|
+
raise RuntimeError(f"job {job.get('id')}: this record did not come from this scope, so what it holds "
|
|
362
|
+
"may be older than the store; read it again with s.job()")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@contextmanager
|
|
366
|
+
def txn():
|
|
367
|
+
"""The store to this scope alone: read records, and change them. Every write of a job file happens here.
|
|
368
|
+
|
|
369
|
+
The scope holds the store lock (one for all the queues), so no other pp process reads or writes a record
|
|
370
|
+
while it runs, and the signals `lock.deferring` holds back are delivered when it ends, so a change is
|
|
371
|
+
never left half made. Keep it short: readers wait for it, so nothing that can block for long (a probe,
|
|
372
|
+
nvidia-smi, starting a process) belongs inside one. `TestContracts` in tests/test_constructs.py reads the
|
|
373
|
+
source and fails on a call to one of those inside a scope of this.
|
|
374
|
+
|
|
375
|
+
One lock is taken inside it, and only this one: the claim on a job's run, which a worker takes before the
|
|
376
|
+
record says the job is running (Worker.launch). A claim is taken without waiting, so the scope stays as
|
|
377
|
+
short as the two calls it takes; the job itself starts after the scope ends."""
|
|
378
|
+
with lock.locked(_store_lock(), defer=True):
|
|
379
|
+
yield Txn(write=True)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@contextmanager
|
|
383
|
+
def listing():
|
|
384
|
+
"""The store for reading: one pass over each queue's directory, with nothing writing meanwhile.
|
|
385
|
+
|
|
386
|
+
POSIX does not say what a directory read sees while entries in it are replaced, and a file system may
|
|
387
|
+
report one entry twice or skip it, so a listing holds the store lock shared: a writer waits for it. Other
|
|
388
|
+
pp processes list beside it; two threads of one pp take turns, since the lock helper keeps one open file
|
|
389
|
+
per lock. A txn inside one is refused, since it would have to let the lock go to take it alone, and what it
|
|
390
|
+
had read would then be older than the store."""
|
|
391
|
+
with lock.locked(_store_lock(), shared=True):
|
|
392
|
+
yield Txn(write=False)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _jobs(state=(), queue=None) -> list[dict]:
|
|
396
|
+
"""One listing, for a caller that needs one list of records and nothing else from it."""
|
|
397
|
+
with listing() as s:
|
|
398
|
+
return s.jobs(state, queue)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def find(jid: int) -> dict | None:
|
|
402
|
+
"""The job with this id, in whichever queue it is, or None.
|
|
403
|
+
|
|
404
|
+
Takes no lock: a job lives in one file, and every write of it is one replace, so one read sees one whole
|
|
405
|
+
record. A caller that needs several records to agree with each other asks a listing for them together."""
|
|
406
|
+
for q in queues():
|
|
407
|
+
j = _read_job(q, jid)
|
|
408
|
+
if j is not None:
|
|
409
|
+
return j
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _run_claim(q: str, jid: int) -> str:
|
|
414
|
+
"""The file whose claim one run of job jid holds. Taking it is how a worker says it is starting that job,
|
|
415
|
+
and the job's own wrapper holds it from then on, so a held claim means a run of this job is live."""
|
|
416
|
+
return os.path.join(_qroot(q, "jobs"), f"{jid}.run")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _new_rcfile(j: dict) -> str:
|
|
420
|
+
"""The file this attempt of job j writes its exit code to. One name per attempt, chosen as the attempt
|
|
421
|
+
starts and kept on the record, so no attempt can read the code another wrote and nothing has to be removed
|
|
422
|
+
before a launch."""
|
|
423
|
+
return os.path.join(_qroot(j["queue"], "rc"), f"{j['id']}.{j.get('attempt', 1)}.{secrets.token_hex(4)}")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _forget_job(s: Txn, job: dict) -> None:
|
|
427
|
+
"""In a transaction: take a job's record away, and with it every file that belongs to that job alone.
|
|
428
|
+
|
|
429
|
+
The record goes first, so no reader finds a job whose files are gone. Then the exit code each attempt
|
|
430
|
+
wrote, the job's log, the log of each hook that ran for it, and the claim its runs held. Only files under
|
|
431
|
+
the queue's own directories, never one pp did not write. What `drop` and `clean` both take away, so
|
|
432
|
+
neither leaves a file behind that nothing names any more: once the record is gone, nothing names them."""
|
|
433
|
+
q, jid = job["queue"], job["id"]
|
|
434
|
+
files = [p for p in [job.get("rcfile")] + [h.get("rcfile") for h in job.get("history") or []]
|
|
435
|
+
if p and os.path.dirname(p) == _qroot(q, "rc", create=False)]
|
|
436
|
+
if job.get("log") and os.path.dirname(job["log"]) == _qroot(q, "logs", create=False):
|
|
437
|
+
files.append(job["log"])
|
|
438
|
+
# a hook's log is named after the job and the event, so both names are this job's whether or not the
|
|
439
|
+
# record got as far as naming them (a hook that could not start wrote its first line and no more)
|
|
440
|
+
files += [os.path.join(_qroot(q, "hooks", create=False), f"{jid}-{e}.log") for e in ("failed", "done")]
|
|
441
|
+
s.remove(job)
|
|
442
|
+
for path in files:
|
|
443
|
+
try:
|
|
444
|
+
os.remove(path)
|
|
445
|
+
except OSError:
|
|
446
|
+
pass
|
|
447
|
+
# its record is gone and its run is over, so nothing holds this claim and this id never takes that name again
|
|
448
|
+
lock.Claim.forget(_run_claim(q, jid))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def ended_on_its_own(job: dict) -> tuple[int, float] | None:
|
|
452
|
+
"""(the exit code this attempt wrote, when it wrote it), or None when it wrote none. The one reader of an
|
|
453
|
+
attempt's exit code.
|
|
454
|
+
|
|
455
|
+
The job's own wrapper writes it, under the name the record names, after the command has returned and before
|
|
456
|
+
the wrapper exits. So a code means the command ran to its end, whatever else was on its way, and its time
|
|
457
|
+
is when the job really ended, however late a worker reaps it. A signal that ends the wrapper leaves no code.
|
|
458
|
+
Both come from one open file, so the code and its time are the same attempt's."""
|
|
459
|
+
path = job.get("rcfile")
|
|
460
|
+
if not path:
|
|
461
|
+
return None
|
|
462
|
+
try:
|
|
463
|
+
with open(path) as f:
|
|
464
|
+
return int(f.read().strip()), os.fstat(f.fileno()).st_mtime
|
|
465
|
+
except (OSError, ValueError):
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _finish(s: Txn, job: dict, state: str, rc: int | None, reason: str | None = None,
|
|
470
|
+
ended: float | None = None, not_retried: str | None = None, hook: str | None = None):
|
|
471
|
+
"""In a transaction: the job is over, with this outcome. `state` is what it ended as, which `decide` says
|
|
472
|
+
and nothing works out again.
|
|
473
|
+
|
|
474
|
+
The outcome and the hook it owes are written together (`hook_due`), and that field is cleared once the
|
|
475
|
+
hook has run (`Worker.fire`). So a worker that dies between the two leaves a record that says a hook is
|
|
476
|
+
owed, and the next pass over the queue fires it: a hook fires once, and not never."""
|
|
477
|
+
now = ended or time.time()
|
|
478
|
+
job.update(ended=now, seconds=now - (job.get("started") or now), rc=rc, state=state)
|
|
479
|
+
if reason:
|
|
480
|
+
job["reason"] = reason
|
|
481
|
+
if not_retried:
|
|
482
|
+
job["not_retried"] = not_retried
|
|
483
|
+
if hook:
|
|
484
|
+
job["hook_due"] = hook
|
|
485
|
+
s.save(job)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _requeue(s: Txn, job: dict, a: Action) -> str:
|
|
489
|
+
"""In a transaction: the job goes back to its queue for the attempt the action names, with the same id.
|
|
490
|
+
-> the line for its log, which the caller appends after the scope."""
|
|
491
|
+
attempt, retries = int(job.get("attempt", 1)), int(job.get("retries") or 0)
|
|
492
|
+
job.setdefault("history", []).append({"attempt": attempt, "rc": a.rc, "reason": a.reason,
|
|
493
|
+
"started": job.get("started"), "ended": a.ended,
|
|
494
|
+
"seconds": a.ended - (job.get("started") or a.ended),
|
|
495
|
+
"rcfile": job.get("rcfile")})
|
|
496
|
+
for k in ("pid", "pgid", "pstart", "boot", "started", "gpu", "stop", "rcfile"):
|
|
497
|
+
job.pop(k, None) # this attempt's, including the file it wrote its code to
|
|
498
|
+
job.update(attempt=a.attempt, not_before=a.not_before, state="queued")
|
|
499
|
+
s.save(job)
|
|
500
|
+
return (f"[pp queue] {datetime.now():%Y-%m-%d %H:%M:%S} attempt {attempt}/{retries + 1} after {a.why}"
|
|
501
|
+
f"; attempt {a.attempt} in {ui.human_duration(a.not_before - a.ended)}")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _group_handle(j: dict) -> proc.Handle | None:
|
|
505
|
+
"""The job's process group, as the handle every signal to it goes through: the group id, the start time
|
|
506
|
+
recorded with it, and the boot both were read in.
|
|
507
|
+
|
|
508
|
+
None while the job has no process yet, for a record that holds no start time, and for a record from
|
|
509
|
+
another boot, which names no process here. `Worker._signal_group` then falls back to the pid in the job's
|
|
510
|
+
run claim, which is held by a live process and so is always this boot's."""
|
|
511
|
+
if j.get("boot") != proc.boot_id():
|
|
512
|
+
return None
|
|
513
|
+
return proc.Handle.recorded(j.get("pgid"), j.get("pstart"), group=True)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _by() -> str:
|
|
517
|
+
"""Who asked, as a record names them: the calling agent session, or the user."""
|
|
518
|
+
who = session.caller()
|
|
519
|
+
return who["session"] if who else "user"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def add_note(jid: int, text: str, by: str | None = None) -> dict | None:
|
|
523
|
+
"""Append a note to a job in any state; the updated job, or None when there is no such job."""
|
|
524
|
+
if by is None:
|
|
525
|
+
by = _by()
|
|
526
|
+
with txn() as s:
|
|
527
|
+
j = s.job(jid)
|
|
528
|
+
if not j:
|
|
529
|
+
return None
|
|
530
|
+
j.setdefault("notes", []).append({"t": time.time(), "text": text, "by": by})
|
|
531
|
+
s.save(j)
|
|
532
|
+
return j
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ---- durations, small parsing ------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
parse_duration = config.parse_duration
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _dur_arg(v, what: str, default_unit: str = "s") -> float | None:
|
|
541
|
+
try:
|
|
542
|
+
return parse_duration(v, default_unit)
|
|
543
|
+
except ValueError as e:
|
|
544
|
+
ui.die(f"{_PROG}: {what}: {e}", 2)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _num(v) -> int | float:
|
|
548
|
+
f = float(v)
|
|
549
|
+
return int(f) if f.is_integer() else f
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _hook_spec(v) -> str | None:
|
|
553
|
+
v = (v or "").strip() if isinstance(v, str) else v
|
|
554
|
+
return None if not v or v == "none" else str(v)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def hook_argv(spec: str | None) -> list[str] | None:
|
|
558
|
+
"""argv a hook runs as: a shell command."""
|
|
559
|
+
spec = _hook_spec(spec)
|
|
560
|
+
return None if spec is None else ["/bin/sh", "-c", spec]
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# ---- resources ------------------------------------------------------------------------------------
|
|
564
|
+
#
|
|
565
|
+
# The pool model as users see it (shared and per-queue pools, probes, settle) is `pp help queue`'s "Resources"; the
|
|
566
|
+
# defaults below are the ones it states. Here: a job's needs are a dict NAME -> amount; Resources.check asks every pool
|
|
567
|
+
# that provides a NAME, a probe's reading is cached in probes/ for `every`, and jobs launched less than `settle`
|
|
568
|
+
# before a reading are subtracted from it.
|
|
569
|
+
|
|
570
|
+
SHARED_DEFAULTS = {"cpu": "auto", "mem_gb": "auto", "gpu_gb": "auto"}
|
|
571
|
+
QUEUE_DEFAULTS = {"slots": 3}
|
|
572
|
+
POOL_KEYS = ("max", "probe", "every", "settle")
|
|
573
|
+
PROBE_EVERY = 30.0
|
|
574
|
+
PROBE_WAIT = 60.0 # how long a probe may take before pp ends it and its reading says so
|
|
575
|
+
SETTLE = 30.0 # a reading (nvidia-smi, a probe) may not show a job launched less than this before it
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
QUEUE_KEYS = ("timeout", "retries", "retry_delay", "retry_if", "on_fail", "on_done", "idle_exit", "poll", "backfill",
|
|
579
|
+
"resources")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _qcfg(q: str, empty_ok: bool = True) -> dict:
|
|
583
|
+
"""[queue.q], checked: a typo or a bad value is a ConfigError naming it, never a silent default or a traceback.
|
|
584
|
+
Durations come back in seconds. empty_ok False: what a launch reads (config.load)."""
|
|
585
|
+
t = config.load("queue", empty_ok).get(q, {})
|
|
586
|
+
if not isinstance(t, dict):
|
|
587
|
+
raise config.ConfigError(f"[queue.{q}] must be a table")
|
|
588
|
+
t = dict(t)
|
|
589
|
+
where = f"[queue.{q}]"
|
|
590
|
+
config.check_keys(t, where, QUEUE_KEYS)
|
|
591
|
+
for k in ("timeout", "retry_delay", "idle_exit", "poll"):
|
|
592
|
+
if k in t:
|
|
593
|
+
try:
|
|
594
|
+
t[k] = parse_duration(t[k])
|
|
595
|
+
except ValueError as e:
|
|
596
|
+
raise config.ConfigError(f"{where} {k}: {e}") from None
|
|
597
|
+
if "retries" in t and (isinstance(t["retries"], bool) or not isinstance(t["retries"], int) or t["retries"] < 0):
|
|
598
|
+
raise config.ConfigError(f"{where} retries = {t['retries']!r}: a whole number >= 0")
|
|
599
|
+
if "backfill" in t and not isinstance(t["backfill"], bool):
|
|
600
|
+
raise config.ConfigError(f"{where} backfill = {t['backfill']!r}: true or false")
|
|
601
|
+
for k in ("retry_if", "on_fail", "on_done"):
|
|
602
|
+
if k in t and not isinstance(t[k], str):
|
|
603
|
+
raise config.ConfigError(f"{where} {k} = {t[k]!r}: a string")
|
|
604
|
+
if t.get("retry_if"):
|
|
605
|
+
try:
|
|
606
|
+
re.compile(t["retry_if"])
|
|
607
|
+
except re.error as e:
|
|
608
|
+
raise config.ConfigError(f"{where} retry_if {t['retry_if']!r}: {e}") from None
|
|
609
|
+
return t
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _mem_total_gb() -> float:
|
|
613
|
+
try:
|
|
614
|
+
with open("/proc/meminfo") as f:
|
|
615
|
+
for line in f:
|
|
616
|
+
if line.startswith("MemTotal:"):
|
|
617
|
+
return int(line.split()[1]) / 1024 / 1024
|
|
618
|
+
except OSError:
|
|
619
|
+
pass
|
|
620
|
+
return 0.0
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
CARDS_WHY = "" # why the last _cards() gave None, for the messages that report it ("" when it had no card)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _cards() -> list[dict] | None:
|
|
627
|
+
"""[{index, name, total_gb, used_gb, free_gb, util}] from nvidia-smi; None when unreadable. Tests patch this."""
|
|
628
|
+
global CARDS_WHY
|
|
629
|
+
CARDS_WHY = ""
|
|
630
|
+
try:
|
|
631
|
+
out = proc.run(["nvidia-smi", "--query-gpu=index,name,memory.total,memory.used,utilization.gpu",
|
|
632
|
+
"--format=csv,noheader,nounits"], group="own", stdin="none", env="inherit", timeout=10)
|
|
633
|
+
except FileNotFoundError:
|
|
634
|
+
CARDS_WHY = "nvidia-smi is not installed"
|
|
635
|
+
return None
|
|
636
|
+
except OSError as e:
|
|
637
|
+
CARDS_WHY = f"nvidia-smi: {e}"
|
|
638
|
+
return None
|
|
639
|
+
if out.timed_out:
|
|
640
|
+
CARDS_WHY = "nvidia-smi did not answer in 10s"
|
|
641
|
+
return None
|
|
642
|
+
if out.returncode != 0:
|
|
643
|
+
said = (out.stderr.strip() or out.stdout.strip()).splitlines()
|
|
644
|
+
CARDS_WHY = f"nvidia-smi exited {out.returncode}" + (f": {said[-1][:120]}" if said else "")
|
|
645
|
+
return None
|
|
646
|
+
cards = []
|
|
647
|
+
for line in out.stdout.strip().splitlines():
|
|
648
|
+
parts = [x.strip() for x in line.split(",")]
|
|
649
|
+
try: # the name may hold commas: index first, the three numbers last
|
|
650
|
+
total, used = float(parts[-3]) / 1024, float(parts[-2]) / 1024
|
|
651
|
+
cards.append({"index": int(parts[0]), "name": ",".join(parts[1:-3]), "total_gb": total,
|
|
652
|
+
"used_gb": used, "free_gb": total - used, "util": parts[-1]})
|
|
653
|
+
except (ValueError, IndexError):
|
|
654
|
+
continue
|
|
655
|
+
return cards or None
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _pool(name: str, v, scope: str | None) -> dict | None:
|
|
659
|
+
where = (f"[queue.{scope}.resources] " if scope else "[resources] ") + name
|
|
660
|
+
if v is False: # cpu = false: no such pool here
|
|
661
|
+
return None
|
|
662
|
+
t = dict(v) if isinstance(v, dict) else {"max": v}
|
|
663
|
+
bad = sorted(set(t) - set(POOL_KEYS))
|
|
664
|
+
if bad:
|
|
665
|
+
raise config.ConfigError(f"{where}: unknown {', '.join(bad)} (a table takes {', '.join(POOL_KEYS)})")
|
|
666
|
+
p = {"name": name, "scope": scope, "where": where, "max": None, "probe": None, "every": PROBE_EVERY,
|
|
667
|
+
"settle": SETTLE}
|
|
668
|
+
mx = t.get("max")
|
|
669
|
+
if mx == "auto":
|
|
670
|
+
if name not in SHARED_DEFAULTS:
|
|
671
|
+
raise config.ConfigError(f'{where} = "auto": only cpu, mem_gb and gpu_gb know their own size')
|
|
672
|
+
mx = {"cpu": os.cpu_count() or 1, "mem_gb": round(_mem_total_gb()), "gpu_gb": None}[name] # gpu: per card
|
|
673
|
+
elif mx is not None:
|
|
674
|
+
try:
|
|
675
|
+
mx = _num(mx)
|
|
676
|
+
except (TypeError, ValueError):
|
|
677
|
+
raise config.ConfigError(f"{where} = {mx!r}: not a number") from None
|
|
678
|
+
p["max"] = mx
|
|
679
|
+
if t.get("probe") is not None:
|
|
680
|
+
if name == "gpu_gb":
|
|
681
|
+
raise config.ConfigError(f"{where}: gpu_gb is probed with nvidia-smi already")
|
|
682
|
+
if not isinstance(t["probe"], str) or not t["probe"].strip():
|
|
683
|
+
raise config.ConfigError(f"{where}: probe is a shell command that prints the amount free")
|
|
684
|
+
p["probe"] = t["probe"]
|
|
685
|
+
for k in ("every", "settle"):
|
|
686
|
+
if k in t:
|
|
687
|
+
try:
|
|
688
|
+
p[k] = parse_duration(t[k]) or 0.0
|
|
689
|
+
except ValueError as e:
|
|
690
|
+
raise config.ConfigError(f"{where} {k}: {e}") from None
|
|
691
|
+
if p["max"] is None and p["probe"] is None and name != "gpu_gb":
|
|
692
|
+
raise config.ConfigError(f"{where}: a number, or a table with max and/or probe")
|
|
693
|
+
return p
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def pools(q: str, empty_ok: bool = True) -> list[dict]:
|
|
697
|
+
"""The pools queue q's jobs draw from: its own ([queue.q.resources]), then the shared ones ([resources])."""
|
|
698
|
+
own = _qcfg(q, empty_ok).get("resources", {})
|
|
699
|
+
if not isinstance(own, dict):
|
|
700
|
+
raise config.ConfigError(f"[queue.{q}.resources] must be a table")
|
|
701
|
+
own = {**QUEUE_DEFAULTS, **own}
|
|
702
|
+
shared = {**SHARED_DEFAULTS, **config.load("resources", empty_ok)}
|
|
703
|
+
return [p for p in [_pool(k, v, q) for k, v in own.items()] + [_pool(k, v, None) for k, v in shared.items()] if p]
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _probe_path(p: dict) -> str:
|
|
707
|
+
return os.path.join(_top("probes"), f"{p['scope'] or '_shared'}.{p['name']}.json")
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _probe_claim(p: dict) -> str:
|
|
711
|
+
return os.path.join(_top("probes"), f"{p['scope'] or '_shared'}.{p['name']}.lock")
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _cached(path: str) -> dict | None:
|
|
715
|
+
try:
|
|
716
|
+
with open(path) as f:
|
|
717
|
+
return json.load(f)
|
|
718
|
+
except (OSError, json.JSONDecodeError):
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def reading(p: dict, run: bool = True) -> dict | None:
|
|
723
|
+
"""A probe pool's latest reading {t, value, error}: the cached one while younger than `every`, which every
|
|
724
|
+
worker and `status` share. Where that one is older, a fresh reading, which only `run` asks for.
|
|
725
|
+
|
|
726
|
+
One run of a probe at a time: whoever takes the probe's claim runs it, and anyone else uses the reading
|
|
727
|
+
that is there, which is what a cached reading is for. Nothing waits for a probe (they may take a minute),
|
|
728
|
+
and the user's command is never run twice over at once.
|
|
729
|
+
|
|
730
|
+
Where another holder is running the first reading there is, this gives back {t, value: None, running: True}:
|
|
731
|
+
a job waits for that reading, and nobody is told the probe failed."""
|
|
732
|
+
path = _probe_path(p)
|
|
733
|
+
r = _cached(path)
|
|
734
|
+
if not run or (r and time.time() - float(r.get("t", 0)) < p["every"]):
|
|
735
|
+
return r
|
|
736
|
+
claim = lock.Claim.take(_probe_claim(p))
|
|
737
|
+
if claim is None:
|
|
738
|
+
# someone is running it now: this reading is the one they are replacing, and there may be none yet
|
|
739
|
+
return r or {"t": time.time(), "value": None, "running": True}
|
|
740
|
+
with claim:
|
|
741
|
+
t = time.time()
|
|
742
|
+
try:
|
|
743
|
+
# its own session, so the probe's own children end with it when its time runs out
|
|
744
|
+
out = proc.run(["/bin/sh", "-c", p["probe"]], group="own", stdin="none", env="scrubbed",
|
|
745
|
+
cwd=os.path.expanduser("~"), timeout=PROBE_WAIT)
|
|
746
|
+
except OSError as e:
|
|
747
|
+
r = {"t": t, "value": None, "error": str(e)[:160]}
|
|
748
|
+
else:
|
|
749
|
+
m = re.search(r"-?\d+(?:\.\d+)?", out.stdout)
|
|
750
|
+
if out.timed_out:
|
|
751
|
+
r = {"t": t, "value": None, "error": f"it did not answer in {PROBE_WAIT:g}s"}
|
|
752
|
+
elif out.returncode == 0 and m:
|
|
753
|
+
r = {"t": t, "value": _num(m[0]), "error": None}
|
|
754
|
+
else:
|
|
755
|
+
said = (out.stderr.strip() or out.stdout.strip()).splitlines()
|
|
756
|
+
r = {"t": t, "value": None, "error": (f"exit {out.returncode}" if out.returncode else "printed no number")
|
|
757
|
+
+ (f": {said[-1][:120]}" if said else "")}
|
|
758
|
+
paths.write_json(path, r)
|
|
759
|
+
return r
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _need(j: dict, k: str):
|
|
763
|
+
return (j.get("needs") or {}).get(k, 0)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
class Resources:
|
|
767
|
+
"""Queue q's pools with their live readings (nvidia-smi, probes), taken once and outside the store lock;
|
|
768
|
+
admit() then runs under the lock against every queue's running jobs."""
|
|
769
|
+
|
|
770
|
+
def __init__(self, q: str, needed=(), live: bool = True, empty_ok: bool = True):
|
|
771
|
+
self.q = q
|
|
772
|
+
self.pools = pools(q, empty_ok)
|
|
773
|
+
self.by_name: dict[str, list[dict]] = {}
|
|
774
|
+
for p in self.pools:
|
|
775
|
+
self.by_name.setdefault(p["name"], []).append(p)
|
|
776
|
+
needed = set(needed)
|
|
777
|
+
self.cards_t = time.time()
|
|
778
|
+
self.cards = _cards() if "gpu_gb" in needed and "gpu_gb" in self.by_name else None
|
|
779
|
+
self.readings = {(p["scope"], p["name"]): reading(p, run=live)
|
|
780
|
+
for p in self.pools if p["probe"] and p["name"] in needed}
|
|
781
|
+
|
|
782
|
+
def _in(self, p: dict, running: list[dict]) -> list[dict]:
|
|
783
|
+
return running if p["scope"] is None else [r for r in running if r["queue"] == p["scope"]]
|
|
784
|
+
|
|
785
|
+
def gpu_budget(self, p: dict, card: dict) -> float:
|
|
786
|
+
return p["max"] if p["max"] is not None else round(max(card["total_gb"] - 2, 0), 1)
|
|
787
|
+
|
|
788
|
+
def check(self, needs: dict) -> str | None:
|
|
789
|
+
"""Why these needs can never fit (nothing provides one, or it exceeds a budget); None when they might."""
|
|
790
|
+
for k, v in needs.items():
|
|
791
|
+
ps = self.by_name.get(k)
|
|
792
|
+
if not ps:
|
|
793
|
+
return (f"nothing provides '{k}': set [resources] {k} (shared by every queue) or "
|
|
794
|
+
f"[queue.{self.q}.resources] {k} (this queue's own) in {config.global_path()}")
|
|
795
|
+
if k == "gpu_gb":
|
|
796
|
+
if not self.cards:
|
|
797
|
+
return "needs gpu_gb but nvidia-smi sees no GPU" + (f" ({CARDS_WHY})" if CARDS_WHY else "")
|
|
798
|
+
top = max(min(self.gpu_budget(p, c) for p in ps) for c in self.cards)
|
|
799
|
+
if v > top:
|
|
800
|
+
return f"needs gpu_gb={v:g} but no card's budget is that large ({top:g})"
|
|
801
|
+
continue
|
|
802
|
+
for p in ps:
|
|
803
|
+
if p["max"] is not None and v > p["max"]:
|
|
804
|
+
return f"needs {k}={v:g} but {p['where']} = {p['max']:g}"
|
|
805
|
+
return None
|
|
806
|
+
|
|
807
|
+
def admit(self, job: dict, running: list[dict]) -> tuple[bool, str, int | None]:
|
|
808
|
+
"""(fits, why not, card). running = every queue's running jobs. `why` keeps its fluctuating detail after
|
|
809
|
+
';' so the worker notes a change of reason only."""
|
|
810
|
+
needs = job.get("needs") or {}
|
|
811
|
+
for k, v in needs.items():
|
|
812
|
+
if k == "gpu_gb":
|
|
813
|
+
continue
|
|
814
|
+
ps = self.by_name.get(k)
|
|
815
|
+
if not ps:
|
|
816
|
+
return False, f"{k}: nothing provides it ([resources] or [queue.{self.q}.resources] {k})", None
|
|
817
|
+
for p in ps:
|
|
818
|
+
if p["max"] is not None:
|
|
819
|
+
used = sum(_need(r, k) for r in self._in(p, running))
|
|
820
|
+
if used + v > p["max"]:
|
|
821
|
+
return False, f"{k}: {p['where']}; {used:g}+{v:g} > {p['max']:g}", None
|
|
822
|
+
if p["probe"]:
|
|
823
|
+
r = self.readings.get((p["scope"], p["name"]))
|
|
824
|
+
if r and r.get("running"):
|
|
825
|
+
return False, f"{k}: its probe is being read ({p['where']})", None
|
|
826
|
+
if not r or r.get("value") is None:
|
|
827
|
+
return False, f"{k}: probe failed ({p['where']}); {(r or {}).get('error') or 'no reading'}", None
|
|
828
|
+
pending = sum(_need(x, k) for x in running if (x.get("started") or 0) + p["settle"] > r["t"])
|
|
829
|
+
free = r["value"] - pending
|
|
830
|
+
if v > free:
|
|
831
|
+
return False, (f"{k}: not free ({p['where']}); probe says {r['value']:g}"
|
|
832
|
+
+ (f", {pending:g} of it launched since" if pending else "") + f", need {v:g}"), None
|
|
833
|
+
need = needs.get("gpu_gb", 0)
|
|
834
|
+
if not need:
|
|
835
|
+
return True, "", None
|
|
836
|
+
ps = self.by_name.get("gpu_gb")
|
|
837
|
+
if not ps:
|
|
838
|
+
return False, "gpu_gb: nothing provides it", None
|
|
839
|
+
if not self.cards:
|
|
840
|
+
return False, "gpu: nvidia-smi unreadable" + (f" ({CARDS_WHY})" if CARDS_WHY else ""), None
|
|
841
|
+
settle = max(p["settle"] for p in ps)
|
|
842
|
+
whys = []
|
|
843
|
+
for c in sorted(self.cards, key=lambda c: -c["free_gb"]):
|
|
844
|
+
i = c["index"]
|
|
845
|
+
on = [r for r in running if r.get("gpu") == i]
|
|
846
|
+
why = None
|
|
847
|
+
for p in ps:
|
|
848
|
+
reserved, budget = sum(_need(r, "gpu_gb") for r in self._in(p, on)), self.gpu_budget(p, c)
|
|
849
|
+
if reserved + need > budget:
|
|
850
|
+
why = f"card {i} reserved {reserved:g}+{need:g} > {budget:g} ({p['where']})"
|
|
851
|
+
break
|
|
852
|
+
if why is None:
|
|
853
|
+
# what the reading cannot see yet: jobs launched on this card less than `settle` before it
|
|
854
|
+
pending = sum(_need(r, "gpu_gb") for r in on if (r.get("started") or 0) + settle > self.cards_t)
|
|
855
|
+
if c["free_gb"] - pending < need:
|
|
856
|
+
why = f"card {i} free {c['free_gb'] - pending:.1f}" + (
|
|
857
|
+
f" ({pending:g} launched in the last {settle:g}s, not allocated yet)" if pending else "")
|
|
858
|
+
if why is None:
|
|
859
|
+
return True, "", i
|
|
860
|
+
whys.append(why)
|
|
861
|
+
return False, f"gpu_gb {need:g} does not fit on any card; {', '.join(sorted(whys))}", None
|
|
862
|
+
|
|
863
|
+
def usage(self, running: list[dict], scope: str | None) -> list[dict]:
|
|
864
|
+
"""The pools of one scope (a queue name, or None = shared) with what running jobs hold: for status."""
|
|
865
|
+
out = []
|
|
866
|
+
for p in self.pools:
|
|
867
|
+
if p["scope"] != scope or p["name"] == "gpu_gb":
|
|
868
|
+
continue
|
|
869
|
+
r = reading(p, run=False) if p["probe"] else None
|
|
870
|
+
out.append({"name": p["name"], "max": p["max"], "used": sum(_need(j, p["name"]) for j in self._in(p, running)),
|
|
871
|
+
"probe": p["probe"], "reading": r})
|
|
872
|
+
return out
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# ---- how a job ends: one decision ------------------------------------------------------------------
|
|
876
|
+
#
|
|
877
|
+
# Five things can be true of a running job at once: a run of it is live or over, it wrote an exit code or it
|
|
878
|
+
# did not, a stop is on its record or there is none, the grace that stop was given is up or it is not, and its
|
|
879
|
+
# own time has run out or it has not. `decide` is the one place that says what is to be done about it, from all
|
|
880
|
+
# of them together. It reads facts and the job's record, and writes nothing: no clock, no store, no process,
|
|
881
|
+
# which `TestContracts` in tests/test_constructs.py reads the source to keep.
|
|
882
|
+
#
|
|
883
|
+
# Around it, `Worker.carry_out` gathers the facts about each running job, calls this once, and carries the one
|
|
884
|
+
# action out: the signal through the job's Handle, the record in one transaction, the hook after it. So a
|
|
885
|
+
# timeout, a kill and a reap are not three actors with three views of one job; they are facts about it, and one
|
|
886
|
+
# decision made from all of them.
|
|
887
|
+
|
|
888
|
+
NOTHING = "nothing" # leave it as it is
|
|
889
|
+
TIME_UP = "time-up" # write a timeout's stop on it; from there it is a stop like any other
|
|
890
|
+
TERM = "term" # SIGTERM to its group, recorded as the stop's `sent`
|
|
891
|
+
KILL9 = "kill9" # SIGKILL to its group, recorded as the stop's `kill9`
|
|
892
|
+
ENDED_AS = "ended-as" # record how it ended, and the hook that is owed for it
|
|
893
|
+
AGAIN = "again" # back to its queue for another attempt
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
@dataclass(frozen=True)
|
|
897
|
+
class Facts:
|
|
898
|
+
"""What is true of one running job apart from its record, read before the decision and never inside it.
|
|
899
|
+
|
|
900
|
+
held a run of this attempt is live: its own wrapper holds the job's run claim. While it is held the
|
|
901
|
+
attempt's exit code counts for nothing, since the command itself can write that file ($PP_QUEUE_RCFILE
|
|
902
|
+
is in its environment); a code is the command's last word only once the run is over.
|
|
903
|
+
rc, rc_at the exit code this attempt wrote and when it wrote it (`ended_on_its_own`), or None for both.
|
|
904
|
+
sig the signal the attempt's process died of, where this worker started it and so holds the child.
|
|
905
|
+
now the time the facts were read at, which is the only clock the decision has.
|
|
906
|
+
tail the job's log as --retry-if matches against it, read where the job asks for one.
|
|
907
|
+
"""
|
|
908
|
+
|
|
909
|
+
held: bool
|
|
910
|
+
rc: int | None = None
|
|
911
|
+
rc_at: float | None = None
|
|
912
|
+
sig: int | None = None
|
|
913
|
+
now: float = 0.0
|
|
914
|
+
tail: str = ""
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
@dataclass(frozen=True)
|
|
918
|
+
class Action:
|
|
919
|
+
"""The one thing to be done about a job. `do` is which, and a field a kind does not use is None.
|
|
920
|
+
|
|
921
|
+
nothing leave it alone
|
|
922
|
+
time-up t, grace write {by: timeout, timeout: True, t, grace} on it
|
|
923
|
+
term SIGTERM to its group
|
|
924
|
+
kill9 SIGKILL to its group
|
|
925
|
+
ended-as state, rc, reason, hook, ended, sweep, not_retried record how it ended
|
|
926
|
+
again attempt, not_before, rc, reason, ended, sweep back to its queue for another attempt
|
|
927
|
+
|
|
928
|
+
hook is the event whose hook is owed ("failed", "done"), or None where the outcome owes none or the job
|
|
929
|
+
carries no such hook. sweep says anything left in the job's process group goes with it.
|
|
930
|
+
"""
|
|
931
|
+
|
|
932
|
+
do: str
|
|
933
|
+
state: str | None = None
|
|
934
|
+
rc: int | None = None
|
|
935
|
+
reason: str | None = None
|
|
936
|
+
hook: str | None = None
|
|
937
|
+
ended: float | None = None
|
|
938
|
+
sweep: bool = False
|
|
939
|
+
attempt: int | None = None
|
|
940
|
+
not_before: float | None = None
|
|
941
|
+
not_retried: str | None = None
|
|
942
|
+
t: float | None = None
|
|
943
|
+
grace: float | None = None
|
|
944
|
+
|
|
945
|
+
@property
|
|
946
|
+
def why(self) -> str:
|
|
947
|
+
"""The failure as the job's log names it and --retry-if matches it: a reason, or the exit code."""
|
|
948
|
+
return self.reason or f"exit {self.rc}"
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def matches_retry_if(job: dict, why: str, tail: str) -> bool:
|
|
952
|
+
"""Whether --retry-if matches this failure: its reason, or the tail of the job's log."""
|
|
953
|
+
pat = job.get("retry_if")
|
|
954
|
+
if not pat:
|
|
955
|
+
return True
|
|
956
|
+
try:
|
|
957
|
+
return re.search(pat, why + "\n" + tail, re.M) is not None # ^ $ per line
|
|
958
|
+
except re.error:
|
|
959
|
+
return False
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def decide(job: dict, facts: Facts) -> Action:
|
|
963
|
+
"""What is to be done about this running job: one action, from its record and the facts read with it.
|
|
964
|
+
|
|
965
|
+
The rules are the ones `pp help queue` states under "How a job ends", in the order they are tried here.
|
|
966
|
+
That help is their one statement; `TestTheDecision` in tests/test_queue.py is the table over every
|
|
967
|
+
combination of the facts, and it checks each of its rules against that help.
|
|
968
|
+
|
|
969
|
+
What a stop is does not rest on who asked for it: a timeout's carries `timeout`, so an asker called
|
|
970
|
+
`timeout` writes a kill's stop like anyone else.
|
|
971
|
+
"""
|
|
972
|
+
stop = job.get("stop") or {}
|
|
973
|
+
asked = bool(stop) and not stop.get("timeout") # a kill: someone asked for this job to stop
|
|
974
|
+
out = bool(stop.get("sent")) or bool(stop.get("kill9")) # a signal of pp's went out for this stop
|
|
975
|
+
if facts.held:
|
|
976
|
+
# a run of it is live, so nothing is recorded about how it ended and its exit code says nothing yet:
|
|
977
|
+
# the one thing to do about it is to carry out the stop it carries, or to write a timeout's
|
|
978
|
+
if stop.get("denied"):
|
|
979
|
+
return Action(NOTHING) # pp was refused this job's group: nothing more goes out for it
|
|
980
|
+
if not stop:
|
|
981
|
+
due = (job.get("timeout") and job.get("started")
|
|
982
|
+
and facts.now - float(job["started"]) >= float(job["timeout"]))
|
|
983
|
+
return Action(TIME_UP, t=facts.now, grace=TIMEOUT_GRACE) if due else Action(NOTHING)
|
|
984
|
+
if stop.get("sent") is None:
|
|
985
|
+
return Action(TERM)
|
|
986
|
+
if not stop.get("kill9") and facts.now - float(stop["sent"]) >= float(stop.get("grace") or 0):
|
|
987
|
+
return Action(KILL9)
|
|
988
|
+
return Action(NOTHING) # signalled, and inside the grace it was given
|
|
989
|
+
# its run is over: how it ended is recorded here, and nothing of it is signalled again. A stop that was
|
|
990
|
+
# asked of it takes its group with it, so a command that held the signal back does not outlive the job.
|
|
991
|
+
sweep = bool(stop) and not stop.get("denied")
|
|
992
|
+
own = None if facts.rc is not None else (f"signal {facts.sig}" if facts.sig else "lost")
|
|
993
|
+
if asked and out:
|
|
994
|
+
# a kill's signal stopped it: "killed", and no hook, unless it wrote its own code as the kill arrived
|
|
995
|
+
return _ends(job, facts.rc, None if facts.rc is not None else "killed", facts.rc_at, sweep,
|
|
996
|
+
None if facts.rc is None else ("done" if facts.rc == 0 else "failed"))
|
|
997
|
+
if asked:
|
|
998
|
+
# a kill wrote the stop and no signal of pp's ever went out, so the job ended as it ended: that
|
|
999
|
+
# outcome, and the hook it owes. A job a kill has touched is never retried, whatever it had left
|
|
1000
|
+
return _ends(job, facts.rc, own, facts.rc_at, sweep, "done" if facts.rc == 0 else "failed")
|
|
1001
|
+
if facts.rc == 0:
|
|
1002
|
+
return _ends(job, 0, None, facts.rc_at, sweep, "done")
|
|
1003
|
+
if facts.rc is None and (out or stop.get("denied")):
|
|
1004
|
+
# a timeout's stop: pp's signal ended it, or pp was refused it and the attempt is over at its timeout
|
|
1005
|
+
return _or_again(job, facts, None, "timeout", None, sweep)
|
|
1006
|
+
# it ended on its own: the code it wrote, the signal it died of, or nothing at all
|
|
1007
|
+
return _or_again(job, facts, facts.rc, own, facts.rc_at, sweep)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _hook_due(job: dict, event: str | None) -> str | None:
|
|
1011
|
+
"""The hook event this outcome owes, or None: the outcome owes none, or the job carries no such hook."""
|
|
1012
|
+
if event is None:
|
|
1013
|
+
return None
|
|
1014
|
+
return event if _hook_spec(job.get("on_fail" if event == "failed" else "on_done")) else None
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _ends(job: dict, rc: int | None, reason: str | None, ended: float | None, sweep: bool, event: str | None,
|
|
1018
|
+
not_retried: str | None = None) -> Action:
|
|
1019
|
+
"""The job is over: its state follows its exit code, and the hook that outcome owes is the job's own."""
|
|
1020
|
+
return Action(ENDED_AS, state="done" if rc == 0 and not reason else "failed", rc=rc, reason=reason,
|
|
1021
|
+
hook=_hook_due(job, event), ended=ended, sweep=sweep, not_retried=not_retried)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _or_again(job: dict, facts: Facts, rc: int | None, reason: str | None, ended: float | None,
|
|
1025
|
+
sweep: bool) -> Action:
|
|
1026
|
+
"""A failure of the job's own: another attempt where it has one left and --retry-if matches it, else the
|
|
1027
|
+
end of it. A delay doubles per attempt, up to RETRY_DELAY_CAP."""
|
|
1028
|
+
attempt, retries = int(job.get("attempt", 1)), int(job.get("retries") or 0)
|
|
1029
|
+
why = reason or f"exit {rc}"
|
|
1030
|
+
left = attempt <= retries
|
|
1031
|
+
if left and matches_retry_if(job, why, facts.tail):
|
|
1032
|
+
now = ended or facts.now
|
|
1033
|
+
base = job.get("retry_delay")
|
|
1034
|
+
delay = min(float(RETRY_DELAY if base is None else base) * 2 ** (attempt - 1), RETRY_DELAY_CAP)
|
|
1035
|
+
return Action(AGAIN, rc=rc, reason=reason, ended=now, sweep=sweep, attempt=attempt + 1,
|
|
1036
|
+
not_before=now + delay)
|
|
1037
|
+
# attempts were left, but retry_if says this failure is not worth one
|
|
1038
|
+
nr = f"neither the reason ({why}) nor the log tail matches /{job.get('retry_if')}/" if left else None
|
|
1039
|
+
return _ends(job, rc, reason, ended, sweep, "failed", nr)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def merge_stop(existing: dict | None, ask: dict) -> dict:
|
|
1043
|
+
"""The one stop this run of the job carries, once this ask has landed on it. Pure: the caller names who
|
|
1044
|
+
asked and when.
|
|
1045
|
+
|
|
1046
|
+
One stop per run: what went out is all the decision and the waiting verb have to go on, so a second ask
|
|
1047
|
+
never writes over `t`, `sent` or `kill9`, and keeps only the shorter of the two graces, since a second ask
|
|
1048
|
+
hastens the SIGKILL and never delays it. It clears `denied`, so a signal pp was refused is asked for again.
|
|
1049
|
+
|
|
1050
|
+
A kill's ask on the stop a worker wrote for a timeout becomes the kill's: `by` is the asker, `timeout`
|
|
1051
|
+
goes, and the timeout stays in `was`. So a job a kill has touched is ended as a kill, never retried,
|
|
1052
|
+
however its stop began. A timeout landing on a kill's stop changes nothing: a kill stays a kill.
|
|
1053
|
+
|
|
1054
|
+
Which of the two an ask is comes from its `timeout` field and never from who asked, so an asker called
|
|
1055
|
+
`timeout` asks as a kill."""
|
|
1056
|
+
if not existing:
|
|
1057
|
+
return dict(ask)
|
|
1058
|
+
out = dict(existing)
|
|
1059
|
+
out["grace"] = min(float(existing.get("grace") or 0), float(ask.get("grace") or 0))
|
|
1060
|
+
out.pop("denied", None)
|
|
1061
|
+
if existing.get("timeout") and not ask.get("timeout"):
|
|
1062
|
+
out["was"], out["by"] = existing["by"], ask["by"]
|
|
1063
|
+
out.pop("timeout")
|
|
1064
|
+
return out
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
# ---- worker ---------------------------------------------------------------------------------------
|
|
1068
|
+
|
|
1069
|
+
def _tail_start(f, n: int) -> int:
|
|
1070
|
+
"""Where the last n lines of open binary file f begin (its end when n is 0), without reading all of it."""
|
|
1071
|
+
end = pos = f.seek(0, 2)
|
|
1072
|
+
buf = b""
|
|
1073
|
+
while n > 0 and pos > 0 and buf.count(b"\n") <= n:
|
|
1074
|
+
pos = max(0, pos - 65536)
|
|
1075
|
+
f.seek(pos)
|
|
1076
|
+
buf = f.read(end - pos)
|
|
1077
|
+
return end - sum(len(line) for line in buf.splitlines(keepends=True)[-n:]) if n > 0 else end
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _tail(src, n: int) -> list[str]:
|
|
1081
|
+
"""Last n lines of a file (a path, or a file open for binary reading) without reading all of it."""
|
|
1082
|
+
if isinstance(src, str):
|
|
1083
|
+
with open(src, "rb") as f:
|
|
1084
|
+
return _tail(f, n)
|
|
1085
|
+
src.seek(_tail_start(src, n))
|
|
1086
|
+
return src.read().decode(errors="replace").splitlines()[-n:] if n > 0 else []
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def default_tag(cmd: list[str]) -> str:
|
|
1090
|
+
"""The tag of a job added without --tag."""
|
|
1091
|
+
return shlex.join(cmd)[:40]
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _text(v, cut: int | None = None) -> str:
|
|
1095
|
+
"""A job's own text as pp prints it: its tag, its command, its project, a note, a line of its log.
|
|
1096
|
+
|
|
1097
|
+
The one place a job's text is made printable: through ui.printable (CONTRIBUTING.md, The code), so an
|
|
1098
|
+
escape sequence a queued command put in it cannot redraw the terminal of whoever reads it, with tabs shown
|
|
1099
|
+
too, since one would break a table's columns. The cut comes after that, so no cut lands inside an
|
|
1100
|
+
escape."""
|
|
1101
|
+
out = ui.printable("" if v is None else str(v), tabs=False)
|
|
1102
|
+
return out if cut is None else out[:cut]
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def failure_tail(job: dict, n: int) -> list[str]:
|
|
1106
|
+
"""The last n lines of a job's log as a person reads them: colors and \\r-overwritten progress gone, every
|
|
1107
|
+
other control character shown instead of acted on, long lines cut, and without the launch banner ("job N
|
|
1108
|
+
(q) in CWD: CMD"), which only repeats what was queued."""
|
|
1109
|
+
lines = [_text(ANSI.sub("", ln.rsplit("\r", 1)[-1]).rstrip()) for ln in log_tail(job, n * 3)
|
|
1110
|
+
if not LAUNCH_BANNER.match(ln)]
|
|
1111
|
+
return [ln[:300] for ln in lines if ln][-n:]
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def log_tail(job: dict, n: int = 40) -> list[str]:
|
|
1115
|
+
try:
|
|
1116
|
+
return _tail(job["log"], n) if job.get("log") else []
|
|
1117
|
+
except OSError:
|
|
1118
|
+
return []
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _append_log(job: dict, line: str):
|
|
1122
|
+
try:
|
|
1123
|
+
os.makedirs(os.path.dirname(job["log"]), exist_ok=True)
|
|
1124
|
+
with open(job["log"], "a") as f:
|
|
1125
|
+
f.write(line.rstrip("\n") + "\n")
|
|
1126
|
+
except (OSError, KeyError):
|
|
1127
|
+
pass
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
class Wait:
|
|
1131
|
+
"""The wait a worker does between its ticks, and the one thing that shortens it. `waking()` hands one out.
|
|
1132
|
+
|
|
1133
|
+
`wait(seconds)` waits that long, ends the moment a signal arrives, and says whether one did. Nothing in a
|
|
1134
|
+
worker's loop sleeps: a stop, a Ctrl-C and a `worker stop` all reach it through the same wait."""
|
|
1135
|
+
|
|
1136
|
+
def __init__(self, fd: int):
|
|
1137
|
+
self.fd = fd
|
|
1138
|
+
|
|
1139
|
+
def wait(self, seconds: float) -> bool:
|
|
1140
|
+
"""-> whether a signal ended the wait, rather than the time being up."""
|
|
1141
|
+
if self._took():
|
|
1142
|
+
return True # one arrived while the tick ran: it ends this wait, and is not waited out
|
|
1143
|
+
left = max(float(seconds), 0.0)
|
|
1144
|
+
if not left or not select.select([self.fd], [], [], left)[0]:
|
|
1145
|
+
return False
|
|
1146
|
+
self._took()
|
|
1147
|
+
return True
|
|
1148
|
+
|
|
1149
|
+
def _took(self) -> bool:
|
|
1150
|
+
"""Take whatever the wakeup pipe holds. -> whether it held anything."""
|
|
1151
|
+
try:
|
|
1152
|
+
return bool(os.read(self.fd, 4096))
|
|
1153
|
+
except BlockingIOError:
|
|
1154
|
+
return False
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
@contextmanager
|
|
1158
|
+
def waking():
|
|
1159
|
+
"""The scope a worker's loop runs in: inside it, a signal ends the wait `Wait.wait` does, at once.
|
|
1160
|
+
|
|
1161
|
+
Python's own wakeup pipe is what ends it (signal.set_wakeup_fd): the C handler writes the number of every
|
|
1162
|
+
signal this process takes into that pipe, so a wake sent while a tick runs is a byte waiting in the pipe
|
|
1163
|
+
when the next wait begins, and no wake is lost. The Python handlers run as they always do, so the same
|
|
1164
|
+
SIGTERM that ends a wait sets the stop flag work() reads. The wake's own handler does nothing: it is there
|
|
1165
|
+
so that a wake cannot end a worker that is not waiting.
|
|
1166
|
+
|
|
1167
|
+
Only the main thread may hold the wakeup pipe, and one process has one: this is the scope of the worker in
|
|
1168
|
+
it, entered before it claims its queue, so a wake can never reach a worker that is not in this scope."""
|
|
1169
|
+
r, w = os.pipe()
|
|
1170
|
+
os.set_blocking(r, False)
|
|
1171
|
+
os.set_blocking(w, False)
|
|
1172
|
+
was_handler = signal.signal(WAKE, lambda *_: None)
|
|
1173
|
+
was_fd = signal.set_wakeup_fd(w, warn_on_full_buffer=False)
|
|
1174
|
+
try:
|
|
1175
|
+
yield Wait(r)
|
|
1176
|
+
finally:
|
|
1177
|
+
signal.set_wakeup_fd(was_fd)
|
|
1178
|
+
signal.signal(WAKE, was_handler)
|
|
1179
|
+
os.close(r)
|
|
1180
|
+
os.close(w)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
class Worker:
|
|
1184
|
+
"""One queue's worker. One tick = one pass over its running jobs (`carry_out`: the facts about each, the
|
|
1185
|
+
decision, the action), then launch what fits. work() loops it; tests drive it tick by tick."""
|
|
1186
|
+
|
|
1187
|
+
def __init__(self, queue: str, backfill: bool = False, log=None):
|
|
1188
|
+
me = proc.read(os.getpid())
|
|
1189
|
+
self.queue = queue
|
|
1190
|
+
self.children: dict[int, proc.Child] = {} # jobs this worker launched; others are adopted
|
|
1191
|
+
self.hooks: list[proc.Child] = [] # kept only to reap them
|
|
1192
|
+
self.backfill = backfill
|
|
1193
|
+
self.pstart = me.start if me else None
|
|
1194
|
+
self.started = time.time()
|
|
1195
|
+
self.waiting: dict | None = None # {id, tag, why, t}: what the head of the queue waits for
|
|
1196
|
+
self.said = None # (id, reason) last logged, without the detail after ';'
|
|
1197
|
+
self.serving = 0 # stops of this queue in flight at the last tick: while any is, the wait is short
|
|
1198
|
+
self.claim: lock.Claim | None = None # where it records itself; work() sets it once it has taken it
|
|
1199
|
+
self.log = log or (lambda msg: None)
|
|
1200
|
+
self.stopping = lambda: False # work() points it at its stop flag: a stopping worker launches nothing new
|
|
1201
|
+
|
|
1202
|
+
def record(self) -> dict:
|
|
1203
|
+
"""What this worker says about itself while it holds its claim, which is where `status` reads it."""
|
|
1204
|
+
return {"pid": os.getpid(), "pstart": self.pstart, "started": self.started, "backfill": self.backfill,
|
|
1205
|
+
"waiting": self.waiting}
|
|
1206
|
+
|
|
1207
|
+
def _publish(self):
|
|
1208
|
+
if self.claim is not None:
|
|
1209
|
+
self.claim.write(self.record())
|
|
1210
|
+
|
|
1211
|
+
def set_backfill(self, on: bool):
|
|
1212
|
+
self.backfill = on
|
|
1213
|
+
self._publish()
|
|
1214
|
+
|
|
1215
|
+
def tick(self):
|
|
1216
|
+
self.carry_out() # the one actor that signals this queue's jobs and records how one ended
|
|
1217
|
+
watchers() # and the leases of the waits a signal ended, swept where the queue is read
|
|
1218
|
+
self.hooks = [h for h in self.hooks if h.poll() is None]
|
|
1219
|
+
if not self.stopping():
|
|
1220
|
+
self.launch() # last: a broken config raises here, after ended jobs are recorded
|
|
1221
|
+
|
|
1222
|
+
# -- one pass over the running jobs: the facts, the decision, the action
|
|
1223
|
+
|
|
1224
|
+
def carry_out(self):
|
|
1225
|
+
"""One pass over this queue's running jobs: gather the facts about each, ask `decide` once, and do the
|
|
1226
|
+
one thing it says. The whole of a tick apart from launching, and the worker's own: the worker is the
|
|
1227
|
+
one actor that signals this queue's jobs.
|
|
1228
|
+
|
|
1229
|
+
A timeout, a kill and a reap are not three passes over the queue with three views of a job. They are
|
|
1230
|
+
facts about one job and one decision made from all of them, so no part of it can be wrong about what
|
|
1231
|
+
another part did."""
|
|
1232
|
+
now = time.time()
|
|
1233
|
+
with listing() as s: # one pass: the jobs to decide about, and the hooks the queue still owes
|
|
1234
|
+
jobs = list(s.jobs(("running",) + ENDED, self.queue))
|
|
1235
|
+
owed = [j for j in jobs if j["state"] in ENDED and j.get("hook_due")]
|
|
1236
|
+
in_flight = 0
|
|
1237
|
+
for j in [x for x in jobs if x["state"] == "running"]:
|
|
1238
|
+
stop = j.get("stop") or {}
|
|
1239
|
+
action = self._step(j, self._facts(j, now))
|
|
1240
|
+
if action.do in (TERM, KILL9, TIME_UP) or (action.do == NOTHING and stop
|
|
1241
|
+
and not stop.get("denied") and not stop.get("kill9")):
|
|
1242
|
+
in_flight += 1 # a stop with something still to go out for it
|
|
1243
|
+
# while a stop is in flight, and a verb is waking this worker for it, the loop's wait is STOP_PERIOD
|
|
1244
|
+
# and not the poll (work()). A stop whose SIGKILL has gone out has nothing left to carry out, so it is
|
|
1245
|
+
# not one: the worker waits for the kernel to end a job that is slow to die, at its poll or at the
|
|
1246
|
+
# next wake of whoever is waiting, and the reap sees the run claim go like any other
|
|
1247
|
+
self.serving = in_flight
|
|
1248
|
+
for j in owed:
|
|
1249
|
+
self.fire(j, j["hook_due"]) # owed by a worker that did not get to fire it
|
|
1250
|
+
# a child of this worker's whose record no longer says running: something outside pp ended the job
|
|
1251
|
+
# while it was starting, and `_spawn` stopped the process it had started. Reap the zombie
|
|
1252
|
+
for jid, p in list(self.children.items()):
|
|
1253
|
+
if p.poll() is not None and (find(jid) or {}).get("state") != "running":
|
|
1254
|
+
del self.children[jid]
|
|
1255
|
+
|
|
1256
|
+
def _facts(self, j: dict, now: float) -> Facts:
|
|
1257
|
+
"""What is true of this running job besides its record, all of it read here and nothing decided:
|
|
1258
|
+
whether a run of it is live, the exit code that run wrote and when, the signal its own child died of,
|
|
1259
|
+
and the log tail --retry-if matches against.
|
|
1260
|
+
|
|
1261
|
+
Read before the transaction, since none of it is store state and a log tail can be long."""
|
|
1262
|
+
held = lock.Claim.holder(_run_claim(self.queue, j["id"]))
|
|
1263
|
+
if held is not None:
|
|
1264
|
+
# a run of it is live, whatever its record holds: it is never lost, and never started again. Its
|
|
1265
|
+
# exit code is not read at all here: while the run is live it says nothing (Facts).
|
|
1266
|
+
self._adopt(j, held)
|
|
1267
|
+
return Facts(held=True, now=now)
|
|
1268
|
+
p = self.children.pop(j["id"], None)
|
|
1269
|
+
code = p.poll() if p is not None else None
|
|
1270
|
+
rc, ended = ended_on_its_own(j) or (None, None)
|
|
1271
|
+
tail = "\n".join(log_tail(j, 200)) if rc != 0 and j.get("retry_if") else ""
|
|
1272
|
+
return Facts(held=False, rc=rc, rc_at=ended, sig=-code if code is not None and code < 0 else None,
|
|
1273
|
+
now=now, tail=tail)
|
|
1274
|
+
|
|
1275
|
+
def _step(self, j: dict, facts: Facts) -> Action:
|
|
1276
|
+
"""The one decision about one job, and the action it names, carried out: what is store state in one
|
|
1277
|
+
transaction, the signal through the job's Handle, the log line and the hook after the scope.
|
|
1278
|
+
-> the action, which says whether a stop of this job is still in flight.
|
|
1279
|
+
|
|
1280
|
+
The decision is made inside the transaction, on the record the store holds and the facts read before
|
|
1281
|
+
it: a stop another actor wrote since those facts were read is part of it, so a job a kill has touched
|
|
1282
|
+
is never retried past its stop."""
|
|
1283
|
+
jid = j["id"]
|
|
1284
|
+
line = None
|
|
1285
|
+
with txn() as s:
|
|
1286
|
+
cur = s.job(jid, must_be=("running",))
|
|
1287
|
+
if cur is None:
|
|
1288
|
+
return Action(NOTHING) # nothing else records a running job: there is nothing to record
|
|
1289
|
+
a = decide(cur, facts)
|
|
1290
|
+
if a.sweep:
|
|
1291
|
+
self._sweep_group(cur) # its leader is gone; anything left in its group goes too
|
|
1292
|
+
if a.do == TIME_UP:
|
|
1293
|
+
cur["stop"] = {"by": "timeout", "timeout": True, "t": a.t, "grace": a.grace}
|
|
1294
|
+
s.save(cur)
|
|
1295
|
+
elif a.do == AGAIN:
|
|
1296
|
+
line = _requeue(s, cur, a)
|
|
1297
|
+
elif a.do == ENDED_AS:
|
|
1298
|
+
_finish(s, cur, a.state, a.rc, a.reason, a.ended, a.not_retried, hook=a.hook)
|
|
1299
|
+
if a.do in (TERM, KILL9):
|
|
1300
|
+
self._send(cur, a)
|
|
1301
|
+
return a
|
|
1302
|
+
if line:
|
|
1303
|
+
_append_log(cur, line) # after the scope: a job's log is not the store
|
|
1304
|
+
if a.do == TIME_UP:
|
|
1305
|
+
self.log(f"timeout {jid} {cur.get('tag')} after {ui.human_duration(float(cur['timeout']))}: "
|
|
1306
|
+
"stopping it")
|
|
1307
|
+
elif a.do == AGAIN:
|
|
1308
|
+
self.log(f"retry {jid} {cur.get('tag')}: attempt {cur['attempt']}/{int(cur.get('retries') or 0) + 1} "
|
|
1309
|
+
f"in {_in_delay(cur['not_before'])}")
|
|
1310
|
+
elif a.do == ENDED_AS:
|
|
1311
|
+
why = f" ({cur['reason']})" if cur.get("reason") else ""
|
|
1312
|
+
self.log(f"{'done' if cur['state'] == 'done' else 'FAILED'} {jid} {cur.get('tag')} "
|
|
1313
|
+
f"rc={cur.get('rc')}{why} {ui.human_duration(cur.get('seconds'))}")
|
|
1314
|
+
if a.hook:
|
|
1315
|
+
self.fire(cur, a.hook)
|
|
1316
|
+
return a
|
|
1317
|
+
|
|
1318
|
+
def _send(self, job: dict, a: Action) -> None:
|
|
1319
|
+
"""The signal one stop asks for next, and what it did on the record: a SIGTERM marked `sent`, a
|
|
1320
|
+
SIGKILL marked `kill9`, a refusal marked `denied`. The queue's worker is the one actor that signals a
|
|
1321
|
+
job's processes, so a kill, a timeout and a reap never cross.
|
|
1322
|
+
|
|
1323
|
+
The signal goes out first and is recorded after, so a record that says a signal went out is one that
|
|
1324
|
+
did, which is all the decision about this job has to go on."""
|
|
1325
|
+
stop = job.get("stop") or {}
|
|
1326
|
+
sig, mark, word = ((signal.SIGTERM, "sent", "SIGTERM to its group") if a.do == TERM else
|
|
1327
|
+
(signal.SIGKILL, "kill9",
|
|
1328
|
+
f"SIGKILL after {ui.human_duration(float(stop.get('grace') or 0))}"))
|
|
1329
|
+
outcome = self._signal_group(job, sig)
|
|
1330
|
+
if outcome is None or outcome == "gone":
|
|
1331
|
+
return # nothing to signal: its run is starting, or it is over and the next pass says how
|
|
1332
|
+
now = time.time()
|
|
1333
|
+
with txn() as s:
|
|
1334
|
+
cur = s.job(job["id"], must_be=("running",))
|
|
1335
|
+
if cur is None or not cur.get("stop"):
|
|
1336
|
+
return
|
|
1337
|
+
cur["stop"]["denied" if outcome == "denied" else mark] = now
|
|
1338
|
+
s.save(cur)
|
|
1339
|
+
self.log(f"stop {job['id']} {job.get('tag')} ({stop.get('by')}): "
|
|
1340
|
+
+ (f"not allowed to signal its group {cur.get('pgid')}" if outcome == "denied" else word))
|
|
1341
|
+
|
|
1342
|
+
def _adopt(self, j: dict, held: dict):
|
|
1343
|
+
"""Complete the record of a running job from its claim. The wrapper writes its own pid in there, so a
|
|
1344
|
+
job whose worker died between the start and the record is named again, and can be timed out, killed and
|
|
1345
|
+
reported like any other. The claim is held, so that pid is that wrapper's."""
|
|
1346
|
+
if j.get("pid") or not held.get("pid"):
|
|
1347
|
+
return
|
|
1348
|
+
p = proc.read(int(held["pid"]))
|
|
1349
|
+
if p is None:
|
|
1350
|
+
return # it ended as this was read; the next look reaps it
|
|
1351
|
+
with txn() as s:
|
|
1352
|
+
cur = s.job(j["id"], must_be=("running",))
|
|
1353
|
+
if cur is None or cur.get("pid"):
|
|
1354
|
+
return
|
|
1355
|
+
cur.update(pid=p.pid, pgid=p.pgid, pstart=p.start, boot=proc.boot_id())
|
|
1356
|
+
s.save(cur)
|
|
1357
|
+
self.log(f"adopted {j['id']} {j.get('tag')}: its run is pid {p.pid}, from its claim")
|
|
1358
|
+
|
|
1359
|
+
def fire(self, job: dict, event: str):
|
|
1360
|
+
"""Run the job's on_fail / on_done hook, detached; record it on the job, and that the job no longer
|
|
1361
|
+
owes it.
|
|
1362
|
+
|
|
1363
|
+
The outcome and the hook owed for it are written in one transaction (`_finish`); `hook_due` is cleared
|
|
1364
|
+
here, once the hook has run. A hook that could not start is reported and cleared too, so a queue is
|
|
1365
|
+
never left trying to start it again every tick."""
|
|
1366
|
+
spec = _hook_spec(job.get("on_fail" if event == "failed" else "on_done"))
|
|
1367
|
+
argv = hook_argv(spec)
|
|
1368
|
+
q, jid = job["queue"], job["id"]
|
|
1369
|
+
hlog = os.path.join(_qroot(q, "hooks"), f"{jid}-{event}.log")
|
|
1370
|
+
ran = False
|
|
1371
|
+
if argv:
|
|
1372
|
+
env = {"PP_QUEUE": q, "PP_QUEUE_JOB": str(jid), "PP_QUEUE_EVENT": event,
|
|
1373
|
+
"PP_QUEUE_RC": "" if job.get("rc") is None else str(job["rc"]), "PP_QUEUE_REASON": job.get("reason") or (f"exit {job['rc']}" if event == "failed" and job.get("rc") is not None else ""),
|
|
1374
|
+
"PP_QUEUE_LOG": job.get("log") or "", "PP_QUEUE_CWD": job.get("cwd") or "", "PP_QUEUE_TAG": job.get("tag") or ""}
|
|
1375
|
+
cwd = job.get("cwd") if job.get("cwd") and os.path.isdir(job["cwd"]) else "/"
|
|
1376
|
+
try:
|
|
1377
|
+
with open(hlog, "a") as f:
|
|
1378
|
+
f.write(f"[pp queue] {datetime.now():%Y-%m-%d %H:%M:%S} {event} hook of job {jid}: {shlex.join(argv)}\n")
|
|
1379
|
+
f.flush()
|
|
1380
|
+
h = proc.spawn(argv, group="own", stdin="none", env="scrubbed", add_env=env, cwd=cwd, stdout=f,
|
|
1381
|
+
stderr=proc.STDOUT)
|
|
1382
|
+
self.hooks.append(h)
|
|
1383
|
+
ran = True
|
|
1384
|
+
except OSError as e:
|
|
1385
|
+
self.log(f"hook {event} of {jid} could not start: {e}")
|
|
1386
|
+
with txn() as s: # it has run: the job owes it no longer, whether or not it could start
|
|
1387
|
+
cur = s.job(jid)
|
|
1388
|
+
if cur is not None:
|
|
1389
|
+
if ran:
|
|
1390
|
+
cur.setdefault("hooks", []).append({"event": event, "cmd": spec, "t": time.time(), "log": hlog})
|
|
1391
|
+
if cur.pop("hook_due", None) is not None or ran:
|
|
1392
|
+
s.save(cur)
|
|
1393
|
+
if ran:
|
|
1394
|
+
self.log(f"hook {event} of {jid}: {spec} (log {hlog})")
|
|
1395
|
+
|
|
1396
|
+
# -- stopping a job: a `kill`, and a timeout, which is a stop the worker writes on the job itself
|
|
1397
|
+
|
|
1398
|
+
@staticmethod
|
|
1399
|
+
def wake(h: proc.Handle | None) -> None:
|
|
1400
|
+
"""End the wait of the worker this handle names, so it ticks now instead of when its period is up. The
|
|
1401
|
+
handle comes from the look `worker_up` has already taken (`Holder.handle`), so waking a worker costs no
|
|
1402
|
+
second look at the claim.
|
|
1403
|
+
|
|
1404
|
+
The wake is a signal to a worker, never to a job: a worker inside `waking` takes it from its own
|
|
1405
|
+
wakeup pipe, so one sent while a tick runs ends the next wait rather than being lost. It is the
|
|
1406
|
+
worker's own, like the signalling above it, which is why it lives here and not in the verb that
|
|
1407
|
+
sends it. No verb holds the worker claim: a verb writes its stop, wakes the worker or starts one, and
|
|
1408
|
+
waits for the record."""
|
|
1409
|
+
if h is not None:
|
|
1410
|
+
proc.signal_pid(h, WAKE)
|
|
1411
|
+
|
|
1412
|
+
def _sweep_group(self, job: dict):
|
|
1413
|
+
"""The leader of a stopped job's group is gone; anything left in that group goes too, so a command that
|
|
1414
|
+
held the signal back does not outlive the job."""
|
|
1415
|
+
self._signal_group(job, signal.SIGKILL)
|
|
1416
|
+
|
|
1417
|
+
def _signal_group(self, job: dict, sig: int) -> str | None:
|
|
1418
|
+
"""Signal the job's process group, and say how that went (proc.signal_group's outcomes), or None while
|
|
1419
|
+
the job has no group to signal yet.
|
|
1420
|
+
|
|
1421
|
+
The record names the group with the start time read beside it, so a group id that belongs to another
|
|
1422
|
+
process by now is not signalled. Where the record names none, or names one without that start time, the
|
|
1423
|
+
job's claim names its wrapper, which leads that group and holds the claim, so that pid is still its own.
|
|
1424
|
+
One of the two names the group, unless the wrapper has not written its pid yet, which is the first
|
|
1425
|
+
moment of a launch."""
|
|
1426
|
+
h = _group_handle(job)
|
|
1427
|
+
if h is None:
|
|
1428
|
+
held = lock.Claim.holder(_run_claim(job["queue"], job["id"])) or {}
|
|
1429
|
+
h = proc.Handle.live(int(held["pid"]), group=True) if held.get("pid") else None
|
|
1430
|
+
if h is None:
|
|
1431
|
+
return None
|
|
1432
|
+
outcome = proc.signal_group(h, sig)
|
|
1433
|
+
if outcome == "gone":
|
|
1434
|
+
self.log(f"nothing to signal in job {job['id']}'s group {h.id}: it is gone")
|
|
1435
|
+
return outcome
|
|
1436
|
+
|
|
1437
|
+
# -- launching
|
|
1438
|
+
|
|
1439
|
+
def launch(self):
|
|
1440
|
+
queued = _jobs("queued", self.queue)
|
|
1441
|
+
now = time.time()
|
|
1442
|
+
ready = [j for j in queued if (j.get("not_before") or 0) <= now] # a retry's delay never blocks the line
|
|
1443
|
+
if not ready:
|
|
1444
|
+
self._note(None)
|
|
1445
|
+
return
|
|
1446
|
+
res = Resources(self.queue, {k for j in ready for k in (j.get("needs") or {})}, empty_ok=False)
|
|
1447
|
+
if self.stopping():
|
|
1448
|
+
return # told to stop while the readings were taken
|
|
1449
|
+
taken: list = [] # (record, card, claim) for each job this pass admitted
|
|
1450
|
+
blocked = None # the first job that did not fit, and why
|
|
1451
|
+
try:
|
|
1452
|
+
# one transaction for the whole pass: it reads the running jobs it admits against once, and reads
|
|
1453
|
+
# each job it is about to start again in that same scope, since another queue's worker may be
|
|
1454
|
+
# admitting from a shared pool and a verb may have changed the job since it was listed (prio, a note)
|
|
1455
|
+
with txn() as s:
|
|
1456
|
+
running = s.jobs("running")
|
|
1457
|
+
for j in ready:
|
|
1458
|
+
cur = s.job(j["id"], must_be=("queued",))
|
|
1459
|
+
if cur is None:
|
|
1460
|
+
continue # held, dropped or started meanwhile
|
|
1461
|
+
ok, why, card = res.admit(cur, running)
|
|
1462
|
+
if not ok:
|
|
1463
|
+
if blocked is None:
|
|
1464
|
+
blocked = (cur, why)
|
|
1465
|
+
if self.backfill:
|
|
1466
|
+
continue
|
|
1467
|
+
break
|
|
1468
|
+
# the run claim before the record says running, so a reader never sees a running job with
|
|
1469
|
+
# nothing holding its claim unless its run has ended. Taking one is two calls that never
|
|
1470
|
+
# wait, which is why it belongs in here; nothing under the store lock may wait either.
|
|
1471
|
+
claim = lock.Claim.take(_run_claim(self.queue, cur["id"]))
|
|
1472
|
+
if claim is None:
|
|
1473
|
+
continue # a run of this job is live already
|
|
1474
|
+
taken.append((cur, card, claim)) # before the rest of it, which can raise
|
|
1475
|
+
self._claim(s, cur, card)
|
|
1476
|
+
running.append(cur) # what this pass has taken counts against the next job in it
|
|
1477
|
+
for cur, card, claim in taken: # outside the transaction: starting a process is not store work
|
|
1478
|
+
self._spawn(cur, card, claim)
|
|
1479
|
+
finally:
|
|
1480
|
+
# pp's own copy of every claim this pass took, however it ended: a wrapper that was started holds
|
|
1481
|
+
# the claim through the file description it was given, so releasing this copy frees nothing of
|
|
1482
|
+
# its; one for a job no `_spawn` reached is freed here, and that job is reaped like any other
|
|
1483
|
+
# whose run is over. Releasing twice is not an error.
|
|
1484
|
+
for _cur, _card, claim in taken:
|
|
1485
|
+
claim.release()
|
|
1486
|
+
if blocked is None:
|
|
1487
|
+
self._note(None)
|
|
1488
|
+
else:
|
|
1489
|
+
self._note(*blocked)
|
|
1490
|
+
|
|
1491
|
+
@staticmethod
|
|
1492
|
+
def _claim(s: Txn, j: dict, gpu: int | None):
|
|
1493
|
+
"""In a transaction: the job, as read in it, becomes running, with the file this attempt writes its exit
|
|
1494
|
+
code to. Its run claim is held before this, so no reader ever sees it running with nothing holding its
|
|
1495
|
+
claim unless its run has ended."""
|
|
1496
|
+
j.update(started=time.time(), gpu=gpu, state="running", rcfile=_new_rcfile(j))
|
|
1497
|
+
j.pop("not_before", None)
|
|
1498
|
+
s.save(j)
|
|
1499
|
+
|
|
1500
|
+
def _spawn(self, j: dict, gpu: int | None, claim: lock.Claim) -> dict | None:
|
|
1501
|
+
"""Start the attempt's wrapper, and hand it the run claim as its stdin: it holds that claim for exactly
|
|
1502
|
+
as long as the attempt lives. pp closes its own copy here, so nothing but the wrapper holds it."""
|
|
1503
|
+
jid, q = j["id"], self.queue
|
|
1504
|
+
argv = list(j.get("runner") or []) + list(j["cmd"])
|
|
1505
|
+
env = {**(j.get("shell_env") or {}), **(j.get("env") or {}),
|
|
1506
|
+
"PP_QUEUE": q, "PP_QUEUE_JOB": str(jid), "PP_QUEUE_RCFILE": j["rcfile"]}
|
|
1507
|
+
if gpu is not None:
|
|
1508
|
+
env["CUDA_VISIBLE_DEVICES"] = str(gpu)
|
|
1509
|
+
retries = int(j.get("retries") or 0)
|
|
1510
|
+
attempt = f" attempt {j.get('attempt', 1)}/{retries + 1}" if retries else ""
|
|
1511
|
+
try:
|
|
1512
|
+
os.makedirs(os.path.dirname(j["log"]), exist_ok=True)
|
|
1513
|
+
with open(j["log"], "a") as f:
|
|
1514
|
+
f.write(f"[pp queue] {datetime.now():%Y-%m-%d %H:%M:%S} job {jid} ({q}){attempt} in {j['cwd']}: {shlex.join(argv)}\n")
|
|
1515
|
+
f.flush()
|
|
1516
|
+
p = proc.spawn(["/bin/sh", "-c", WRAP, "pp-queue-job", *argv], group="own", stdin=claim.fd,
|
|
1517
|
+
env="scrubbed", add_env=env, cwd=j["cwd"], stdout=f, stderr=proc.STDOUT)
|
|
1518
|
+
except OSError as e:
|
|
1519
|
+
with txn() as s:
|
|
1520
|
+
cur = s.job(jid, must_be=("running",))
|
|
1521
|
+
if cur:
|
|
1522
|
+
_finish(s, cur, "failed", None, f"launch failed: {e}", hook=_hook_due(cur, "failed"))
|
|
1523
|
+
claim.release() # after the record says it ended, so no reader sees it running with a free claim
|
|
1524
|
+
self.log(f"FAILED {jid} {j.get('tag')}: launch failed: {e}")
|
|
1525
|
+
if cur:
|
|
1526
|
+
self.fire(cur, "failed")
|
|
1527
|
+
return None
|
|
1528
|
+
claim.release() # pp's own copy of it; the wrapper's keeps the claim
|
|
1529
|
+
with txn() as s: # amend the record as it is now, not the copy launch read: a note added meanwhile stays
|
|
1530
|
+
cur = s.job(jid, must_be=("running",))
|
|
1531
|
+
if cur:
|
|
1532
|
+
cur.update(pid=p.pid, pgid=p.pid, pstart=p.handle.start if p.handle else None,
|
|
1533
|
+
boot=proc.boot_id())
|
|
1534
|
+
s.save(cur)
|
|
1535
|
+
if cur is None:
|
|
1536
|
+
# its record no longer says running, which only something outside pp can do to a job this worker is
|
|
1537
|
+
# starting; its process must not run on under a record that has ended
|
|
1538
|
+
p.end(signal.SIGTERM)
|
|
1539
|
+
self.log(f"job {jid} ended elsewhere while it started; stopped its process {p.pid}")
|
|
1540
|
+
self.children[jid] = p
|
|
1541
|
+
self.log(f"start {jid} {j.get('tag')} [{j.get('project')}] {_needs_str(j.get('needs'))}"
|
|
1542
|
+
+ (f" on GPU{gpu}" if gpu is not None else "") + attempt)
|
|
1543
|
+
return cur
|
|
1544
|
+
|
|
1545
|
+
def _note(self, j: dict | None, why: str = ""):
|
|
1546
|
+
"""Record what the head of the queue waits for, in this worker's own claim where `status` reads it,
|
|
1547
|
+
whenever it changes; log it only when its reason changes, not its detail after ';'."""
|
|
1548
|
+
now = None if j is None else {"id": j["id"], "tag": j.get("tag"), "why": why, "t": time.time()}
|
|
1549
|
+
if (self.waiting or {}).get("id") == (now or {}).get("id") \
|
|
1550
|
+
and (self.waiting or {}).get("why") == (now or {}).get("why"):
|
|
1551
|
+
return # the same job, waiting for the same thing
|
|
1552
|
+
self.waiting = now
|
|
1553
|
+
self._publish()
|
|
1554
|
+
key = None if j is None else (j["id"], why.split(";")[0])
|
|
1555
|
+
if key is not None and key != self.said:
|
|
1556
|
+
self.log(f"waiting: {j['id']} {j.get('tag')} for {why}")
|
|
1557
|
+
self.said = key
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def _worker_claim(q: str) -> str:
|
|
1561
|
+
return os.path.join(_qroot(q, create=False), "worker.lock")
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _worker_log(q: str, create: bool = False) -> str:
|
|
1565
|
+
"""Where queue q's worker writes what it does. Naming it creates nothing: a verb that only reports
|
|
1566
|
+
(`worker status`) leaves no directory behind for a queue name that was a typo. create: the worker
|
|
1567
|
+
about to open it for writing makes the queue's directories."""
|
|
1568
|
+
return os.path.join(_qroot(q, create=create), "worker.log")
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def worker(q: str) -> dict | None:
|
|
1572
|
+
"""Queue q's live worker as it records itself: {pid, pstart, started, backfill, waiting}. None when the
|
|
1573
|
+
queue has no live worker.
|
|
1574
|
+
|
|
1575
|
+
Only a worker ever holds the claim on worker.lock, and it holds it for as long as it runs: that is what
|
|
1576
|
+
being up means. A worker that was killed leaves it free at once, and one that is alive holds it however
|
|
1577
|
+
long a tick takes. The record is what it is doing, written under the claim. A record that names no live
|
|
1578
|
+
process is a worker in its first moment, which has not recorded itself yet (`_said`). Asking never waits
|
|
1579
|
+
for it.
|
|
1580
|
+
|
|
1581
|
+
`worker_now` is the one place that asks this, and every verb asks `worker_now` or `worker_up`."""
|
|
1582
|
+
return lock.Claim.holder(_worker_claim(q))
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def _start_worker(q: str) -> proc.Child:
|
|
1586
|
+
"""A detached worker for q, with no session identity in its environment (the caller may be an agent), once
|
|
1587
|
+
it is ready: it says so when it holds its claim, has recorded itself and handles a stop."""
|
|
1588
|
+
with open(_worker_log(q, create=True), "a") as log:
|
|
1589
|
+
return proc.spawn(paths.self_cmd() + [_PROG, "work", "-q", q], group="own", stdin="none", env="scrubbed",
|
|
1590
|
+
cwd="/", stdout=log, stderr=proc.STDOUT, ready=True)
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
# ---- whether a queue's worker is up: one construct -------------------------------------------------
|
|
1594
|
+
#
|
|
1595
|
+
# Only a worker ever holds a queue's worker claim, and `work()` is the one place that takes it. So two things
|
|
1596
|
+
# can be true of that claim, and one of them at a time: a worker holds it, or nobody does.
|
|
1597
|
+
#
|
|
1598
|
+
# a worker holds the claim it is up, and its record says what it is doing
|
|
1599
|
+
# nobody holds the claim the queue has no worker, and a verb that needs one starts it here
|
|
1600
|
+
#
|
|
1601
|
+
# A worker in its first moment is neither: it has the claim and the record in it names no process, since a take
|
|
1602
|
+
# writes that record in the call after the lock. Such a look is no answer, so the construct looks again, a
|
|
1603
|
+
# bounded number of times, and says a worker is starting where the record still names nobody.
|
|
1604
|
+
#
|
|
1605
|
+
# A verb asks `worker_now`, which reports, or `worker_up`, which makes sure a worker is up, and prints from the
|
|
1606
|
+
# one answer it gets back. So no verb acts on a look it took outside the scope that acted.
|
|
1607
|
+
|
|
1608
|
+
UP = "up" # a worker holds the claim and has recorded itself
|
|
1609
|
+
STARTED = "started" # the claim was free, and the worker this start brought up holds it
|
|
1610
|
+
DOWN = "down" # nobody holds the claim
|
|
1611
|
+
STARTING = "starting" # a worker holds the claim and has not recorded itself yet
|
|
1612
|
+
|
|
1613
|
+
WORKER_STARTS = 3 # workers `worker_up` starts at most, each one asked about after it
|
|
1614
|
+
STARTING_LOOKS = 25 # looks it takes at a claim whose holder has not recorded itself ...
|
|
1615
|
+
STARTING_PAUSE = 0.02 # ... and how long apart, which is many times the one write that ends that moment
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
@dataclass(frozen=True)
|
|
1619
|
+
class Holder:
|
|
1620
|
+
"""What one look at a queue's worker claim came to, and the one thing every verb prints from.
|
|
1621
|
+
|
|
1622
|
+
how up, started, down or starting, as above.
|
|
1623
|
+
rec the record of the worker that holds the claim. None where nobody holds it.
|
|
1624
|
+
why why a round of starts brought no worker up, where this answer came from such a round. None
|
|
1625
|
+
otherwise. A start that times out is the one reason the worker's own log cannot carry, since a
|
|
1626
|
+
worker that hung before it said it was ready has logged nothing, so a caller that asked quietly
|
|
1627
|
+
says this itself.
|
|
1628
|
+
"""
|
|
1629
|
+
|
|
1630
|
+
how: str
|
|
1631
|
+
rec: dict | None = None
|
|
1632
|
+
why: str | None = None
|
|
1633
|
+
|
|
1634
|
+
@property
|
|
1635
|
+
def up(self) -> bool:
|
|
1636
|
+
"""Whether a worker is up: the one answer, so nothing has to read a record to find out."""
|
|
1637
|
+
return self.how in (UP, STARTED)
|
|
1638
|
+
|
|
1639
|
+
@property
|
|
1640
|
+
def pid(self) -> int | None:
|
|
1641
|
+
"""The pid of the queue's worker. None where nobody holds the claim, and while a worker that holds it
|
|
1642
|
+
has not recorded itself yet."""
|
|
1643
|
+
return (self.rec or {}).get("pid") if self.up else None
|
|
1644
|
+
|
|
1645
|
+
@property
|
|
1646
|
+
def handle(self) -> proc.Handle | None:
|
|
1647
|
+
"""The worker as the handle a signal to it goes through: a wake, or the SIGTERM of `worker stop`. None
|
|
1648
|
+
where this look found no worker. A worker that is up has recorded itself, so both halves are there."""
|
|
1649
|
+
return proc.Handle.recorded(self.pid, (self.rec or {}).get("pstart")) if self.up else None
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def _starting(q: str) -> str:
|
|
1653
|
+
"""What a person is told while a worker holds q's claim and has not recorded itself yet."""
|
|
1654
|
+
return f"the {q} worker is starting and has not recorded itself yet"
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
def _said(rec: dict | None) -> bool:
|
|
1658
|
+
"""Whether the record in a worker claim is the record of the worker that holds it now.
|
|
1659
|
+
|
|
1660
|
+
A take locks the claim file, empties it and writes the record in that order (`lock.Claim.take`), so for
|
|
1661
|
+
that moment a reader finds the file empty, or holding the record a worker that was killed left. A release
|
|
1662
|
+
empties the file, and a worker's record names itself, so those bytes name a process that is gone. Either
|
|
1663
|
+
way a record that names no process of its own, or one that names a process that is not running, is a
|
|
1664
|
+
worker in its first moment."""
|
|
1665
|
+
return proc.Handle.running((rec or {}).get("pid"), (rec or {}).get("pstart")) is not None
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def worker_now(q: str) -> Holder:
|
|
1669
|
+
"""What q's worker is: the one look at the claim's holder. It writes nothing and starts nothing.
|
|
1670
|
+
|
|
1671
|
+
A worker that holds the claim and has not recorded itself yet is no state to report, so this looks again,
|
|
1672
|
+
a bounded number of times. A holder that never records itself comes back as a worker starting, for the
|
|
1673
|
+
verb to say so rather than name it as up."""
|
|
1674
|
+
rec: dict | None = None
|
|
1675
|
+
for _ in range(STARTING_LOOKS):
|
|
1676
|
+
rec = worker(q)
|
|
1677
|
+
if rec is None:
|
|
1678
|
+
return Holder(DOWN)
|
|
1679
|
+
if _said(rec):
|
|
1680
|
+
return Holder(UP, rec=rec)
|
|
1681
|
+
time.sleep(STARTING_PAUSE) # its first moment: look again rather than decide from it
|
|
1682
|
+
return Holder(STARTING, rec=rec)
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
# When a round of starts of this process last brought no worker up, per queue. A loop that asks on every pass
|
|
1686
|
+
# waits START_EVERY out before it starts another round, so a worker that cannot come up is not started over
|
|
1687
|
+
# and over. The entry goes as soon as a worker is found up, so a queue that recovers is not held off.
|
|
1688
|
+
_none_came_up: dict[str, float] = {}
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def _no_worker_came_up(q: str, quiet: bool, said: bool, w: Holder, why: str | None = None) -> Holder:
|
|
1692
|
+
"""This process's round of starts brought no worker up: hold off from starting another for START_EVERY,
|
|
1693
|
+
and say so unless the caller asked quietly. -> that answer, carrying why on it for a quiet caller to say.
|
|
1694
|
+
|
|
1695
|
+
The clock is read here, after the starts, and not before them: a round can take longer than START_EVERY
|
|
1696
|
+
(a start waits for the worker to say it is ready), and a reading from before it would already be past."""
|
|
1697
|
+
_none_came_up[q] = time.time()
|
|
1698
|
+
if not quiet and not said:
|
|
1699
|
+
ui.warn(f"{_PROG}: the {q} worker did not come up; its log: {_worker_log(q)}")
|
|
1700
|
+
return Holder(w.how, rec=w.rec, why=why)
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def worker_up(q: str, quiet: bool = False, wake: bool = False) -> Holder:
|
|
1704
|
+
"""Make sure q has a worker, and say what it is: what `add`, `kill`, `release`, `retry`, a wait loop and
|
|
1705
|
+
`worker start` all ask, and the one answer they print from.
|
|
1706
|
+
|
|
1707
|
+
A worker that is up comes back as it is, woken where wake asks, so a job this verb made ready launches now
|
|
1708
|
+
and not at the worker's next tick. Where nobody holds the claim this starts a worker and waits for it: the
|
|
1709
|
+
new worker says it is ready once it holds its claim, has recorded itself and has its stop handler in
|
|
1710
|
+
place, so a `worker stop` or a status right after this sees it. A start whose worker found the claim taken
|
|
1711
|
+
meanwhile brings up no worker, and then this asks again rather than guessing, WORKER_STARTS starts at
|
|
1712
|
+
most. Every answer is a decision taken after the last start this made, so no verb reports a state from
|
|
1713
|
+
before the act it reports on.
|
|
1714
|
+
|
|
1715
|
+
A worker in its first moment holds the claim, so no start can take it: this says a worker is starting and
|
|
1716
|
+
leaves it at that. Where a round of starts brought no worker up, the answer is a queue with no worker, and
|
|
1717
|
+
this process starts another round only once START_EVERY has passed, so a loop that asks on every pass does
|
|
1718
|
+
not start workers it cannot bring up ten times a second. A caller that is not quiet is told when its own
|
|
1719
|
+
round brought no worker up. An ask that lands inside the hold-off of an earlier round starts nothing and
|
|
1720
|
+
says nothing. Every answer from a round that brought none up carries the reason in `why`, so a caller
|
|
1721
|
+
that asked quietly, `worker start` and a watch loop among them, says it itself."""
|
|
1722
|
+
starts: list[int | None] = [] # the workers this started, which the look after each asks about
|
|
1723
|
+
while True:
|
|
1724
|
+
w = worker_now(q)
|
|
1725
|
+
if w.up:
|
|
1726
|
+
_none_came_up.pop(q, None) # it is up: nothing to hold the next start off for
|
|
1727
|
+
if w.pid is not None and w.pid in starts:
|
|
1728
|
+
if not quiet:
|
|
1729
|
+
print(f"pp {_PROG}: started the {q} worker (pid {w.pid}; log {_worker_log(q)})",
|
|
1730
|
+
file=sys.stderr)
|
|
1731
|
+
return Holder(STARTED, rec=w.rec)
|
|
1732
|
+
if wake:
|
|
1733
|
+
Worker.wake(w.handle)
|
|
1734
|
+
return w
|
|
1735
|
+
if w.how == STARTING:
|
|
1736
|
+
if not quiet: # the same thing every verb says about this state, from the answer it got
|
|
1737
|
+
print(f"pp {_PROG}: {_starting(q)}", file=sys.stderr)
|
|
1738
|
+
return w
|
|
1739
|
+
if not starts and time.time() < _none_came_up.get(q, 0.0) + START_EVERY:
|
|
1740
|
+
return w # a round of this process's brought none up that recently: not another yet
|
|
1741
|
+
if len(starts) == WORKER_STARTS:
|
|
1742
|
+
return _no_worker_came_up(q, quiet, False, w) # the claim's next holder shut this many out
|
|
1743
|
+
try:
|
|
1744
|
+
starts.append(_start_worker(q).pid) # starting a process is not store work: no lock is held
|
|
1745
|
+
except TimeoutError as e:
|
|
1746
|
+
if not quiet:
|
|
1747
|
+
ui.warn(f"{_PROG}: {e}; its log: {_worker_log(q)}")
|
|
1748
|
+
return _no_worker_came_up(q, quiet, True, w, why=str(e))
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def work(q: str, backfill: bool | None = None, poll: float | None = None) -> int:
|
|
1752
|
+
writing_queue(q) # a queue this pp cannot read is refused here too; the worker's own queue says its format
|
|
1753
|
+
from_cfg = backfill is None # `work --backfill` fixes it; otherwise it follows the config, like the rest
|
|
1754
|
+
if from_cfg:
|
|
1755
|
+
try:
|
|
1756
|
+
backfill = bool(_qcfg(q).get("backfill", False))
|
|
1757
|
+
except config.ConfigError:
|
|
1758
|
+
backfill = False
|
|
1759
|
+
|
|
1760
|
+
def log(msg):
|
|
1761
|
+
print(f"[{q} {datetime.now():%m-%d %H:%M:%S}] {msg}", flush=True)
|
|
1762
|
+
|
|
1763
|
+
w = Worker(q, bool(backfill), log)
|
|
1764
|
+
# the wake is held for the whole of this worker's life, before it claims the queue: a wake can only ever
|
|
1765
|
+
# reach a worker that takes it from its own wakeup pipe
|
|
1766
|
+
with waking() as waits:
|
|
1767
|
+
claim = lock.Claim.take(_worker_claim(q), w.record())
|
|
1768
|
+
if claim is None: # one worker per queue, and holding the claim is being that worker
|
|
1769
|
+
held = worker_now(q)
|
|
1770
|
+
if held.up:
|
|
1771
|
+
print(f"pp {_PROG}: the {q} worker is already running (pid {held.pid})")
|
|
1772
|
+
elif held.how == STARTING:
|
|
1773
|
+
print(f"pp {_PROG}: {_starting(q)}, so this worker is not the one")
|
|
1774
|
+
else:
|
|
1775
|
+
print(f"pp {_PROG}: another worker held the {q} worker's claim and has let it go")
|
|
1776
|
+
return 0
|
|
1777
|
+
w.claim = claim
|
|
1778
|
+
stop: list = []
|
|
1779
|
+
signal.signal(signal.SIGTERM, lambda *_: stop.append(1)) # a flag, never an exception mid-move
|
|
1780
|
+
proc.say_ready() # it holds its claim, it is recorded, a stop is handled and a wake is taken
|
|
1781
|
+
w.stopping = lambda: bool(stop)
|
|
1782
|
+
adopted = _jobs("running", q)
|
|
1783
|
+
log(f"worker up, pid {os.getpid()}" + (", backfill" if backfill else "")
|
|
1784
|
+
+ (f", adopting {len(adopted)} running job(s)" if adopted else ""))
|
|
1785
|
+
idle_since = None
|
|
1786
|
+
bad_cfg = None
|
|
1787
|
+
woken = False
|
|
1788
|
+
try:
|
|
1789
|
+
while not stop:
|
|
1790
|
+
period, idle_exit, bad = poll or 2.0, 600.0, None
|
|
1791
|
+
try:
|
|
1792
|
+
cfg = _qcfg(q)
|
|
1793
|
+
period = float(poll or cfg.get("poll", 2))
|
|
1794
|
+
idle_exit = float(cfg.get("idle_exit", 600))
|
|
1795
|
+
if from_cfg and bool(cfg.get("backfill", False)) != w.backfill:
|
|
1796
|
+
w.set_backfill(bool(cfg.get("backfill", False)))
|
|
1797
|
+
log(f"backfill {'on' if w.backfill else 'off'} (config)")
|
|
1798
|
+
except config.ConfigError as e:
|
|
1799
|
+
bad = str(e)
|
|
1800
|
+
try:
|
|
1801
|
+
w.tick() # reaping and timeouts go on under a broken config; only launching needs it
|
|
1802
|
+
except config.ConfigError as e:
|
|
1803
|
+
bad = str(e)
|
|
1804
|
+
except Exception:
|
|
1805
|
+
log("tick failed:\n" + traceback.format_exc())
|
|
1806
|
+
if bad != bad_cfg:
|
|
1807
|
+
log(f"bad config, nothing launches until it is fixed: {bad}" if bad else "config fixed")
|
|
1808
|
+
bad_cfg = bad
|
|
1809
|
+
with listing() as s:
|
|
1810
|
+
if s.jobs(("queued", "running"), q):
|
|
1811
|
+
idle_since, spent = None, False
|
|
1812
|
+
else:
|
|
1813
|
+
idle_since = idle_since or time.time()
|
|
1814
|
+
spent = time.time() - idle_since >= idle_exit
|
|
1815
|
+
if spent:
|
|
1816
|
+
# let the claim go inside the scope that read the store, which is the one order
|
|
1817
|
+
# that leaves no job behind: a job stored after this finds the claim free and
|
|
1818
|
+
# starts the next worker, and one stored before it was read here, so this worker
|
|
1819
|
+
# stays
|
|
1820
|
+
claim.release()
|
|
1821
|
+
if spent:
|
|
1822
|
+
log(f"idle for {idle_exit:g}s, exiting")
|
|
1823
|
+
break
|
|
1824
|
+
# one wait, ended by the period or by a signal: a stop in flight is carried out in the next
|
|
1825
|
+
# tick, and a wake or a SIGTERM is acted on as it arrives. The short period goes with the
|
|
1826
|
+
# wake: a verb waiting for a stop wakes this worker on every pass of its own wait, so the
|
|
1827
|
+
# tick after that wake is this worker's next one. A stop nobody waits for, a timeout's, is
|
|
1828
|
+
# carried out at the queue's poll, and the store lock is not taken ten times a second for a
|
|
1829
|
+
# job the kernel is slow to end
|
|
1830
|
+
woken = waits.wait(min(period, STOP_PERIOD) if w.serving and woken else period)
|
|
1831
|
+
except KeyboardInterrupt:
|
|
1832
|
+
stop.append(1)
|
|
1833
|
+
finally:
|
|
1834
|
+
if stop:
|
|
1835
|
+
log("stopped; running jobs keep running and the next worker adopts them")
|
|
1836
|
+
claim.release()
|
|
1837
|
+
return 0
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
# ---- watchers: the pull side ----------------------------------------------------------------------
|
|
1841
|
+
#
|
|
1842
|
+
# `wait` and `watch` hold a lease (watchers/<pid>-<pstart>.claim) while they run and stamp each outcome they print on the job
|
|
1843
|
+
# (`reported`). An agent that runs one in the background hears the outcome from it.
|
|
1844
|
+
#
|
|
1845
|
+
# The stamp: job["reported"] = [{t, by: "wait pid N", session}], written only by _stamp_reported (wait/watch; never
|
|
1846
|
+
# `log -f` or a hook). session is the caller's agent session, None for a person. Readers:
|
|
1847
|
+
# wait --mine collects the session's ended jobs that carry no stamp of that session, so a job that fails
|
|
1848
|
+
# between `add` and `wait --mine` is still reported and the wait does not exit 0. Cost: the first
|
|
1849
|
+
# --mine of a session that never waited prints every outcome it never heard, old ones included; a
|
|
1850
|
+
# per-session cursor would avoid that with more state.
|
|
1851
|
+
# show "reported to" rows
|
|
1852
|
+
|
|
1853
|
+
def _watcher_claim(pid: int, pstart: int) -> str:
|
|
1854
|
+
"""The file one `wait` or `watch` holds its lease on. The name is that process's own, so nobody else ever
|
|
1855
|
+
takes it and the file goes with the lease."""
|
|
1856
|
+
return os.path.join(_top("watchers"), f"{pid}-{pstart}.claim")
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
@contextmanager
|
|
1860
|
+
def watching(ids: list[int] | None, scope: list[str] | None, verb: str):
|
|
1861
|
+
"""Hold a lease on these jobs (ids None: every job of scope; scope None: every queue) while the block runs."""
|
|
1862
|
+
me, who = proc.read(os.getpid()), session.caller()
|
|
1863
|
+
# the lease is a claim this process holds while it waits, under a name that is this process's own: the
|
|
1864
|
+
# kernel lets it go when the process ends, however it ends, so no reader has to judge whether it is alive.
|
|
1865
|
+
# It is locked before that name is visible (lock.Claim.make), so the sweep below can never take it from a
|
|
1866
|
+
# wait that is taking it: a lease that is there is held, and one that is not held has lost its holder.
|
|
1867
|
+
claim = lock.Claim.make(_watcher_claim(os.getpid(), me.start if me else 0),
|
|
1868
|
+
{"pid": os.getpid(), "ids": ids, "scope": scope, "verb": verb,
|
|
1869
|
+
"session": who["session"] if who else None, "t": time.time()})
|
|
1870
|
+
try:
|
|
1871
|
+
yield
|
|
1872
|
+
finally:
|
|
1873
|
+
claim.release()
|
|
1874
|
+
lock.Claim.forget(claim.path) # nobody else ever takes this name, so this frees nothing of theirs
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
def watchers(job: dict | None = None) -> list[dict]:
|
|
1878
|
+
"""The live `wait` / `watch` processes holding a lease on this job, and none when job is None, which is
|
|
1879
|
+
the sweep on its own.
|
|
1880
|
+
|
|
1881
|
+
A lease nobody holds is removed wherever this is asked. A wait ended by a signal runs no finally of its
|
|
1882
|
+
own: the kernel let its claim go as the process died, and the file it was holding goes here. `show` asks
|
|
1883
|
+
about one job, and a worker's tick asks about none, so the leases of an agent's interrupted waits are
|
|
1884
|
+
swept where the queue is being read anyway.
|
|
1885
|
+
|
|
1886
|
+
A lease is locked before its name is visible (`watching`), so a lease that is there and unheld is one
|
|
1887
|
+
whose holder has gone, and this takes nothing from a wait that is taking its own."""
|
|
1888
|
+
d = _top("watchers")
|
|
1889
|
+
out = []
|
|
1890
|
+
for fn in sorted(os.listdir(d)):
|
|
1891
|
+
if not fn.endswith(".claim"):
|
|
1892
|
+
continue
|
|
1893
|
+
path = os.path.join(d, fn)
|
|
1894
|
+
rec = lock.Claim.holder(path)
|
|
1895
|
+
if rec is None:
|
|
1896
|
+
lock.Claim.forget(path) # the process whose own name this is has gone, so the file goes too
|
|
1897
|
+
continue
|
|
1898
|
+
if job is None or not rec.get("pid"):
|
|
1899
|
+
continue # a sweep, which asks about none; or a record that names nobody
|
|
1900
|
+
if (rec.get("ids") is None or job["id"] in rec["ids"]) and (rec.get("scope") is None or job["queue"] in rec["scope"]):
|
|
1901
|
+
out.append(rec)
|
|
1902
|
+
return out
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
def _stamp_reported(jid: int, verb: str):
|
|
1906
|
+
who = session.caller()
|
|
1907
|
+
with txn() as s:
|
|
1908
|
+
j = s.job(jid, must_be=ENDED)
|
|
1909
|
+
if j:
|
|
1910
|
+
j.setdefault("reported", []).append({"t": time.time(), "by": f"{verb} pid {os.getpid()}",
|
|
1911
|
+
"session": who["session"] if who else None})
|
|
1912
|
+
s.save(j)
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
# ---- helpers for verbs ----------------------------------------------------------------------------
|
|
1916
|
+
|
|
1917
|
+
def _resolve(specs: list[str], scope: list[str] | None = None) -> list[dict]:
|
|
1918
|
+
"""Jobs named by ids, ranges (12-15) or exact tags, in the order given, without duplicates. Ids are global.
|
|
1919
|
+
Each carries _bulk: it came from a range or a tag naming several jobs, so a verb skips it when it is in a state
|
|
1920
|
+
the verb does not apply to, where a job named on its own is an error."""
|
|
1921
|
+
by_id = {j["id"]: j for j in _jobs(queue=scope)}
|
|
1922
|
+
out, seen = [], set()
|
|
1923
|
+
for s in specs:
|
|
1924
|
+
m = re.fullmatch(r"(\d+)-(\d+)", s)
|
|
1925
|
+
if m:
|
|
1926
|
+
hits = [by_id[i] for i in range(int(m[1]), int(m[2]) + 1) if i in by_id]
|
|
1927
|
+
elif s.isdigit():
|
|
1928
|
+
hits = [by_id[int(s)]] if int(s) in by_id else []
|
|
1929
|
+
else:
|
|
1930
|
+
hits = sorted((j for j in by_id.values() if j.get("tag") == s), key=lambda j: j["id"])
|
|
1931
|
+
qs = sorted({j["queue"] for j in hits})
|
|
1932
|
+
if len(qs) > 1:
|
|
1933
|
+
ui.die(f"{_PROG}: tag '{s}' names jobs in queues {', '.join(qs)}: "
|
|
1934
|
+
+ ", ".join(f"{j['id']} ({j['queue']})" for j in hits) + "; use ids, or -q", 2)
|
|
1935
|
+
if not hits:
|
|
1936
|
+
ui.die(f"{_PROG}: no job matches '{s}'", 2)
|
|
1937
|
+
for j in hits:
|
|
1938
|
+
if j["id"] not in seen:
|
|
1939
|
+
seen.add(j["id"])
|
|
1940
|
+
out.append({**j, "_bulk": bool(m) or len(hits) > 1})
|
|
1941
|
+
return out
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
def _one(spec: str, verb: str, scope: list[str] | None = None) -> dict:
|
|
1945
|
+
"""The job show, note and log act on. A tag naming several jobs (a retry keeps the tag): the newest, said so;
|
|
1946
|
+
note refuses, since a note on the wrong job misleads."""
|
|
1947
|
+
hits = _resolve([spec], scope)
|
|
1948
|
+
if len(hits) > 1:
|
|
1949
|
+
ids = ", ".join(str(j["id"]) for j in hits)
|
|
1950
|
+
if verb == "note":
|
|
1951
|
+
ui.die(f"{_PROG} note: '{spec}' names jobs {ids}; note one by its id", 2)
|
|
1952
|
+
ui.warn(f"{_PROG} {verb}: '{spec}' names jobs {ids}; showing the newest, {hits[-1]['id']}")
|
|
1953
|
+
return hits[-1]
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def _left(skipped: list[dict]) -> None:
|
|
1957
|
+
"""Jobs a range or tag swept over that the verb does not apply to: listed once, not errors."""
|
|
1958
|
+
if skipped:
|
|
1959
|
+
print(ui.c("left as they are: " + ", ".join(f"{j['id']} {j['state']}" for j in skipped), "dim"))
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
def _gate(a, jobs: list[dict], verb: str, ok: tuple) -> int | None:
|
|
1963
|
+
"""kill and drop: a job named by its id is acted on at once; a range or a tag naming several jobs is shown first
|
|
1964
|
+
and asked about (-y: do not ask). -n only shows. None to go ahead, else the exit code."""
|
|
1965
|
+
if not a.dry_run and not any(j["_bulk"] for j in jobs):
|
|
1966
|
+
return None
|
|
1967
|
+
hits = [j for j in jobs if j["state"] in ok]
|
|
1968
|
+
print(f"{verb} {len(hits)} job(s): "
|
|
1969
|
+
+ (", ".join(f"{j['id']} {_text(j.get('tag'))}" for j in hits) or "none"))
|
|
1970
|
+
_left([j for j in jobs if j["state"] not in ok])
|
|
1971
|
+
if a.dry_run or not hits:
|
|
1972
|
+
return 0
|
|
1973
|
+
return None if ui.confirm(f"{verb} them?", a.yes) else 2
|
|
1974
|
+
|
|
1975
|
+
|
|
1976
|
+
def _ranges(ids: list[int]) -> str:
|
|
1977
|
+
"""[1, 2, 3, 7] -> '1-3, 7' (sorted ids)."""
|
|
1978
|
+
out: list[list[int]] = []
|
|
1979
|
+
for i in ids:
|
|
1980
|
+
if out and i == out[-1][1] + 1:
|
|
1981
|
+
out[-1][1] = i
|
|
1982
|
+
else:
|
|
1983
|
+
out.append([i, i])
|
|
1984
|
+
return ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in out)
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
def _needs_str(needs: dict | None) -> str:
|
|
1988
|
+
out = []
|
|
1989
|
+
for k, v in (needs or {}).items():
|
|
1990
|
+
if k == "slots" and v == 1:
|
|
1991
|
+
continue
|
|
1992
|
+
out.append({"gpu_gb": f"gpu {v:g}G", "mem_gb": f"mem {v:g}G"}.get(k, f"{k} {v:g}"))
|
|
1993
|
+
return " ".join(out)
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
def project_name(cwd: str) -> str:
|
|
1997
|
+
root, data = config.find_project(cwd)
|
|
1998
|
+
if root:
|
|
1999
|
+
return config.project_name(root, data)
|
|
2000
|
+
g = config.git_root(cwd)
|
|
2001
|
+
return os.path.basename(os.path.normpath(g or cwd))
|
|
2002
|
+
|
|
2003
|
+
|
|
2004
|
+
def _kv(items: list[str] | None, flag: str, conv) -> dict:
|
|
2005
|
+
out = {}
|
|
2006
|
+
for it in items or []:
|
|
2007
|
+
k, sep, v = it.partition("=")
|
|
2008
|
+
if not sep or not k:
|
|
2009
|
+
ui.die(f"{_PROG}: {flag} takes NAME=VALUE, got '{it}'", 2)
|
|
2010
|
+
try:
|
|
2011
|
+
out[k] = conv(v)
|
|
2012
|
+
except ValueError:
|
|
2013
|
+
ui.die(f"{_PROG}: {flag} {k}: '{v}' is not a number", 2)
|
|
2014
|
+
return out
|
|
2015
|
+
|
|
2016
|
+
|
|
2017
|
+
def _from_lines(src: str) -> list[list[str]]:
|
|
2018
|
+
try:
|
|
2019
|
+
text = sys.stdin.read() if src == "-" else open(paths.expand(src)).read()
|
|
2020
|
+
except OSError as e:
|
|
2021
|
+
ui.die(f"{_PROG} add --from: {e.strerror}: {src}", 2)
|
|
2022
|
+
out = []
|
|
2023
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
2024
|
+
if line.strip() and not line.lstrip().startswith("#"):
|
|
2025
|
+
try:
|
|
2026
|
+
out.append(shlex.split(line))
|
|
2027
|
+
except ValueError as e:
|
|
2028
|
+
ui.die(f"{_PROG} add --from {src}: line {n}: {e}: {line.strip()[:80]} (nothing was queued)", 2)
|
|
2029
|
+
return out
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _last_line(path: str | None, width: int) -> str:
|
|
2033
|
+
"""The job's latest output line, never pp's own banner: a job that has printed nothing yet would show that
|
|
2034
|
+
banner as its line."""
|
|
2035
|
+
if not path:
|
|
2036
|
+
return ""
|
|
2037
|
+
try:
|
|
2038
|
+
lines = _tail(path, 5)
|
|
2039
|
+
except OSError:
|
|
2040
|
+
return ""
|
|
2041
|
+
for line in reversed(lines):
|
|
2042
|
+
line = _text(ANSI.sub("", line).strip())
|
|
2043
|
+
if line and not line.startswith("[pp queue] "):
|
|
2044
|
+
return line[:width]
|
|
2045
|
+
return ""
|
|
2046
|
+
|
|
2047
|
+
|
|
2048
|
+
def _in_delay(not_before: float | None, now: float | None = None) -> str:
|
|
2049
|
+
"""How long until a retry's delay is up, as a person reads it. Never negative: a worker notices an attempt
|
|
2050
|
+
ended a poll period after it did, so the delay of a job it is about to re-queue may be up already. The one
|
|
2051
|
+
place that remaining delay is put into words, for the three that print it."""
|
|
2052
|
+
return ui.human_duration(max(float(not_before or 0) - (now or time.time()), 0))
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
def _section(rows: list[list], tag: int, width: int, indent: str = " ") -> list[str]:
|
|
2056
|
+
"""One section of `pp queue status` as its lines: one table, so every row of it lines up, with the column
|
|
2057
|
+
at `tag` cut until the widest row fits a terminal `width` wide.
|
|
2058
|
+
|
|
2059
|
+
A job's tag is its command cut to 40 characters unless it was named, so that is the column with something
|
|
2060
|
+
to give; the rest of a row is pp's own, and a row whose other columns alone are wider than the terminal
|
|
2061
|
+
keeps eight characters of tag and runs past the edge. Empty cells at the end of a row are left out, so no
|
|
2062
|
+
row ends in the padding of a column nothing filled."""
|
|
2063
|
+
rows = [_without_empty_tail(r) for r in rows]
|
|
2064
|
+
keep = max((ui.width(str(r[tag])) for r in rows), default=0)
|
|
2065
|
+
while True:
|
|
2066
|
+
out = ui.table([[ui.cut(str(v), keep) if i == tag else v for i, v in enumerate(r)] for r in rows],
|
|
2067
|
+
indent=indent).splitlines()
|
|
2068
|
+
if keep <= 8 or max((ui.width(ln) for ln in out), default=0) <= width:
|
|
2069
|
+
return out
|
|
2070
|
+
keep -= 1
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def _without_empty_tail(row: list) -> list:
|
|
2074
|
+
out = list(row)
|
|
2075
|
+
while len(out) > 1 and not str("" if out[-1] is None else out[-1]):
|
|
2076
|
+
out.pop()
|
|
2077
|
+
return out
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
def _latest_note(j: dict, width: int = 60) -> str:
|
|
2081
|
+
notes = j.get("notes") or []
|
|
2082
|
+
return ("note: " + _text(notes[-1]["text"].replace("\n", " "), width)) if notes else ""
|
|
2083
|
+
|
|
2084
|
+
|
|
2085
|
+
def public(j: dict) -> dict:
|
|
2086
|
+
"""A job as JSON consumers (`show/status --json`) see it: every stored field.
|
|
2087
|
+
|
|
2088
|
+
Fields others rely on: id (int), state, queue, tag, project, cwd, cmd (argv), runner (argv, maybe []), needs,
|
|
2089
|
+
prio, owner {session, cwd} or None, timeout, retries, attempt, rc, reason, seconds, log,
|
|
2090
|
+
notes [{t, by, text}], reported [{t, by, session}], stop {by, t, grace, timeout, sent, kill9, denied, was}
|
|
2091
|
+
on a job asked to stop, and from `show` also watchers [{pid, verb, session}].
|
|
2092
|
+
|
|
2093
|
+
A stop's `timeout` is set on the one a worker wrote because the job's own time ran out, and on no other,
|
|
2094
|
+
so what a stop is never rests on the name of whoever asked. `sent` and `kill9` are when each signal went
|
|
2095
|
+
out, and `denied` is when pp was refused the job's process group. A stop with `denied` set is not in
|
|
2096
|
+
flight: nothing more goes out for it, and the job runs on until it ends on its own. `was` is who asked
|
|
2097
|
+
for the stop this one landed on, where a kill landed on a timeout's."""
|
|
2098
|
+
return {k: v for k, v in j.items() if not k.startswith("_")}
|
|
2099
|
+
|
|
2100
|
+
|
|
2101
|
+
def _ended_line(j: dict | None, jid: int) -> str:
|
|
2102
|
+
if j is None:
|
|
2103
|
+
return f"dropped {jid}"
|
|
2104
|
+
word = "done" if j["state"] == "done" else ui.c("FAILED", "red")
|
|
2105
|
+
why = f" ({j['reason']})" if j.get("reason") else ""
|
|
2106
|
+
return f"{word} {jid} rc={_rc(j)}{why} {ui.human_duration(j.get('seconds'))} {_text(j.get('tag'))}"
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def _rc(j: dict):
|
|
2110
|
+
"""A job's exit code as shown: '-' when there is none (killed, lost, never started)."""
|
|
2111
|
+
return "-" if j.get("rc") is None else j["rc"]
|
|
2112
|
+
|
|
2113
|
+
|
|
2114
|
+
def _outcome_lines(j: dict | None, jid: int, tail: int) -> list[str]:
|
|
2115
|
+
"""A job's final outcome as wait/watch print it: one line, and for a failure its log tail and what to do next."""
|
|
2116
|
+
out = [_ended_line(j, jid)]
|
|
2117
|
+
if j and j.get("not_retried"):
|
|
2118
|
+
out.append(f" not retried: {j['not_retried']}")
|
|
2119
|
+
if j and j["state"] == "failed" and tail > 0:
|
|
2120
|
+
out += [" | " + ln for ln in failure_tail(j, tail)]
|
|
2121
|
+
out.append(f" next: pp {_PROG} log {jid} | pp {_PROG} retry {jid} | pp {_PROG} note {jid} \"why\"")
|
|
2122
|
+
return out
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def _attempt_str(j: dict) -> str:
|
|
2126
|
+
retries = int(j.get("retries") or 0)
|
|
2127
|
+
return f"attempt {j.get('attempt', 1)}/{retries + 1}" if retries else ""
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
# ---- verbs ----------------------------------------------------------------------------------------
|
|
2131
|
+
|
|
2132
|
+
def cmd_add(a, cmd: list[str], q: str, prepare_job) -> int:
|
|
2133
|
+
cmds = _from_lines(a.from_) if a.from_ else [cmd]
|
|
2134
|
+
if not cmds or not all(cmds):
|
|
2135
|
+
ui.die(f"{_PROG} add: no command. Put it after -- (pp {_PROG} add -- python x.py)", 2)
|
|
2136
|
+
cwd = paths.expand(a.cwd) if a.cwd else os.getcwd()
|
|
2137
|
+
if not os.path.isdir(cwd):
|
|
2138
|
+
ui.die(f"{_PROG} add: no directory {cwd}", 2)
|
|
2139
|
+
env = _kv(a.env, "--env", str)
|
|
2140
|
+
needs: dict = {"slots": 1}
|
|
2141
|
+
for k, v in (("gpu_gb", a.gpu_gb), ("cpu", a.cpu), ("mem_gb", a.mem_gb)):
|
|
2142
|
+
if v:
|
|
2143
|
+
needs[k] = _num(v)
|
|
2144
|
+
needs.update(_kv(a.need, "--need", _num))
|
|
2145
|
+
for k, v in needs.items():
|
|
2146
|
+
if not 0 < v < float("inf"): # also nan: it compares false with everything, so it would never wait
|
|
2147
|
+
ui.die(f"{_PROG} add: {k}={v:g}: a need is a positive number", 2)
|
|
2148
|
+
why = Resources(q, needs, live=False).check(needs)
|
|
2149
|
+
if why:
|
|
2150
|
+
ui.die(f"{_PROG} add: {why}", 2)
|
|
2151
|
+
qc = _qcfg(q)
|
|
2152
|
+
if a.timeout is None:
|
|
2153
|
+
timeout = qc.get("timeout")
|
|
2154
|
+
else: # none (or 0): no timeout, whatever the queue's default
|
|
2155
|
+
timeout = None if a.timeout.strip().lower() == "none" else _dur_arg(a.timeout, "--timeout")
|
|
2156
|
+
timeout = timeout or None
|
|
2157
|
+
retries = a.retries if a.retries is not None else qc.get("retries", 0)
|
|
2158
|
+
if retries < 0:
|
|
2159
|
+
ui.die(f"{_PROG} add: --retries must be >= 0", 2)
|
|
2160
|
+
retry_delay = _dur_arg(a.retry_delay, "--retry-delay") if a.retry_delay is not None else qc.get("retry_delay", RETRY_DELAY)
|
|
2161
|
+
retry_if = a.retry_if if a.retry_if is not None else qc.get("retry_if")
|
|
2162
|
+
if retry_if:
|
|
2163
|
+
try:
|
|
2164
|
+
re.compile(retry_if)
|
|
2165
|
+
except re.error as e:
|
|
2166
|
+
ui.die(f"{_PROG} add: --retry-if {retry_if!r}: {e}", 2)
|
|
2167
|
+
if a.tag and re.fullmatch(r"\d+(-\d+)?", a.tag):
|
|
2168
|
+
ui.die(f"{_PROG} add: --tag {a.tag} reads as a job id or a range, so it could not select its jobs; "
|
|
2169
|
+
"use a word", 2)
|
|
2170
|
+
owner = session.caller()
|
|
2171
|
+
on_fail = _hook_spec(a.on_fail if a.on_fail is not None else qc.get("on_fail"))
|
|
2172
|
+
on_done = _hook_spec(a.on_done if a.on_done is not None else qc.get("on_done"))
|
|
2173
|
+
shell_env = {k: os.environ[k] for k in CAPTURED_ENV if k in os.environ}
|
|
2174
|
+
state = "held" if a.hold else "queued"
|
|
2175
|
+
project = project_name(cwd)
|
|
2176
|
+
ids = []
|
|
2177
|
+
for c in cmds:
|
|
2178
|
+
job = {"queue": q, "tag": a.tag or default_tag(c), "project": project, "cwd": cwd, "cmd": c, "runner": [],
|
|
2179
|
+
"env": dict(env), "shell_env": shell_env, "needs": needs, "prio": a.prio, "owner": owner,
|
|
2180
|
+
"timeout": timeout, "retries": retries, "retry_delay": retry_delay, "retry_if": retry_if or None,
|
|
2181
|
+
"attempt": 1, "on_fail": on_fail, "on_done": on_done}
|
|
2182
|
+
if prepare_job:
|
|
2183
|
+
prepare_job(job, a)
|
|
2184
|
+
with txn() as s:
|
|
2185
|
+
jid = s.add(job, state)["id"]
|
|
2186
|
+
ids.append(jid)
|
|
2187
|
+
print(jid)
|
|
2188
|
+
extra = " ".join(x for x in (f"timeout {ui.human_duration(timeout)}" if timeout else "",
|
|
2189
|
+
f"retries {retries}" if retries else "",
|
|
2190
|
+
f"on-fail {on_fail}" if on_fail else "") if x)
|
|
2191
|
+
via = f" (via {_text(os.path.basename(job['runner'][0]))})" if job["runner"] else ""
|
|
2192
|
+
print(f"pp {_PROG}: {state} {jid} ({q}) [{_text(project)}] p{a.prio} {_needs_str(needs)} {extra} "
|
|
2193
|
+
f"{_text(shlex.join(c), 100)}{via}", file=sys.stderr)
|
|
2194
|
+
if not a.hold and not a.no_start:
|
|
2195
|
+
worker_up(q, wake=True)
|
|
2196
|
+
if owner: # the pull side: an agent hears the outcome by waiting on it in the background
|
|
2197
|
+
some = " ".join(map(str, ids))
|
|
2198
|
+
print(f"pp {_PROG}: to hear how it ends, run `pp {_PROG} wait {some}` in the background (it exits with the "
|
|
2199
|
+
f"outcome and, on failure, the log tail), or `pp {_PROG} watch {some}` for a line per event", file=sys.stderr)
|
|
2200
|
+
return 0
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
def _scope_list(scope: list[str] | None) -> list[str]:
|
|
2204
|
+
"""The queues a verb works on: every one there is, or the ones it was given by name, each one opened."""
|
|
2205
|
+
if scope is None:
|
|
2206
|
+
return queues()
|
|
2207
|
+
for q in scope:
|
|
2208
|
+
open_queue(q)
|
|
2209
|
+
return scope
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
def cmd_status(a, scope: list[str] | None) -> int:
|
|
2213
|
+
"""The state of every queue in scope, or of the one named with -q.
|
|
2214
|
+
|
|
2215
|
+
`--json` is what an agent reads, and this is its shape, stated once:
|
|
2216
|
+
|
|
2217
|
+
queues [name], the queues this status covers
|
|
2218
|
+
workers {queue: pid or null}, the pid of each queue's worker; null where none is up and while
|
|
2219
|
+
one that holds the claim has not recorded itself yet
|
|
2220
|
+
starting [queue] whose worker holds the claim and has not recorded itself yet, which is the
|
|
2221
|
+
first moment of a worker: one is coming up, and it launches the queue's jobs
|
|
2222
|
+
waiting {queue: {id, tag, why, t} or null}, what the head of each queue waits for
|
|
2223
|
+
running, queued, held [job], each one as `public()` gives it
|
|
2224
|
+
recent [job], the jobs that ended most recently, at most -n of them
|
|
2225
|
+
pools {queue: [pool]}, each queue's own pools
|
|
2226
|
+
shared [pool], the pools every queue draws from; a pool is {name, max, used, probe, reading}
|
|
2227
|
+
cards [{index, name, total_gb, used_gb, free_gb, util}], the GPUs nvidia-smi reports
|
|
2228
|
+
config_errors [message]; while there is one, workers launch nothing
|
|
2229
|
+
|
|
2230
|
+
A field change goes in CHANGELOG.md (CONTRIBUTING.md, "Adding a command").
|
|
2231
|
+
"""
|
|
2232
|
+
cards = _cards() # before the listing: nvidia-smi may take seconds, and a reader holds the store
|
|
2233
|
+
now = time.time()
|
|
2234
|
+
# one listing: the counts, the rows and what the shared pools hold are the same jobs, so no job is shown
|
|
2235
|
+
# twice or in two states. Every queue is listed, since a shared pool counts every queue's running jobs.
|
|
2236
|
+
all_jobs = _jobs()
|
|
2237
|
+
# what each queue's worker is, through the one construct, so the hints that nothing launches or records
|
|
2238
|
+
# this queue's jobs come back from the one answer it gave
|
|
2239
|
+
workers = {q: worker_now(q) for q in _scope_list(scope)}
|
|
2240
|
+
qs = [q for q in _scope_list(scope)
|
|
2241
|
+
if scope is not None or any(j["queue"] == q for j in all_jobs) or workers[q].how != DOWN]
|
|
2242
|
+
if a.here or a.project:
|
|
2243
|
+
want = a.project or project_name(os.getcwd())
|
|
2244
|
+
keep = lambda js: [j for j in js if j.get("project") == want] # noqa: E731
|
|
2245
|
+
else:
|
|
2246
|
+
keep = lambda js: js # noqa: E731
|
|
2247
|
+
running_all = [j for j in all_jobs if j["state"] == "running"]
|
|
2248
|
+
per: dict = {}
|
|
2249
|
+
bad: list[str] = [] # a broken config stops launches, not this view of what is running
|
|
2250
|
+
|
|
2251
|
+
def usage(q, sc):
|
|
2252
|
+
try:
|
|
2253
|
+
return Resources(q, live=False, empty_ok=False).usage(running_all, sc)
|
|
2254
|
+
except config.ConfigError as e:
|
|
2255
|
+
if str(e) not in bad:
|
|
2256
|
+
bad.append(str(e))
|
|
2257
|
+
return []
|
|
2258
|
+
shared = usage(qs[0] if qs else "default", None) # the same for every queue
|
|
2259
|
+
for q in qs:
|
|
2260
|
+
mine = [j for j in all_jobs if j["queue"] == q]
|
|
2261
|
+
data = {st: keep([j for j in mine if j["state"] == st]) for st in STATES}
|
|
2262
|
+
own = usage(q, q)
|
|
2263
|
+
w = workers[q]
|
|
2264
|
+
waiting = (w.rec or {}).get("waiting") if w.up else None # only a live worker waits for anything
|
|
2265
|
+
per[q] = {"data": data, "wpid": w.pid, "how": w.how, "pools": own, "waiting": waiting,
|
|
2266
|
+
"recent": sorted(data["done"] + data["failed"], key=lambda j: -(j.get("ended") or 0))}
|
|
2267
|
+
if a.json:
|
|
2268
|
+
flat = lambda st: [public(j) for q in qs for j in per[q]["data"][st]] # noqa: E731
|
|
2269
|
+
recent = sorted((j for q in qs for j in per[q]["recent"]), key=lambda j: -(j.get("ended") or 0))
|
|
2270
|
+
ui.print_json({"cards": cards, "queues": qs, "workers": {q: per[q]["wpid"] for q in qs},
|
|
2271
|
+
"starting": [q for q in qs if per[q]["how"] == STARTING],
|
|
2272
|
+
"pools": {q: per[q]["pools"] for q in qs}, "shared": shared,
|
|
2273
|
+
"waiting": {q: per[q]["waiting"] for q in qs},
|
|
2274
|
+
**{st: flat(st) for st in ("running", "queued", "held")},
|
|
2275
|
+
"recent": [public(j) for j in recent[:a.n]], "config_errors": bad})
|
|
2276
|
+
return 1 if bad else 0
|
|
2277
|
+
width = shutil.get_terminal_size((120, 20)).columns
|
|
2278
|
+
gpu = " ".join(f"GPU{c['index']} {c['util']}% {c['used_gb']:.1f}/{c['total_gb']:.0f}G" for c in cards) if cards else (
|
|
2279
|
+
"GPU n/a" + (f" ({CARDS_WHY})" if CARDS_WHY else ""))
|
|
2280
|
+
print(f"{gpu} {datetime.now():%Y-%m-%d %H:%M:%S}")
|
|
2281
|
+
pool_strs = [_pool_str(p, now) for p in shared if p["used"] or p["probe"] or p["name"] not in SHARED_DEFAULTS]
|
|
2282
|
+
if pool_strs:
|
|
2283
|
+
print("shared: " + " · ".join(pool_strs))
|
|
2284
|
+
for e in bad:
|
|
2285
|
+
print(ui.c(f"bad config: {e}. Workers launch nothing until it is fixed", "yellow"))
|
|
2286
|
+
if not qs:
|
|
2287
|
+
print(f"no queues yet: `pp {_PROG} add -- CMD` starts one")
|
|
2288
|
+
return 1 if bad else 0
|
|
2289
|
+
if scope and scope[0] not in queues() and not _configured(scope[0]):
|
|
2290
|
+
known = [q for q in queues() if q != scope[0]]
|
|
2291
|
+
print(f"no queue '{scope[0]}' yet" + (f" (there are: {', '.join(known)})" if known else ""))
|
|
2292
|
+
if (a.here or a.project) and not any(per[q]["data"][st] for q in qs for st in STATES):
|
|
2293
|
+
print(f"no jobs of project '{want}'")
|
|
2294
|
+
for q in qs:
|
|
2295
|
+
s = per[q]
|
|
2296
|
+
data, waiting, wpid = s["data"], s["waiting"], s["wpid"]
|
|
2297
|
+
up = (f"worker up (pid {wpid})" if wpid is not None else
|
|
2298
|
+
ui.c("worker starting" if s["how"] == STARTING else "worker down", "yellow"))
|
|
2299
|
+
usage = [_pool_str(p, now) for p in s["pools"]]
|
|
2300
|
+
head = ui.c(f"== {q}", "bold") + f" {up}"
|
|
2301
|
+
counts = " · ".join(usage + [f"queued {len(data['queued'])}", f"held {len(data['held'])}",
|
|
2302
|
+
f"done {len(data['done'])}", f"failed {len(data['failed'])}"])
|
|
2303
|
+
# the counts go on a line of their own where the two do not fit the terminal side by side
|
|
2304
|
+
fits = ui.width(head) + 3 + ui.width(counts) <= width
|
|
2305
|
+
print("\n" + head + (" " if fits else "\n ") + counts)
|
|
2306
|
+
if data["running"]:
|
|
2307
|
+
print(ui.c("running", "bold"))
|
|
2308
|
+
rows, lasts = [], []
|
|
2309
|
+
for j in data["running"]:
|
|
2310
|
+
extra = " ".join(x for x in (_attempt_str(j),
|
|
2311
|
+
f"STOPPING ({_text(j['stop']['by'])})" if j.get("stop") else "",
|
|
2312
|
+
_latest_note(j)) if x)
|
|
2313
|
+
row = [j["id"], _text(j.get("tag")), f"[{_text(j.get('project'))}]",
|
|
2314
|
+
ui.human_duration(now - (j.get("started") or now)),
|
|
2315
|
+
_needs_str(j.get("needs")) + (f" on GPU{j['gpu']}" if j.get("gpu") is not None else "")]
|
|
2316
|
+
rows.append(row + [extra])
|
|
2317
|
+
lasts.append(_last_line(j.get("log"), max(width - 8, 20)))
|
|
2318
|
+
for line, last in zip(_section(rows, 1, width), lasts):
|
|
2319
|
+
print(line)
|
|
2320
|
+
if last:
|
|
2321
|
+
print(ui.c(" " + last, "dim"))
|
|
2322
|
+
if s["how"] == DOWN:
|
|
2323
|
+
print(ui.c(f" nothing records how they end: `pp {_PROG} worker start -q {q}`", "yellow"))
|
|
2324
|
+
elif s["how"] == STARTING:
|
|
2325
|
+
print(ui.c(" a worker is starting; it records how they end", "yellow"))
|
|
2326
|
+
if data["queued"]:
|
|
2327
|
+
print(ui.c("queued (run order)", "bold"))
|
|
2328
|
+
rows = []
|
|
2329
|
+
for j in data["queued"][:max(a.n, 1) * 3]:
|
|
2330
|
+
why = waiting["why"] if waiting and waiting.get("id") == j["id"] else ""
|
|
2331
|
+
nb = j.get("not_before") or 0
|
|
2332
|
+
extra = " ".join(x for x in (_attempt_str(j), f"retry in {_in_delay(nb, now)}" if nb > now else "",
|
|
2333
|
+
ui.c("waiting: " + why, "yellow") if why else "", _latest_note(j)) if x)
|
|
2334
|
+
row = [j["id"], f"p{j.get('prio')}", _text(j.get("tag")), f"[{_text(j.get('project'))}]",
|
|
2335
|
+
_needs_str(j.get("needs"))]
|
|
2336
|
+
rows.append(row + [extra])
|
|
2337
|
+
print("\n".join(_section(rows, 2, width)))
|
|
2338
|
+
if len(data["queued"]) > len(rows):
|
|
2339
|
+
print(f" ... {len(data['queued']) - len(rows)} more")
|
|
2340
|
+
if s["how"] == DOWN:
|
|
2341
|
+
print(ui.c(f" nothing launches them: `pp {_PROG} worker start -q {q}`", "yellow"))
|
|
2342
|
+
elif s["how"] == STARTING:
|
|
2343
|
+
print(ui.c(" a worker is starting; it launches them", "yellow"))
|
|
2344
|
+
if data["held"]:
|
|
2345
|
+
print(ui.c("held", "bold"))
|
|
2346
|
+
rows = []
|
|
2347
|
+
for j in data["held"]:
|
|
2348
|
+
row = [j["id"], f"p{j.get('prio')}", _text(j.get("tag")), f"[{_text(j.get('project'))}]"]
|
|
2349
|
+
rows.append(row + [_latest_note(j)])
|
|
2350
|
+
print("\n".join(_section(rows, 2, width)))
|
|
2351
|
+
if s["recent"]:
|
|
2352
|
+
print(ui.c("recent", "bold"))
|
|
2353
|
+
rows = []
|
|
2354
|
+
for j in s["recent"][:a.n]:
|
|
2355
|
+
word = "done" if j["state"] == "done" else ui.c("FAILED", "red")
|
|
2356
|
+
rows.append([j["id"], word, f"rc={_rc(j)}", ui.human_duration(j.get("seconds")),
|
|
2357
|
+
_text(j.get("tag")), f"[{_text(j.get('project'))}]", ui.human_age(j.get("ended"), now)
|
|
2358
|
+
+ (f" {j['reason']}" if j.get("reason") else "") + (f" {_latest_note(j)}" if j.get("notes") else "")])
|
|
2359
|
+
print("\n".join(_section(rows, 4, width)))
|
|
2360
|
+
return 1 if bad else 0
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
def _configured(q: str) -> bool:
|
|
2364
|
+
try:
|
|
2365
|
+
return q in config.load("queue")
|
|
2366
|
+
except config.ConfigError:
|
|
2367
|
+
return False
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
def _pool_str(p: dict, now: float) -> str:
|
|
2371
|
+
out = p["name"] + (f" {p['used']:g}/{p['max']:g}" if p["max"] is not None
|
|
2372
|
+
else (f" {p['used']:g} in use" if p["used"] else ""))
|
|
2373
|
+
if p["probe"]:
|
|
2374
|
+
r = p["reading"]
|
|
2375
|
+
out += (" (probe: not run yet)" if not r else f" (probe: {r['value']:g} free, {ui.human_age(r['t'], now)})"
|
|
2376
|
+
if r.get("value") is not None else f" (probe failed {ui.human_age(r['t'], now)}: {r.get('error')})")
|
|
2377
|
+
return out
|
|
2378
|
+
|
|
2379
|
+
|
|
2380
|
+
def cmd_show(a) -> int:
|
|
2381
|
+
j = _one(a.id, "show", a.scope)
|
|
2382
|
+
watched = [{k: w[k] for k in ("pid", "verb", "session")} for w in watchers(j)]
|
|
2383
|
+
if a.json:
|
|
2384
|
+
ui.print_json({**public(j), "watchers": watched})
|
|
2385
|
+
return 0
|
|
2386
|
+
state = j["state"] if j["state"] != "failed" else ui.c("FAILED", "red")
|
|
2387
|
+
head = f"job {j['id']} ({j['queue']}) {state}"
|
|
2388
|
+
if j["state"] in ENDED:
|
|
2389
|
+
head += f" rc={_rc(j)}" + (f" ({j['reason']})" if j.get("reason") else "")
|
|
2390
|
+
print(ui.c(head, "bold"))
|
|
2391
|
+
owner = j.get("owner")
|
|
2392
|
+
rows = [["tag", _text(j.get("tag"))], ["project", _text(j.get("project"))], ["cwd", _text(j.get("cwd"))],
|
|
2393
|
+
["command", _text(shlex.join(list(j.get("runner") or []) + list(j.get("cmd") or [])))],
|
|
2394
|
+
["needs", _needs_str(j.get("needs")) or "slots 1"], ["prio", j.get("prio")],
|
|
2395
|
+
["attempt", f"{j.get('attempt', 1)}/{int(j.get('retries') or 0) + 1}"
|
|
2396
|
+
+ (f" retry delay {ui.human_duration(j.get('retry_delay'))}" if j.get("retries") else "")
|
|
2397
|
+
+ (f" retry if /{j['retry_if']}/" if j.get("retry_if") else "")],
|
|
2398
|
+
["timeout", ui.human_duration(j["timeout"]) if j.get("timeout") else "-"],
|
|
2399
|
+
["on fail", _text(j.get("on_fail")) or "-"], ["on done", _text(j.get("on_done")) or "-"],
|
|
2400
|
+
["owner", f"session {_text(owner['session'])} in {_text(owner.get('cwd')) or '-'}" if owner else "-"],
|
|
2401
|
+
["added", datetime.fromtimestamp(j["added"]).strftime("%Y-%m-%d %H:%M:%S") if j.get("added") else "-"]]
|
|
2402
|
+
if j.get("started"):
|
|
2403
|
+
rows.append(["started", datetime.fromtimestamp(j["started"]).strftime("%Y-%m-%d %H:%M:%S")])
|
|
2404
|
+
if j.get("ended"):
|
|
2405
|
+
rows.append(["ended", datetime.fromtimestamp(j["ended"]).strftime("%Y-%m-%d %H:%M:%S")
|
|
2406
|
+
+ f" ({ui.human_duration(j.get('seconds'))})"])
|
|
2407
|
+
if j.get("stop"):
|
|
2408
|
+
st = j["stop"]
|
|
2409
|
+
# one stop per run carries one time, which is when the first ask landed. A kill that landed on a
|
|
2410
|
+
# timeout's stop is a kill, and the row says whose stop that time belongs to rather than reading as
|
|
2411
|
+
# the time the kill asked at
|
|
2412
|
+
when = f"{datetime.fromtimestamp(st['t']):%H:%M:%S}"
|
|
2413
|
+
rows.append(["stop", (f"{_text(st.get('by'))}, on the {_text(st['was'])}'s stop from {when}"
|
|
2414
|
+
if st.get("was") else f"{_text(st.get('by'))} asked at {when}")
|
|
2415
|
+
+ (", SIGTERM sent" if st.get("sent") else ", nothing signalled")
|
|
2416
|
+
+ (", SIGKILL sent" if st.get("kill9") else "")
|
|
2417
|
+
# a denied stop is not in flight: say so, or it reads as one still being carried out
|
|
2418
|
+
+ (f"; pp is not allowed to signal its group {j.get('pgid')}, so nothing more goes out"
|
|
2419
|
+
if st.get("denied") else "")])
|
|
2420
|
+
if j.get("not_retried"):
|
|
2421
|
+
rows.append(["not retried", j["not_retried"]])
|
|
2422
|
+
if j.get("retry_of"):
|
|
2423
|
+
rows.append(["retry of", f"job {j['retry_of']}"])
|
|
2424
|
+
if j.get("not_before") and j["state"] == "queued":
|
|
2425
|
+
rows.append(["retry at", datetime.fromtimestamp(j["not_before"]).strftime("%H:%M:%S")])
|
|
2426
|
+
if watched:
|
|
2427
|
+
rows.append(["watched by", ", ".join(f"{w['verb']} pid {w['pid']}" for w in watched)])
|
|
2428
|
+
if j.get("reported"):
|
|
2429
|
+
rows.append(["reported to", ", ".join(r["by"] for r in j["reported"])])
|
|
2430
|
+
print(ui.table(rows, indent=" "))
|
|
2431
|
+
for h in j.get("history") or []:
|
|
2432
|
+
print(f" attempt {h['attempt']}: rc={_rc(h)}" + (f" ({h['reason']})" if h.get("reason") else "")
|
|
2433
|
+
+ f" after {ui.human_duration(h.get('seconds'))}")
|
|
2434
|
+
if j.get("notes"):
|
|
2435
|
+
print(ui.c("notes", "bold"))
|
|
2436
|
+
for n in j["notes"]:
|
|
2437
|
+
print(f" {datetime.fromtimestamp(n['t']):%m-%d %H:%M} {_text(n.get('by'))}: {_text(n['text'])}")
|
|
2438
|
+
if j.get("hooks"):
|
|
2439
|
+
print(ui.c("hooks", "bold"))
|
|
2440
|
+
for h in j["hooks"]:
|
|
2441
|
+
print(f" {datetime.fromtimestamp(h['t']):%m-%d %H:%M} {h['event']}: {_text(h['cmd'])} "
|
|
2442
|
+
f"log {_text(h['log'])}")
|
|
2443
|
+
print(ui.c("log", "bold") + f" {_text(j.get('log'))}")
|
|
2444
|
+
for line in log_tail(j, 20):
|
|
2445
|
+
print(" " + _text(line))
|
|
2446
|
+
return 0
|
|
2447
|
+
|
|
2448
|
+
|
|
2449
|
+
def cmd_note(a) -> int:
|
|
2450
|
+
j = _one(a.id, "note", a.scope)
|
|
2451
|
+
text = " ".join(a.text).strip()
|
|
2452
|
+
if not text:
|
|
2453
|
+
ui.die(f"{_PROG} note: no text", 2)
|
|
2454
|
+
add_note(j["id"], text, by=a.by)
|
|
2455
|
+
print(f"noted on {j['id']}")
|
|
2456
|
+
return 0
|
|
2457
|
+
|
|
2458
|
+
|
|
2459
|
+
def cmd_log(a) -> int:
|
|
2460
|
+
j = _one(a.id, "log", a.scope)
|
|
2461
|
+
path = j.get("log")
|
|
2462
|
+
if a.follow and j["state"] == "held":
|
|
2463
|
+
print(f"[{_PROG} log] note: {j['id']} is held, so this waits forever. "
|
|
2464
|
+
f"`pp {_PROG} release {j['id']}` releases it", file=sys.stderr)
|
|
2465
|
+
if a.follow:
|
|
2466
|
+
while not (path and os.path.exists(path)): # queued: the log appears when it starts
|
|
2467
|
+
cur = find(j["id"])
|
|
2468
|
+
if not cur or cur["state"] not in LIVE:
|
|
2469
|
+
break
|
|
2470
|
+
time.sleep(0.5)
|
|
2471
|
+
try: # opened once and read from there: `clean` may remove it at any moment, before or after
|
|
2472
|
+
f = open(path or "", "rb")
|
|
2473
|
+
except OSError:
|
|
2474
|
+
ui.die(f"{_PROG}: job {j['id']} has no log yet ({j['state']})")
|
|
2475
|
+
if not a.follow:
|
|
2476
|
+
with f:
|
|
2477
|
+
for line in _tail(f, a.lines):
|
|
2478
|
+
print(line)
|
|
2479
|
+
return 0
|
|
2480
|
+
with f: # the tail, then what follows it, from one open file: no line falls between
|
|
2481
|
+
f.seek(_tail_start(f, a.lines))
|
|
2482
|
+
while True:
|
|
2483
|
+
chunk = f.read()
|
|
2484
|
+
if chunk:
|
|
2485
|
+
sys.stdout.write(chunk.decode(errors="replace"))
|
|
2486
|
+
sys.stdout.flush()
|
|
2487
|
+
continue
|
|
2488
|
+
cur = find(j["id"])
|
|
2489
|
+
if not cur or cur["state"] in ENDED: # a pending retry is queued, not ended: keep following
|
|
2490
|
+
sys.stdout.write(f.read().decode(errors="replace"))
|
|
2491
|
+
print(_ended_line(cur, j["id"]), file=sys.stderr)
|
|
2492
|
+
return 0 if cur and cur["state"] == "done" else 1 # like wait
|
|
2493
|
+
time.sleep(0.5)
|
|
2494
|
+
|
|
2495
|
+
|
|
2496
|
+
def cmd_kill(a) -> int:
|
|
2497
|
+
"""Ask for each job to be stopped, and report the outcome that is recorded for it.
|
|
2498
|
+
|
|
2499
|
+
A kill signals no job itself. It writes a stop on the job in one transaction and waits for the record of
|
|
2500
|
+
how the job ended, the way `wait` waits for an outcome. The queue's worker carries that stop out, so this
|
|
2501
|
+
makes sure the queue has one: it wakes the worker that holds the claim, or starts one where nobody does.
|
|
2502
|
+
That worker signals the job's process group, escalates to SIGKILL after the grace, reaps it and records
|
|
2503
|
+
how it ended, so one actor ends a job and its outcome is recorded once. A worker this started launches the
|
|
2504
|
+
queue's queued jobs too, as the worker an `add` starts does.
|
|
2505
|
+
|
|
2506
|
+
A job named as queued or held is refused, since it has no process: `drop` removes it. A job this kill read
|
|
2507
|
+
as running may be back in its queue for a retry by the time the stop is written. It has no process then,
|
|
2508
|
+
so the transaction ends it there."""
|
|
2509
|
+
rc = 0
|
|
2510
|
+
jobs = _resolve(a.ids, a.scope)
|
|
2511
|
+
go = _gate(a, jobs, "kill", ("running",))
|
|
2512
|
+
if go is not None:
|
|
2513
|
+
return go
|
|
2514
|
+
asked = [] # the jobs a worker has to stop, in the order they were named
|
|
2515
|
+
for j in jobs:
|
|
2516
|
+
jid = j["id"]
|
|
2517
|
+
if j["state"] != "running" and j["_bulk"]:
|
|
2518
|
+
continue # listed by _gate
|
|
2519
|
+
if j["state"] != "running":
|
|
2520
|
+
hint = f"`pp {_PROG} drop {jid}` removes it" if j["state"] in ("queued", "held") else "it has ended"
|
|
2521
|
+
ui.warn(f"{_PROG}: job {jid} is {j['state']}, not running; {hint}")
|
|
2522
|
+
rc = 2
|
|
2523
|
+
continue
|
|
2524
|
+
with txn() as s:
|
|
2525
|
+
cur = s.job(jid)
|
|
2526
|
+
if cur is None or cur["state"] in ENDED:
|
|
2527
|
+
print(f"job {jid} already {cur['state'] if cur else 'gone'}")
|
|
2528
|
+
continue
|
|
2529
|
+
if cur["state"] in ("queued", "held"):
|
|
2530
|
+
# it went back to its queue for a retry: no process exists, so this is where it ends
|
|
2531
|
+
was = cur["state"]
|
|
2532
|
+
_finish(s, cur, "failed", None, "killed")
|
|
2533
|
+
print(f"killed {jid} (it was {was}, with no process to stop) {_text(j.get('tag'))}")
|
|
2534
|
+
continue
|
|
2535
|
+
cur["stop"] = merge_stop(cur.get("stop"), {"by": _by(), "t": time.time(), "grace": float(a.grace)})
|
|
2536
|
+
s.save(cur)
|
|
2537
|
+
asked.append((j, cur["stop"]["t"]))
|
|
2538
|
+
for q in sorted({j["queue"] for j, _t in asked}):
|
|
2539
|
+
worker_up(q, wake=True) # the queue's worker is what carries these stops out
|
|
2540
|
+
for j, t in asked:
|
|
2541
|
+
rc = max(rc, _await_stop(j, float(a.grace), t))
|
|
2542
|
+
return rc
|
|
2543
|
+
|
|
2544
|
+
|
|
2545
|
+
def _await_stop(j: dict, grace: float, t: float) -> int:
|
|
2546
|
+
"""Wait for the stop written on this job at t to be carried out, then print how the job ended. -> this
|
|
2547
|
+
job's exit status: 0 once it has ended, 1 when pp may not signal it, when the record stops carrying that
|
|
2548
|
+
stop, or when the job outlives the wait.
|
|
2549
|
+
|
|
2550
|
+
Every pass makes sure the queue has a worker. A pass that finds one up wakes it, so its next tick is this
|
|
2551
|
+
pass and what this waits for is the job's own exit time, not the queue's poll period. A pass that finds
|
|
2552
|
+
none starts a round of them, which `worker_up` does at most every START_EVERY, so a worker that cannot
|
|
2553
|
+
come up is not started on every pass. Such a round waits for each start in it to say it is ready, so a
|
|
2554
|
+
pass that makes one can take the wait past the job's exit time plus KILL_WAIT by those ready waits. The
|
|
2555
|
+
deadline is read again as soon as that round is over, and what this prints is the time it really waited,
|
|
2556
|
+
not the bound."""
|
|
2557
|
+
jid, started_at = j["id"], time.time()
|
|
2558
|
+
deadline = started_at + grace + KILL_WAIT
|
|
2559
|
+
while True:
|
|
2560
|
+
cur = find(jid)
|
|
2561
|
+
if cur is None:
|
|
2562
|
+
print(f"dropped {jid}")
|
|
2563
|
+
return 1
|
|
2564
|
+
stop = cur.get("stop") or {}
|
|
2565
|
+
if cur["state"] in ENDED:
|
|
2566
|
+
break
|
|
2567
|
+
if stop.get("t") != t:
|
|
2568
|
+
# the record no longer carries this stop, so nobody is going to carry it out: say where the job is
|
|
2569
|
+
where = f"it is {cur['state']}" + (f", attempt {cur['attempt']}" if cur.get("attempt", 1) > 1 else "")
|
|
2570
|
+
hint = f"`pp {_PROG} drop {jid}` removes it" if cur["state"] in ("queued", "held") else "ask again"
|
|
2571
|
+
ui.warn(f"{_PROG}: job {jid} no longer carries the stop it was asked for; {where}, so {hint}")
|
|
2572
|
+
return 1
|
|
2573
|
+
if stop.get("denied"):
|
|
2574
|
+
ui.warn(f"{_PROG}: job {jid}: not allowed to signal process group {cur.get('pgid')}; "
|
|
2575
|
+
"it is still running")
|
|
2576
|
+
return 1
|
|
2577
|
+
if time.time() > deadline:
|
|
2578
|
+
# what the queue's worker is says who has the stop now, and the record says what has gone out for
|
|
2579
|
+
# it already, whoever sent it. Neither answer names a log that does not exist
|
|
2580
|
+
q, waited = j["queue"], ui.human_duration(time.time() - started_at)
|
|
2581
|
+
went = ("the SIGKILL went out" if stop.get("kill9") else
|
|
2582
|
+
"the SIGTERM went out" if stop.get("sent") else "no signal of pp's has gone out")
|
|
2583
|
+
said = (f"{_PROG}: job {jid} is still running {waited} after it was asked to stop; {went}. "
|
|
2584
|
+
f"`pp {_PROG} show {jid}` says what went out for it.")
|
|
2585
|
+
if worker_now(q).up:
|
|
2586
|
+
ui.warn(f"{said} The stop went to the {q} worker on every pass, so this wait is not the "
|
|
2587
|
+
f"queue's poll period. The {q} worker's log: {_worker_log(q)}")
|
|
2588
|
+
else:
|
|
2589
|
+
ui.warn(f"{said} No worker of the {q} queue is up now, so nothing is carrying it out. The "
|
|
2590
|
+
f"{q} worker's log says why none came up: {_worker_log(q)}")
|
|
2591
|
+
return 1
|
|
2592
|
+
# a pass wakes the worker that is up, so its next tick is this pass and what this waits out is the
|
|
2593
|
+
# job's own exit time. Where none is up, worker_up starts one at most every START_EVERY
|
|
2594
|
+
worker_up(j["queue"], quiet=True, wake=True)
|
|
2595
|
+
if time.time() > deadline:
|
|
2596
|
+
continue # a round of starts took this pass past the deadline: look once more and report
|
|
2597
|
+
time.sleep(KILL_POLL)
|
|
2598
|
+
stop = cur.get("stop") or {}
|
|
2599
|
+
if not stop.get("sent"):
|
|
2600
|
+
print(f"job {jid} had already ended: {_ended_line(cur, jid)}")
|
|
2601
|
+
elif cur.get("rc") is not None:
|
|
2602
|
+
print(f"job {jid} ended on its own as it was being killed: {_ended_line(cur, jid)}")
|
|
2603
|
+
else:
|
|
2604
|
+
# the grace on the record, which is the shorter of every ask (`merge_stop`) and the one the SIGKILL
|
|
2605
|
+
# went out after, not the one this kill asked for
|
|
2606
|
+
how = (f"SIGKILL after {float(stop.get('grace') or 0):g}s" if stop.get("kill9")
|
|
2607
|
+
else "stopped on SIGTERM")
|
|
2608
|
+
print(f"killed {jid} ({how}) {_text(cur.get('tag'))}")
|
|
2609
|
+
return 0
|
|
2610
|
+
|
|
2611
|
+
|
|
2612
|
+
def _transition(a, src: tuple, dst: str | None, verb: str, done: str) -> int:
|
|
2613
|
+
rc, skipped, started = 0, [], set()
|
|
2614
|
+
jobs = _resolve(a.ids, a.scope)
|
|
2615
|
+
if verb == "drop":
|
|
2616
|
+
go = _gate(a, jobs, "drop", src)
|
|
2617
|
+
if go is not None:
|
|
2618
|
+
return go
|
|
2619
|
+
for j in jobs:
|
|
2620
|
+
with txn() as s:
|
|
2621
|
+
cur = s.job(j["id"])
|
|
2622
|
+
if cur is None or cur["state"] not in src:
|
|
2623
|
+
if j["_bulk"]:
|
|
2624
|
+
if verb != "drop": # drop's plan listed them already
|
|
2625
|
+
skipped.append(cur or {**j, "state": "gone"})
|
|
2626
|
+
continue
|
|
2627
|
+
st = cur["state"] if cur else "gone"
|
|
2628
|
+
hint = f". `pp {_PROG} kill {j['id']}` stops it" if st == "running" else ""
|
|
2629
|
+
ui.warn(f"{_PROG}: job {j['id']} is {st}; {verb} works on {'/'.join(src)} jobs{hint}")
|
|
2630
|
+
rc = 2
|
|
2631
|
+
continue
|
|
2632
|
+
if dst is None:
|
|
2633
|
+
_forget_job(s, cur) # the record, and every file that belongs to this job alone
|
|
2634
|
+
else:
|
|
2635
|
+
cur["state"] = dst
|
|
2636
|
+
s.save(cur)
|
|
2637
|
+
print(f"{done} {j['id']} {_text(j.get('tag'))}")
|
|
2638
|
+
if dst == "queued":
|
|
2639
|
+
started.add(cur["queue"])
|
|
2640
|
+
_left(skipped)
|
|
2641
|
+
if not getattr(a, "no_start", False): # a job added with --hold started no worker; one per queue
|
|
2642
|
+
for q in sorted(started):
|
|
2643
|
+
worker_up(q, wake=True)
|
|
2644
|
+
return rc
|
|
2645
|
+
|
|
2646
|
+
|
|
2647
|
+
def _reprio(a) -> int:
|
|
2648
|
+
rc, skipped = 0, []
|
|
2649
|
+
for j in _resolve(a.ids, a.scope):
|
|
2650
|
+
with txn() as s:
|
|
2651
|
+
cur = s.job(j["id"])
|
|
2652
|
+
if cur is None or cur["state"] not in ("queued", "held"):
|
|
2653
|
+
if j["_bulk"]:
|
|
2654
|
+
skipped.append(cur or {**j, "state": "gone"})
|
|
2655
|
+
continue
|
|
2656
|
+
ui.warn(f"{_PROG}: job {j['id']} is {cur['state'] if cur else 'gone'}; reprio works on queued/held jobs")
|
|
2657
|
+
rc = 2
|
|
2658
|
+
continue
|
|
2659
|
+
cur["prio"] = a.prio
|
|
2660
|
+
s.save(cur)
|
|
2661
|
+
print(f"reprio {j['id']} -> p{a.prio}")
|
|
2662
|
+
_left(skipped)
|
|
2663
|
+
return rc
|
|
2664
|
+
|
|
2665
|
+
|
|
2666
|
+
CARRY = ("queue", "tag", "project", "cwd", "cmd", "runner", "env", "shell_env", "needs", "prio", "owner", "timeout",
|
|
2667
|
+
"retries", "retry_delay", "retry_if", "on_fail", "on_done")
|
|
2668
|
+
|
|
2669
|
+
|
|
2670
|
+
def cmd_retry(a) -> int:
|
|
2671
|
+
started, rc, skipped = set(), 0, []
|
|
2672
|
+
for j in _resolve(a.ids, a.scope):
|
|
2673
|
+
if j["state"] not in ENDED and j["_bulk"]:
|
|
2674
|
+
skipped.append(j)
|
|
2675
|
+
continue
|
|
2676
|
+
if j["state"] not in ENDED:
|
|
2677
|
+
ui.warn(f"{_PROG}: job {j['id']} is {j['state']}; retry re-queues ended jobs")
|
|
2678
|
+
rc = 2
|
|
2679
|
+
continue
|
|
2680
|
+
new = {k: j[k] for k in CARRY if k in j}
|
|
2681
|
+
new.update(retry_of=j["id"], attempt=1)
|
|
2682
|
+
if a.prio is not None:
|
|
2683
|
+
new["prio"] = a.prio
|
|
2684
|
+
with txn() as s:
|
|
2685
|
+
jid = s.add(new, "queued")["id"]
|
|
2686
|
+
print(jid)
|
|
2687
|
+
print(f"pp {_PROG}: retry of {j['id']} queued as {jid} ({j['queue']})", file=sys.stderr)
|
|
2688
|
+
started.add(j["queue"])
|
|
2689
|
+
_left(skipped)
|
|
2690
|
+
if not a.no_start:
|
|
2691
|
+
for q in sorted(started):
|
|
2692
|
+
worker_up(q, wake=True)
|
|
2693
|
+
return rc
|
|
2694
|
+
|
|
2695
|
+
|
|
2696
|
+
def cmd_clean(a, scope: list[str] | None) -> int:
|
|
2697
|
+
cutoff = time.time() - _dur_arg(a.older, "--older", "d") if a.older else None
|
|
2698
|
+
victims = [j for j in _jobs(ENDED, scope) if cutoff is None or (j.get("ended") or 0) < cutoff]
|
|
2699
|
+
if not victims:
|
|
2700
|
+
print("nothing to clean")
|
|
2701
|
+
return 0
|
|
2702
|
+
print(f"{len(victims)} ended job record(s)" + (f" older than {a.older}" if a.older else "")
|
|
2703
|
+
+ ", with their logs")
|
|
2704
|
+
for q in sorted({j["queue"] for j in victims}):
|
|
2705
|
+
print(f" {q}: {_ranges(sorted(j['id'] for j in victims if j['queue'] == q))}")
|
|
2706
|
+
if a.dry_run:
|
|
2707
|
+
return 0
|
|
2708
|
+
if not ui.confirm("remove them?", a.yes):
|
|
2709
|
+
return 2
|
|
2710
|
+
removed = 0
|
|
2711
|
+
for batch in (victims[i:i + CLEAN_BATCH] for i in range(0, len(victims), CLEAN_BATCH)):
|
|
2712
|
+
with txn() as s: # a batch at a time: a reader waits for the transaction, so it stays short
|
|
2713
|
+
for v in batch:
|
|
2714
|
+
j = s.job(v["id"], must_be=ENDED)
|
|
2715
|
+
if j is None:
|
|
2716
|
+
continue
|
|
2717
|
+
_forget_job(s, j)
|
|
2718
|
+
removed += 1
|
|
2719
|
+
print(f"removed {removed}")
|
|
2720
|
+
return 0
|
|
2721
|
+
|
|
2722
|
+
|
|
2723
|
+
def _event(prev: dict | None, j: dict | None, jid: int) -> str | None:
|
|
2724
|
+
"""watch: the lines for a change of a job (start, retry, hold, release), before its outcome; None when nothing
|
|
2725
|
+
changed. A poll can miss steps: a held job seen running or ended was released, and a job seen ended that was
|
|
2726
|
+
held or queued was started, so those lines come too."""
|
|
2727
|
+
if j is None or prev is None:
|
|
2728
|
+
return None
|
|
2729
|
+
st, k, was = j["state"], j.get("attempt", 1), prev["state"]
|
|
2730
|
+
if (st, k) == (was, prev.get("attempt", 1)):
|
|
2731
|
+
return None
|
|
2732
|
+
tag = _text(j.get("tag"))
|
|
2733
|
+
started = f"{jid} started{' ' + _attempt_str(j) if _attempt_str(j) else ''} {tag}"
|
|
2734
|
+
lines = [f"{jid} released {tag}"] if was == "held" and st != "held" else []
|
|
2735
|
+
if st in ENDED:
|
|
2736
|
+
lines += [started] if was in ("held", "queued") else []
|
|
2737
|
+
elif st == "running":
|
|
2738
|
+
lines.append(started)
|
|
2739
|
+
elif st == "queued" and k > prev.get("attempt", 1):
|
|
2740
|
+
h = (j.get("history") or [{}])[-1]
|
|
2741
|
+
why = h.get("reason") or f"exit {h.get('rc')}"
|
|
2742
|
+
left = _in_delay(j.get("not_before"))
|
|
2743
|
+
lines.append(f"{jid} attempt {k - 1}/{int(j.get('retries') or 0) + 1} failed ({why}); attempt {k} in {left} {tag}")
|
|
2744
|
+
elif not lines:
|
|
2745
|
+
lines.append(f"{jid} {st} {tag}")
|
|
2746
|
+
return "\n".join(lines) or None
|
|
2747
|
+
|
|
2748
|
+
|
|
2749
|
+
def cmd_wait(a, scope: list[str] | None, stream: bool = False) -> int:
|
|
2750
|
+
"""wait: block until jobs end, then print their outcomes. watch (stream): a line per event as it happens.
|
|
2751
|
+
Both hold a watcher lease while they run (`show` lists it) and stamp each outcome they print on the job."""
|
|
2752
|
+
verb = "watch" if stream else "wait"
|
|
2753
|
+
t0 = time.time()
|
|
2754
|
+
|
|
2755
|
+
def say(*lines):
|
|
2756
|
+
for line in lines:
|
|
2757
|
+
print(line, flush=True)
|
|
2758
|
+
|
|
2759
|
+
def timed_out():
|
|
2760
|
+
return a.timeout is not None and time.time() - t0 >= a.timeout
|
|
2761
|
+
|
|
2762
|
+
def footer():
|
|
2763
|
+
with listing() as s:
|
|
2764
|
+
live = {st: len(s.jobs(st, scope)) for st in LIVE}
|
|
2765
|
+
say(f"[{_PROG} {verb}] {datetime.now():%H:%M:%S}: running {live['running']}, queued {live['queued']}, held {live['held']}")
|
|
2766
|
+
|
|
2767
|
+
def tell(j, jid):
|
|
2768
|
+
say(*_outcome_lines(j, jid, a.tail))
|
|
2769
|
+
if j is not None:
|
|
2770
|
+
_stamp_reported(jid, verb)
|
|
2771
|
+
|
|
2772
|
+
if a.idle:
|
|
2773
|
+
before = {j["id"] for j in _jobs(ENDED, scope)}
|
|
2774
|
+
told: set = set()
|
|
2775
|
+
asked: set = set() # the queues this loop has asked for a worker: it says what holds a claim once
|
|
2776
|
+
with watching(None, scope, verb):
|
|
2777
|
+
while True:
|
|
2778
|
+
# one listing: the jobs still to come and the ones that ended are read together, so the last
|
|
2779
|
+
# look, the one that finds none live, holds every outcome there is to report
|
|
2780
|
+
with listing() as s:
|
|
2781
|
+
live = s.jobs(("queued", "running"), scope)
|
|
2782
|
+
ended = [j for j in s.jobs(ENDED, scope) if j["id"] not in before]
|
|
2783
|
+
if not live:
|
|
2784
|
+
break
|
|
2785
|
+
if stream:
|
|
2786
|
+
for j in sorted(ended, key=lambda j: j["id"]):
|
|
2787
|
+
if j["id"] not in told:
|
|
2788
|
+
told.add(j["id"])
|
|
2789
|
+
tell(j, j["id"])
|
|
2790
|
+
if not a.no_start: # running ones too: only a worker records how a job ended
|
|
2791
|
+
for q in {j["queue"] for j in live}:
|
|
2792
|
+
worker_up(q, quiet=q in asked)
|
|
2793
|
+
asked.add(q)
|
|
2794
|
+
if timed_out():
|
|
2795
|
+
print(f"[{_PROG} {verb}] timeout after {a.timeout:g}s", file=sys.stderr)
|
|
2796
|
+
return 3
|
|
2797
|
+
time.sleep(a.poll)
|
|
2798
|
+
for j in sorted(ended, key=lambda j: j["id"]):
|
|
2799
|
+
if j["id"] not in told:
|
|
2800
|
+
tell(j, j["id"])
|
|
2801
|
+
footer()
|
|
2802
|
+
return 1 if any(j["state"] == "failed" for j in ended) else 0
|
|
2803
|
+
if a.ids:
|
|
2804
|
+
targets, need_all = [j["id"] for j in _resolve(a.ids, scope)], True
|
|
2805
|
+
elif a.mine:
|
|
2806
|
+
me = session.caller()
|
|
2807
|
+
if not me:
|
|
2808
|
+
ui.die(f"{_PROG} {verb} --mine: not inside an agent session (no CLAUDE_CODE_SESSION_ID)", 2)
|
|
2809
|
+
|
|
2810
|
+
def mine(j):
|
|
2811
|
+
return isinstance(j.get("owner"), dict) and j["owner"].get("session") == me["session"]
|
|
2812
|
+
|
|
2813
|
+
def unheard(j): # ended before this session waited on it: a quick failure must not be missed
|
|
2814
|
+
return not any(isinstance(r, dict) and r.get("session") == me["session"] for r in j.get("reported") or [])
|
|
2815
|
+
|
|
2816
|
+
# one listing of both halves: a job is listed once, in one state, so a target is named once
|
|
2817
|
+
with listing() as s:
|
|
2818
|
+
targets = [j["id"] for j in s.jobs(LIVE, scope) if mine(j)] \
|
|
2819
|
+
+ [j["id"] for j in s.jobs(ENDED, scope) if mine(j) and unheard(j)]
|
|
2820
|
+
need_all = True
|
|
2821
|
+
if not targets:
|
|
2822
|
+
say("nothing of this session's is queued, held or running, and every outcome was reported to it")
|
|
2823
|
+
return 0
|
|
2824
|
+
else:
|
|
2825
|
+
targets, need_all = [j["id"] for j in _jobs(("queued", "running"), scope)], False
|
|
2826
|
+
if not targets:
|
|
2827
|
+
say("nothing queued or running")
|
|
2828
|
+
return 0
|
|
2829
|
+
held = [j["id"] for j in (find(t) for t in targets) if j and j["state"] == "held"]
|
|
2830
|
+
if held:
|
|
2831
|
+
print(f"[{_PROG} {verb}] note: {', '.join(map(str, held))} held, so this waits forever. "
|
|
2832
|
+
f"`pp {_PROG} release` them", file=sys.stderr)
|
|
2833
|
+
with watching(targets, None, verb):
|
|
2834
|
+
prev: dict = {}
|
|
2835
|
+
told: set = set()
|
|
2836
|
+
asked = set() # as above: a loop says what holds a queue's claim on its first pass only
|
|
2837
|
+
blamed: set = set() # the queues this loop has said no worker came up for, which it says once
|
|
2838
|
+
while True:
|
|
2839
|
+
cur = {t: find(t) for t in targets}
|
|
2840
|
+
if stream:
|
|
2841
|
+
if not prev:
|
|
2842
|
+
say(f"[{_PROG} watch] " + ", ".join(f"{t} {cur[t]['state'] if cur[t] else 'gone'}" for t in targets))
|
|
2843
|
+
for t in targets:
|
|
2844
|
+
line = _event(prev.get(t), cur[t], t)
|
|
2845
|
+
if line:
|
|
2846
|
+
say(line)
|
|
2847
|
+
if t not in told and (cur[t] is None or cur[t]["state"] in ENDED):
|
|
2848
|
+
told.add(t)
|
|
2849
|
+
tell(cur[t], t)
|
|
2850
|
+
prev = {t: j for t, j in cur.items() if j}
|
|
2851
|
+
ended = {t: j for t, j in cur.items() if j is None or j["state"] in ENDED}
|
|
2852
|
+
if ended and (len(ended) == len(targets) or not need_all):
|
|
2853
|
+
for t in sorted(ended):
|
|
2854
|
+
if t not in told:
|
|
2855
|
+
tell(ended[t], t)
|
|
2856
|
+
return 1 if any(j is None or j["state"] == "failed" for j in ended.values()) else 0
|
|
2857
|
+
if not a.no_start: # a dead worker would otherwise make this wait forever, queued or running:
|
|
2858
|
+
for q in {j["queue"] for j in cur.values() if j and j["state"] in ("queued", "running")}:
|
|
2859
|
+
quietly = stream or q in asked
|
|
2860
|
+
w = worker_up(q, quiet=quietly) # only a worker records how a job ended
|
|
2861
|
+
asked.add(q)
|
|
2862
|
+
if w.why and q not in blamed:
|
|
2863
|
+
# `watch` asks quietly on every pass, so a queue no worker can come up for would
|
|
2864
|
+
# leave it printing events forever and never the reason none launches the job
|
|
2865
|
+
blamed.add(q)
|
|
2866
|
+
if quietly:
|
|
2867
|
+
say(f"[{_PROG} {verb}] the {q} worker did not come up: {w.why}; "
|
|
2868
|
+
f"its log: {_worker_log(q)}")
|
|
2869
|
+
if timed_out():
|
|
2870
|
+
print(f"[{_PROG} {verb}] timeout after {a.timeout:g}s: {len(targets) - len(ended)} of {len(targets)} not ended",
|
|
2871
|
+
file=sys.stderr)
|
|
2872
|
+
return 3
|
|
2873
|
+
time.sleep(a.poll)
|
|
2874
|
+
|
|
2875
|
+
|
|
2876
|
+
def cmd_worker(a, q_given: str | None, default_queue: str, scope: list[str] | None) -> int:
|
|
2877
|
+
"""`worker status`, `worker start` and `worker stop`, each through the one construct that says what a
|
|
2878
|
+
queue's worker is.
|
|
2879
|
+
|
|
2880
|
+
`worker status --json` is a list, one entry per queue in scope, and this is its shape, stated once:
|
|
2881
|
+
|
|
2882
|
+
queue the queue's name
|
|
2883
|
+
pid the pid of its worker; null where none is up and while one that is starting names nothing yet
|
|
2884
|
+
how "up" (a worker holds the claim and has recorded itself), "down" (nobody holds it) or
|
|
2885
|
+
"starting" (a worker holds it and has not recorded itself yet, which is a worker's first
|
|
2886
|
+
moment). "started" is `worker start`'s answer and never appears here, since this starts none
|
|
2887
|
+
up whether a worker is up, which is `pid` being a number
|
|
2888
|
+
log where that worker writes
|
|
2889
|
+
|
|
2890
|
+
A field change goes in CHANGELOG.md (CONTRIBUTING.md, "Adding a command").
|
|
2891
|
+
"""
|
|
2892
|
+
if a.action == "status":
|
|
2893
|
+
qs = [q_given] if q_given else _scope_list(scope)
|
|
2894
|
+
rows = []
|
|
2895
|
+
for q in qs:
|
|
2896
|
+
w = worker_now(q)
|
|
2897
|
+
rows.append({"queue": q, "pid": w.pid, "how": w.how, "log": _worker_log(q)})
|
|
2898
|
+
if a.json:
|
|
2899
|
+
ui.print_json([{**r, "up": r["pid"] is not None} for r in rows])
|
|
2900
|
+
elif not rows:
|
|
2901
|
+
print("no queues yet")
|
|
2902
|
+
for r in [] if a.json else rows:
|
|
2903
|
+
state = (f"worker up (pid {r['pid']})" if r["pid"] is not None
|
|
2904
|
+
else "worker starting" if r["how"] == STARTING else "worker down")
|
|
2905
|
+
print(f"{r['queue']}: {state} log {r['log']}")
|
|
2906
|
+
return 0
|
|
2907
|
+
q = q_given or default_queue
|
|
2908
|
+
writing_queue(q) # `worker start -q NAME` opens that queue, so it goes through the same refusal
|
|
2909
|
+
logf = _worker_log(q)
|
|
2910
|
+
if a.action == "start":
|
|
2911
|
+
# one answer, from the scope that acted on it: nothing looks at the claim again, so a worker that came
|
|
2912
|
+
# up between two looks can never read as one that never came
|
|
2913
|
+
w = worker_up(q, quiet=True)
|
|
2914
|
+
if w.how == STARTED:
|
|
2915
|
+
print(f"{q}: started worker (pid {w.pid}; log {logf})")
|
|
2916
|
+
elif w.up:
|
|
2917
|
+
print(f"{q}: worker already up (pid {w.pid})")
|
|
2918
|
+
elif w.how == STARTING:
|
|
2919
|
+
ui.die(f"{_PROG}: {_starting(q)}; ask again", 1)
|
|
2920
|
+
else:
|
|
2921
|
+
# this asked quietly, so the reason the answer carries is said here: a start that timed out
|
|
2922
|
+
# logged nothing of its own, and that reason is the only word there is about it
|
|
2923
|
+
why = f": {w.why}" if w.why else ""
|
|
2924
|
+
ui.die(f"{_PROG}: the {q} worker did not come up{why}; its log: {logf}")
|
|
2925
|
+
return 0
|
|
2926
|
+
w = worker_now(q) # one look, and this stop signals the worker that look found
|
|
2927
|
+
if w.how == STARTING: # there is nothing to signal yet: it has not recorded itself
|
|
2928
|
+
ui.die(f"{_PROG}: {_starting(q)}; ask again", 1)
|
|
2929
|
+
if not w.up: # nothing holds the claim: there is no worker, whatever is written in it
|
|
2930
|
+
print(f"{q}: worker down")
|
|
2931
|
+
return 0
|
|
2932
|
+
h = w.handle # the handle this one look gave: no second look decides what is signalled
|
|
2933
|
+
pid = h.id
|
|
2934
|
+
outcome = proc.signal_pid(h, signal.SIGTERM)
|
|
2935
|
+
if outcome == "gone": # it exited since we looked, or that pid is another process now
|
|
2936
|
+
print(f"{q}: worker down")
|
|
2937
|
+
return 0
|
|
2938
|
+
if outcome == "denied":
|
|
2939
|
+
ui.die(f"{_PROG}: not allowed to stop the {q} worker (pid {pid}); it belongs to another user", 1)
|
|
2940
|
+
# it stops between ticks, and one tick can wait on a probe (up to 60s each) or nvidia-smi; it is down when
|
|
2941
|
+
# the claim is no longer its own, which the kernel sees to even if it never reaches the end of its own stop.
|
|
2942
|
+
# What this waits for is that worker, not a free claim: an `add` or a background `wait` starts the next
|
|
2943
|
+
# worker as soon as the claim is free, and this stop is over when the one it signalled has let it go.
|
|
2944
|
+
def it_still_holds() -> bool:
|
|
2945
|
+
"""Whether the worker this stop signalled still holds the claim, asked through the one construct. A
|
|
2946
|
+
record naming another worker, and nothing holding the claim, are both that worker gone."""
|
|
2947
|
+
held = worker_now(q).rec or {}
|
|
2948
|
+
return (held.get("pid"), held.get("pstart")) == (h.id, h.start)
|
|
2949
|
+
|
|
2950
|
+
start, said = time.time(), False
|
|
2951
|
+
while it_still_holds():
|
|
2952
|
+
if not said and time.time() - start > 10:
|
|
2953
|
+
print(f"{q}: worker {pid} is finishing its tick (a probe or nvidia-smi); waiting", flush=True)
|
|
2954
|
+
said = True
|
|
2955
|
+
if time.time() - start > STOP_WAIT:
|
|
2956
|
+
ui.die(f"{_PROG}: the {q} worker ({pid}) did not stop within {ui.human_duration(STOP_WAIT)}; its log: {logf}")
|
|
2957
|
+
time.sleep(0.1)
|
|
2958
|
+
print(f"{q}: stopped worker {pid}; running jobs keep running and the next worker adopts them")
|
|
2959
|
+
return 0
|
|
2960
|
+
|
|
2961
|
+
|
|
2962
|
+
# ---- the CLI ----------------------------------------------------------------------------------------
|
|
2963
|
+
|
|
2964
|
+
def _parser(prog: str, doc: str, add_args=None):
|
|
2965
|
+
ap = ui.parser(prog, doc)
|
|
2966
|
+
sub = ap.add_subparsers(dest="verb", metavar="VERB")
|
|
2967
|
+
|
|
2968
|
+
def sp(name, **kw):
|
|
2969
|
+
p = sub.add_parser(name, **kw)
|
|
2970
|
+
p.add_argument("-q", "--queue", help="the named queue (see `pp help queue`)")
|
|
2971
|
+
return p
|
|
2972
|
+
|
|
2973
|
+
p = sp("add", help="queue a command (after --)")
|
|
2974
|
+
p.add_argument("--tag", help="a name to select it by, alone or with the other jobs that share it "
|
|
2975
|
+
"(default: the command, cut to 40 characters)")
|
|
2976
|
+
p.add_argument("--prio", type=int, default=10, help="lower runs first (default 10)")
|
|
2977
|
+
p.add_argument("--gpu-gb", type=float, help="GPU memory to reserve on one card (the job's peak use, plus a margin)")
|
|
2978
|
+
p.add_argument("--cpu", type=float, help="CPUs to reserve")
|
|
2979
|
+
p.add_argument("--mem-gb", type=float, help="RAM to reserve, in GB")
|
|
2980
|
+
p.add_argument("--need", action="append", metavar="NAME=N", help="a counter from [resources] (every queue's) or [queue.Q.resources] (one queue's own)")
|
|
2981
|
+
p.add_argument("--env", action="append", metavar="K=V",
|
|
2982
|
+
help="set in the job's environment; wins over the project's [queue] env")
|
|
2983
|
+
p.add_argument("--cwd", help="run there (default: here); also picks the project")
|
|
2984
|
+
p.add_argument("--timeout", metavar="DUR", help="kill it after DUR (90s, 30m, 2h, 1d); none: no timeout")
|
|
2985
|
+
p.add_argument("--retries", type=int, help="automatic retries of a failure (default 0)")
|
|
2986
|
+
p.add_argument("--retry-delay", metavar="DUR", help="delay before the first retry, doubling after (default 30s)")
|
|
2987
|
+
p.add_argument("--retry-if", metavar="REGEX", help="retry only failures whose reason or log tail matches")
|
|
2988
|
+
p.add_argument("--on-fail", metavar="CMD", help="shell command run on the final failure; none: no hook")
|
|
2989
|
+
p.add_argument("--on-done", metavar="CMD", help="shell command run on success; none: no hook")
|
|
2990
|
+
p.add_argument("--hold", action="store_true", help="park it as held until released")
|
|
2991
|
+
p.add_argument("--no-start", action="store_true", help="do not start a worker")
|
|
2992
|
+
p.add_argument("--from", dest="from_", metavar="FILE", help="one command per line; - for stdin")
|
|
2993
|
+
if add_args:
|
|
2994
|
+
add_args(p)
|
|
2995
|
+
p.add_argument("cmd", nargs="...")
|
|
2996
|
+
for name in ("status", "ls"):
|
|
2997
|
+
p = sp(name, help="running, queued, held, recent" if name == "status" else "same as status")
|
|
2998
|
+
p.add_argument("--json", action="store_true")
|
|
2999
|
+
p.add_argument("-n", type=int, default=10, help="recent jobs shown")
|
|
3000
|
+
g = p.add_mutually_exclusive_group()
|
|
3001
|
+
g.add_argument("--here", action="store_true", help="only this directory's project")
|
|
3002
|
+
g.add_argument("--project")
|
|
3003
|
+
p = sp("show", help="every field of a job, and its log tail")
|
|
3004
|
+
p.add_argument("id")
|
|
3005
|
+
p.add_argument("--json", action="store_true")
|
|
3006
|
+
p = sp("note", help="attach a note to a job (any state)")
|
|
3007
|
+
p.add_argument("id")
|
|
3008
|
+
p.add_argument("text", nargs="+")
|
|
3009
|
+
p.add_argument("--by", metavar="NAME", help="the author shown (default: the calling agent session, else user)")
|
|
3010
|
+
p = sp("log", help="tail a job's log")
|
|
3011
|
+
p.add_argument("id")
|
|
3012
|
+
p.add_argument("-f", "--follow", action="store_true")
|
|
3013
|
+
p.add_argument("-n", "--lines", type=int, default=40)
|
|
3014
|
+
p = sp("kill", help="stop running jobs: the whole process group is signalled; a job the signal stopped "
|
|
3015
|
+
"fires no hook")
|
|
3016
|
+
p.add_argument("ids", nargs="+")
|
|
3017
|
+
p.add_argument("--grace", type=float, default=10, help="seconds between SIGTERM and SIGKILL")
|
|
3018
|
+
p.add_argument("-n", "--dry-run", action="store_true", help="only show which jobs it would stop")
|
|
3019
|
+
p.add_argument("-y", "--yes", action="store_true", help="do not ask about a range or a shared tag")
|
|
3020
|
+
for name, hlp in (("drop", "remove queued/held jobs"), ("hold", "park queued jobs"), ("release", "un-park held jobs")):
|
|
3021
|
+
p = sp(name, help=hlp)
|
|
3022
|
+
p.add_argument("ids", nargs="+")
|
|
3023
|
+
if name == "drop":
|
|
3024
|
+
p.add_argument("-n", "--dry-run", action="store_true", help="only show which jobs it would remove")
|
|
3025
|
+
p.add_argument("-y", "--yes", action="store_true", help="do not ask about a range or a shared tag")
|
|
3026
|
+
if name == "release":
|
|
3027
|
+
p.add_argument("--no-start", action="store_true", help="do not start a worker")
|
|
3028
|
+
p = sp("reprio", help="change a queued job's priority")
|
|
3029
|
+
p.add_argument("ids", nargs="+", metavar="ID")
|
|
3030
|
+
p.add_argument("prio", type=int)
|
|
3031
|
+
p = sp("retry", help="re-queue ended jobs as new jobs")
|
|
3032
|
+
p.add_argument("ids", nargs="+")
|
|
3033
|
+
p.add_argument("--prio", type=int)
|
|
3034
|
+
p.add_argument("--no-start", action="store_true")
|
|
3035
|
+
p = sp("clean", help="remove ended job records, with the logs that belong to them")
|
|
3036
|
+
p.add_argument("--older", metavar="AGE", help="only those ended before AGE ago (30m, 12h, 7d)")
|
|
3037
|
+
p.add_argument("-n", "--dry-run", action="store_true")
|
|
3038
|
+
p.add_argument("-y", "--yes", action="store_true")
|
|
3039
|
+
for name, hlp in (("wait", "block until jobs end, then print the outcomes (exit 0 ok, 1 failed or dropped, 3 timeout)"),
|
|
3040
|
+
("watch", "a line per event (start, retry, outcome) as it happens; exits like wait")):
|
|
3041
|
+
p = sp(name, help=hlp)
|
|
3042
|
+
p.add_argument("ids", nargs="*")
|
|
3043
|
+
g = p.add_mutually_exclusive_group()
|
|
3044
|
+
g.add_argument("--idle", action="store_true", help="until nothing is queued or running")
|
|
3045
|
+
g.add_argument("--mine", action="store_true", help="every job this agent session queued that is live, or ended without a wait/watch of this session reporting it")
|
|
3046
|
+
p.add_argument("--timeout", metavar="DUR", type=lambda v: _dur_arg(v, "--timeout"),
|
|
3047
|
+
help="give up after DUR (90s, 30m, 2h, 1d; a bare number is seconds) and exit 3")
|
|
3048
|
+
p.add_argument("--poll", type=float, default=2.0, help="seconds between looks (default 2)")
|
|
3049
|
+
p.add_argument("--tail", type=int, default=15, help="log lines shown with a failure (default 15)")
|
|
3050
|
+
p.add_argument("--no-start", action="store_true", help="do not start a worker for queued jobs")
|
|
3051
|
+
p = sp("work", help="run a queue's worker in the foreground")
|
|
3052
|
+
p.add_argument("--backfill", action="store_true", default=None, help="start later jobs that fit while the head waits")
|
|
3053
|
+
p.add_argument("--poll", type=float)
|
|
3054
|
+
p = sp("worker", help="start | stop | status of the background workers")
|
|
3055
|
+
p.add_argument("action", choices=("start", "stop", "status"))
|
|
3056
|
+
p.add_argument("--json", action="store_true", help="status as JSON")
|
|
3057
|
+
return ap
|
|
3058
|
+
|
|
3059
|
+
|
|
3060
|
+
def run_cli(argv: list[str], prog: str, default_queue: str, doc: str, prepare_job=None, add_args=None) -> int:
|
|
3061
|
+
"""The queue CLI for `pp <prog>`. default_queue receives `add` and `work` without -q; status, wait --idle, clean
|
|
3062
|
+
and worker status cover every queue. prepare_job(job, args) amends each job at add time; add_args(parser) adds
|
|
3063
|
+
options to `add`."""
|
|
3064
|
+
global _PROG
|
|
3065
|
+
_PROG = prog
|
|
3066
|
+
argv = list(argv)
|
|
3067
|
+
q_top = None
|
|
3068
|
+
if len(argv) >= 2 and argv[0] in ("-q", "--queue"):
|
|
3069
|
+
q_top, argv = argv[1], argv[2:]
|
|
3070
|
+
if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help")):
|
|
3071
|
+
argv = ["status"] + argv
|
|
3072
|
+
cmd: list[str] = []
|
|
3073
|
+
if argv[0] == "add" and "--" in argv:
|
|
3074
|
+
i = argv.index("--")
|
|
3075
|
+
argv, cmd = argv[:i], argv[i + 1:]
|
|
3076
|
+
a = _parser(prog, doc, add_args).parse_args(argv)
|
|
3077
|
+
q = getattr(a, "queue", None) or q_top
|
|
3078
|
+
if q is not None and (not NAME_RE.fullmatch(q) or q in RESERVED):
|
|
3079
|
+
ui.die(f"{prog}: '{q}' is not a queue name (lowercase letters, digits, - and _; not {' or '.join(RESERVED)})", 2)
|
|
3080
|
+
scope = a.scope = [q] if q else None
|
|
3081
|
+
if q is not None:
|
|
3082
|
+
open_queue(q) # a queue named with -q is opened once here, whatever the verb does with it
|
|
3083
|
+
if a.verb == "add":
|
|
3084
|
+
if cmd and a.cmd:
|
|
3085
|
+
ui.die(f"{prog} add: put the whole command after --", 2)
|
|
3086
|
+
return cmd_add(a, cmd or a.cmd, q or default_queue, prepare_job)
|
|
3087
|
+
if a.verb in ("status", "ls"):
|
|
3088
|
+
return cmd_status(a, scope)
|
|
3089
|
+
verbs = {"show": cmd_show, "note": cmd_note, "log": cmd_log, "kill": cmd_kill, "reprio": _reprio, "retry": cmd_retry}
|
|
3090
|
+
if a.verb in verbs:
|
|
3091
|
+
return verbs[a.verb](a)
|
|
3092
|
+
if a.verb == "drop":
|
|
3093
|
+
return _transition(a, ("queued", "held"), None, "drop", "dropped")
|
|
3094
|
+
if a.verb == "hold":
|
|
3095
|
+
return _transition(a, ("queued",), "held", "hold", "held")
|
|
3096
|
+
if a.verb == "release":
|
|
3097
|
+
return _transition(a, ("held",), "queued", "release", "released")
|
|
3098
|
+
if a.verb == "clean":
|
|
3099
|
+
return cmd_clean(a, scope)
|
|
3100
|
+
if a.verb in ("wait", "watch"):
|
|
3101
|
+
return cmd_wait(a, scope, stream=a.verb == "watch")
|
|
3102
|
+
if a.verb == "work":
|
|
3103
|
+
return work(q or default_queue, a.backfill, a.poll)
|
|
3104
|
+
if a.verb == "worker":
|
|
3105
|
+
return cmd_worker(a, q, default_queue, scope)
|
|
3106
|
+
return 2
|