ppkit 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {ppkit-0.2.1 → ppkit-0.2.2}/CHANGELOG.md +24 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/CONTRIBUTING.md +10 -6
- {ppkit-0.2.1 → ppkit-0.2.2}/PKG-INFO +1 -1
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/__init__.py +1 -1
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/queue.py +16 -14
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/jobcore.py +14 -13
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/jobpolicy.py +47 -29
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/jobq.py +18 -12
- {ppkit-0.2.1 → ppkit-0.2.2}/pyproject.toml +1 -1
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_queue.py +148 -88
- {ppkit-0.2.1 → ppkit-0.2.2}/.pp.toml +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/LICENSE +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/README.md +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/bin/pp +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/ci/check +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/ci/package +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/__main__.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/cli.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/__init__.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/batch.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/cmd.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/completion.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/http.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/kill.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/promote.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/__init__.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/config.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/jobstore.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/lock.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/paths.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/proc.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/session.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/ui.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/journey.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/sandbox.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_batch.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_cli.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_cmd.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_constructs.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_http.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_batch.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_cli.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_cmd.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_http.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_kill.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_promote.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_queue.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_kill.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_lock.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_paths.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_proc.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_promote.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_session.py +0 -0
- {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_ui.py +0 -0
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.2.2
|
|
4
|
+
|
|
5
|
+
`pp queue` records a kill as a kill when the worker sending it dies before
|
|
6
|
+
it records the send. Such a job no longer ends as its own failure, and its
|
|
7
|
+
`on_fail` hook does not run. A stop's new `going` field says when pp began
|
|
8
|
+
sending it.
|
|
9
|
+
|
|
10
|
+
`pp queue`: a kill whose worker dies while sending it is still a kill.
|
|
11
|
+
|
|
12
|
+
- A worker that died between sending a kill's stop and recording it left the
|
|
13
|
+
next worker to read the job's end as the job's own. That job was recorded
|
|
14
|
+
as its own failure, with exit code 137 or reason `lost`, and its `on_fail`
|
|
15
|
+
hook ran. It is now recorded `killed`, and no hook runs. A timeout's stop
|
|
16
|
+
in the same window now ends as a timeout.
|
|
17
|
+
- A stop gains `going` in `show --json` and `status --json`: when pp began
|
|
18
|
+
sending it, recorded before anything goes out. An exit code the attempt
|
|
19
|
+
wrote after that time is the stop's outcome, with the code kept, unless pp
|
|
20
|
+
was refused the stop. A job that ends on its own just as its worker dies
|
|
21
|
+
before sending is recorded the same way.
|
|
22
|
+
- `show` says "sending began" for a stop pp began sending and has nothing
|
|
23
|
+
marked sent for. A stop pp was refused still says "nothing signalled".
|
|
24
|
+
- `kill` reports a job that ended in that window as killed, "ended as its
|
|
25
|
+
stop was being sent", instead of saying the job had already ended.
|
|
26
|
+
|
|
3
27
|
## v0.2.1
|
|
4
28
|
|
|
5
29
|
pp finds the session that runs it with nothing set, so `--mine` works for
|
|
@@ -351,8 +351,9 @@ A kill, pass by pass:
|
|
|
351
351
|
|
|
352
352
|
```
|
|
353
353
|
kill: ask_stop(merge_stop) -> worker_up(wake=True) -> SIGUSR1 to the worker
|
|
354
|
-
worker: carry_out: the facts -> decide = TERM
|
|
355
|
-
|
|
354
|
+
worker: carry_out: the facts -> decide = TERM, and its txn records `going`
|
|
355
|
+
-> the project's stop command, or the SIGTERM to the group,
|
|
356
|
+
then txn marks `sent`
|
|
356
357
|
-> (the grace) -> decide = KILL9 -> signal
|
|
357
358
|
-> the wrapper ends, so the job's run claim is free
|
|
358
359
|
-> decide = ENDED_AS -> _finish records the outcome and the hook it owes
|
|
@@ -367,11 +368,14 @@ own exit time and not the queue's poll period.
|
|
|
367
368
|
|
|
368
369
|
The rules the worker follows are the ones `pp help queue` states under "How a
|
|
369
370
|
job ends, whatever reached it", and that help text is their one statement.
|
|
370
|
-
|
|
371
|
+
Three more are for contributors. Which of the two a stop is comes from the
|
|
371
372
|
`timeout` field a worker writes on the stop it wrote itself, never from the
|
|
372
|
-
name of whoever asked
|
|
373
|
-
out is that stop's outcome, with the code kept, since a container runner
|
|
374
|
-
the code of a container the stop command ended.
|
|
373
|
+
name of whoever asked. An exit code the attempt wrote after its stop began
|
|
374
|
+
going out is that stop's outcome, with the code kept, since a container runner
|
|
375
|
+
writes the code of a container the stop command ended. And that moment is the
|
|
376
|
+
stop's `going`, which the decision's transaction records with its own clock
|
|
377
|
+
before anything goes out: a worker can die between a signal and its record,
|
|
378
|
+
and a job can end between its facts and its decision.
|
|
375
379
|
|
|
376
380
|
`TestTheDecision` in `tests/test_queue.py` is a table over every combination
|
|
377
381
|
of those facts. Each row carries the rule from that help as its own label,
|
|
@@ -256,25 +256,27 @@ PP_QUEUE_RCFILE itself: what is done about such a job is what its stop asks for.
|
|
|
256
256
|
Once the run is over the exit code the attempt wrote is the command's last word, and the job ends here. A stop
|
|
257
257
|
someone asked for is never retried.
|
|
258
258
|
|
|
259
|
-
A job a kill's
|
|
260
|
-
|
|
261
|
-
A job that wrote its own exit code before a kill's
|
|
262
|
-
|
|
263
|
-
hook that outcome owes.
|
|
259
|
+
A job still running when a kill's stop began going out is recorded "killed" and fires no hook; an exit code the
|
|
260
|
+
attempt wrote after that is kept as the attempt's code.
|
|
261
|
+
A job that wrote its own exit code before a kill's stop began going out, or whose kill pp never began sending,
|
|
262
|
+
ends with its own outcome, the exit code it wrote or the signal it died of, and fires the hook that outcome owes.
|
|
264
263
|
An exit code of 0 is done, and on_done fires.
|
|
265
|
-
A timeout's stop pp
|
|
266
|
-
after that stop
|
|
264
|
+
A timeout's stop pp began sending or was refused is a timeout, which may be retried; an exit code the attempt
|
|
265
|
+
wrote after that stop began going out is kept as the attempt's code, and on a final timeout on_fail fires.
|
|
267
266
|
Anything else is the job's own failure, the exit code it wrote or the signal it died of, retried while an attempt
|
|
268
267
|
is left and --retry-if matches it, and on_fail fires on the last.
|
|
269
268
|
|
|
270
269
|
One run of a job carries one stop: a kill's ask on a timeout's makes it a kill, a second ask keeps what has gone out
|
|
271
270
|
and only shortens the grace, a refused stop is asked for again, and `show` and --json say whose stop it is, what has
|
|
272
|
-
gone out for it, its command's exit code where it had one, and whether pp was refused.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
271
|
+
gone out for it, its command's exit code where it had one, and whether pp was refused. A stop begins going out when
|
|
272
|
+
the worker records its `going` time, before it sends anything. A stop pp was refused before anything went out counts
|
|
273
|
+
as one it never began sending. A worker that dies before recording what it sent leaves that time, so the next worker
|
|
274
|
+
counts the stop as gone out and sends it again while the run is live. Where a job's work is reached by the project's
|
|
275
|
+
stop command, that command goes out in place of the SIGTERM, and `stop.rc: null` means pp has not seen it end, not
|
|
276
|
+
that it worked. Anything left in the job's group when its run is over goes with it, and a run that ended with no
|
|
277
|
+
exit code of its own, or that a stop was asked of, runs that stop command once more, so nothing the runner started
|
|
278
|
+
outlives its attempt. Nobody asked for that last run, so its output goes where the worker's own lines go (its log,
|
|
279
|
+
or the output of a `pp queue work`), and the job's log holds the job and the stops asked of it.
|
|
278
280
|
|
|
279
281
|
Workers, and after a reboot
|
|
280
282
|
One worker per queue starts what fits, reaps what ended, carries out stops, re-queues retries and fires hooks.
|
|
@@ -331,7 +333,7 @@ Agents
|
|
|
331
333
|
runner (each an argv), stop_cmd ("group", or the command that reaches what the runner started), prio, needs,
|
|
332
334
|
cards, after, grace, timeout, retries, attempt, rc, reason, not_retried, added, started, ended, seconds, log,
|
|
333
335
|
log_at, container, envfile, rcfile, owner, history (one entry per attempt that ended, with its own files and
|
|
334
|
-
cards), notes, reported, hooks, stop (by, t, grace, timeout, sent, kill9, denied, was, cmd, rc), env and
|
|
336
|
+
cards), notes, reported, hooks, stop (by, t, grace, timeout, going, sent, kill9, denied, was, cmd, rc), env and
|
|
335
337
|
shell_env (their names, every value ***). Each is on a job in every state, null or empty where the record holds
|
|
336
338
|
none yet, but `after` only on a job that names what it waits for and `stop` only on one asked to stop. A field is
|
|
337
339
|
added and never renamed or retyped inside a minor version.
|
|
@@ -35,6 +35,7 @@ import signal
|
|
|
35
35
|
import time
|
|
36
36
|
import traceback
|
|
37
37
|
from contextlib import contextmanager, nullcontext
|
|
38
|
+
from dataclasses import replace
|
|
38
39
|
from datetime import datetime
|
|
39
40
|
|
|
40
41
|
from pp.lib import config, jobpolicy as policy, jobstore, lock, paths, proc, session
|
|
@@ -203,14 +204,13 @@ class Worker:
|
|
|
203
204
|
A timeout, a kill and a reap are not three passes over the queue with three views of a job. They are
|
|
204
205
|
facts about one job and one decision made from all of them, so no part of it can be wrong about what
|
|
205
206
|
another part did."""
|
|
206
|
-
now = time.time()
|
|
207
207
|
with jobstore.listing() as s: # one pass: the jobs to decide about, and the hooks the queue still owes
|
|
208
208
|
jobs = list(s.jobs(("running",) + jobstore.ENDED, self.queue))
|
|
209
209
|
owed = [j for j in jobs if j["state"] in jobstore.ENDED and j.get("hook_due")]
|
|
210
210
|
# while a stop is in flight, and a verb is waking this worker for it, the loop's wait is STOP_PERIOD
|
|
211
211
|
# and not the poll (work()); `policy.in_flight` is what one of them is, over the record this pass
|
|
212
212
|
# listed and the action it came to
|
|
213
|
-
self.serving = sum(policy.in_flight(j, self._step(j, self._facts(j
|
|
213
|
+
self.serving = sum(policy.in_flight(j, self._step(j, self._facts(j)))
|
|
214
214
|
for j in jobs if j["state"] == "running")
|
|
215
215
|
for j in owed:
|
|
216
216
|
self.fire(j, j["hook_due"]) # owed by a worker that did not get to fire it
|
|
@@ -220,7 +220,7 @@ class Worker:
|
|
|
220
220
|
if p.poll() is not None and (jobstore.find(jid) or {}).get("state") != "running":
|
|
221
221
|
del self.children[jid]
|
|
222
222
|
|
|
223
|
-
def _facts(self, j: dict
|
|
223
|
+
def _facts(self, j: dict) -> policy.Facts:
|
|
224
224
|
"""What is true of this running job besides its record, all of it read here and nothing decided:
|
|
225
225
|
whether a run of it is live, the exit code that run wrote and when, the signal its own child died of,
|
|
226
226
|
and whether --retry-if matches the tail of its log.
|
|
@@ -236,10 +236,10 @@ class Worker:
|
|
|
236
236
|
# them, before its parent can read how it ended.
|
|
237
237
|
if held is not None:
|
|
238
238
|
self._adopt(j, held)
|
|
239
|
-
return policy.facts(True
|
|
239
|
+
return policy.facts(True)
|
|
240
240
|
self.children.pop(j["id"], None)
|
|
241
241
|
rc, ended = ended_on_its_own(j) or (None, None)
|
|
242
|
-
return policy.facts(False, p.poll() if p is not None else None, rc, ended,
|
|
242
|
+
return policy.facts(False, p.poll() if p is not None else None, rc, ended,
|
|
243
243
|
hit=policy.wants_tail(j, rc) and policy.tail_hit(j, self.tail(j, 200)))
|
|
244
244
|
|
|
245
245
|
def _step(self, j: dict, facts: policy.Facts) -> policy.Action:
|
|
@@ -249,20 +249,21 @@ class Worker:
|
|
|
249
249
|
|
|
250
250
|
The decision is made inside the transaction, on the record the store holds and the facts read before
|
|
251
251
|
it: a stop another actor wrote since those facts were read is part of it, so a job a kill has touched
|
|
252
|
-
is never retried past its stop.
|
|
252
|
+
is never retried past its stop. Its clock is read in there too, after the facts, since that is the
|
|
253
|
+
time a stop's `going` records: a code the job wrote while its stale facts said it ran is its own."""
|
|
253
254
|
jid = j["id"]
|
|
254
255
|
with jobstore.txn() as s:
|
|
255
256
|
cur = s.job(jid, must_be=("running",))
|
|
256
257
|
if cur is None:
|
|
257
258
|
return policy.Action(policy.NOTHING) # nothing else records a running job: there is nothing to record
|
|
258
|
-
a = policy.decide(cur, facts)
|
|
259
|
+
a = policy.decide(cur, replace(facts, now=time.time()))
|
|
259
260
|
was = dict(cur) # this attempt's own names, which a requeue below takes off the record
|
|
260
261
|
if a.sweep:
|
|
261
262
|
# the leader of this job's group is gone and its run is over, so anything left in that group
|
|
262
263
|
# goes too: a command that held the signal back does not outlive the job
|
|
263
264
|
self._signal_group(cur, signal.SIGKILL)
|
|
264
|
-
if a.
|
|
265
|
-
cur["stop"] =
|
|
265
|
+
if a.stop: # a timeout's stop, or the time a stop began going out, before anything does
|
|
266
|
+
cur["stop"] = a.stop
|
|
266
267
|
s.save(cur)
|
|
267
268
|
elif a.do == policy.AGAIN:
|
|
268
269
|
jobstore._requeue(s, cur, a)
|
|
@@ -292,10 +293,10 @@ class Worker:
|
|
|
292
293
|
job's processes, so a kill, a timeout and a reap never cross.
|
|
293
294
|
|
|
294
295
|
The signal goes out first and is recorded after, so a record that says a signal went out is one that
|
|
295
|
-
did
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
296
|
+
did. What an exit code is late against (I10) is the stop's `going`, which the decision's transaction
|
|
297
|
+
recorded before this was called, so a worker that dies between the signal and its record leaves a stop
|
|
298
|
+
the next decision still counts as gone out. The time recorded here is read before the signal goes out
|
|
299
|
+
too, and the grace runs from it.
|
|
299
300
|
|
|
300
301
|
Which of the two went out is recorded with it, as `cmd`: the command where the project's one started,
|
|
301
302
|
and nothing where pp's own signal went. So a reader of the record says what went out for that stop
|
|
@@ -37,7 +37,7 @@ GROUP = "group" # the stop of a job whose work is in its own process
|
|
|
37
37
|
|
|
38
38
|
NOTHING = "nothing" # leave it as it is
|
|
39
39
|
TIME_UP = "time-up" # write a timeout's stop on it; from there it is a stop like any other
|
|
40
|
-
TERM = "term" # SIGTERM to its group, recorded as the stop's `sent`
|
|
40
|
+
TERM = "term" # SIGTERM to its group, recorded as the stop's `sent`, after its `going`
|
|
41
41
|
KILL9 = "kill9" # SIGKILL to its group, recorded as the stop's `kill9`
|
|
42
42
|
ENDED_AS = "ended-as" # record how it ended, and the hook that is owed for it
|
|
43
43
|
AGAIN = "again" # back to its queue for another attempt
|
|
@@ -53,7 +53,9 @@ class Facts:
|
|
|
53
53
|
is in its environment); a code is the command's last word only once the run is over.
|
|
54
54
|
rc, rc_at the exit code this attempt wrote and when it wrote it (`ended_on_its_own`), or None for both.
|
|
55
55
|
sig the signal the attempt's process died of, where this worker started it and so holds the child.
|
|
56
|
-
now the time the
|
|
56
|
+
now the time the decision is made at, which is the only clock the decision has. The core reads it
|
|
57
|
+
inside the decision's transaction (`Worker._step`), after the rest, since a stop's `going` is this
|
|
58
|
+
time: an exit code the attempt wrote before it is the job's own, however stale the rest is.
|
|
57
59
|
hit whether --retry-if matches the tail of this attempt's log (`tail_hit`), matched where the facts
|
|
58
60
|
were read: a pattern of the caller's own takes as long as it likes over a tail, and the decision
|
|
59
61
|
runs inside the store's transaction (I12).
|
|
@@ -72,16 +74,18 @@ class Action:
|
|
|
72
74
|
"""The one thing to be done about a job. `do` is which, and a field a kind does not use is None.
|
|
73
75
|
|
|
74
76
|
nothing leave it alone
|
|
75
|
-
time-up
|
|
76
|
-
term
|
|
77
|
+
time-up stop write {by: timeout, timeout: True, t, grace} on it
|
|
78
|
+
term stop SIGTERM to its group
|
|
77
79
|
kill9 SIGKILL to its group
|
|
78
80
|
ended-as state, rc, reason, hook, ended, sweep, not_retried record how it ended
|
|
79
81
|
again attempt, not_before, history, clear, ended, sweep back to its queue for another attempt
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
stop is the job's stop as the decision's transaction writes it, or None where it writes none: a timeout's,
|
|
84
|
+
and a stop's `going` the first time a TERM is decided for it. hook is the event whose hook is owed
|
|
85
|
+
("failed", "done"), or None where the outcome owes none or the job carries no such hook. sweep says
|
|
86
|
+
anything left in the job's process group goes with it. history is the entry a requeue writes for the
|
|
87
|
+
attempt that ended, and clear the keys it takes off the record, so the store writes what this says and
|
|
88
|
+
works nothing out again.
|
|
85
89
|
"""
|
|
86
90
|
|
|
87
91
|
do: str
|
|
@@ -94,8 +98,7 @@ class Action:
|
|
|
94
98
|
attempt: int | None = None
|
|
95
99
|
not_before: float | None = None
|
|
96
100
|
not_retried: str | None = None
|
|
97
|
-
|
|
98
|
-
grace: float | None = None
|
|
101
|
+
stop: dict | None = None
|
|
99
102
|
history: dict | None = None
|
|
100
103
|
clear: tuple = ()
|
|
101
104
|
|
|
@@ -126,8 +129,8 @@ def attempt_of(job: dict, rc: int | None, reason: str | None, ended: float) -> d
|
|
|
126
129
|
def facts(held: bool, code: int | None = None, rc: int | None = None, rc_at: float | None = None,
|
|
127
130
|
now: float = 0.0, hit: bool = False) -> Facts:
|
|
128
131
|
"""The facts about one running job, from the readings the core took: whether a run of it is live, the exit
|
|
129
|
-
code it wrote and when, the status of a child this worker holds,
|
|
130
|
-
|
|
132
|
+
code it wrote and when, the status of a child this worker holds, and whether --retry-if matched the tail of
|
|
133
|
+
its log. `now` is the decision's clock, which the core puts on inside the decision's transaction (`Facts`).
|
|
131
134
|
|
|
132
135
|
While the run is live none of the rest counts for anything, since the command itself can write the exit
|
|
133
136
|
code file, so those facts are dropped here rather than read by the decision. A child's status below zero
|
|
@@ -194,7 +197,7 @@ def decide(job: dict, facts: Facts) -> Action:
|
|
|
194
197
|
"""
|
|
195
198
|
stop = job.get("stop") or {}
|
|
196
199
|
asked = bool(stop) and not stop.get("timeout") # a kill: someone asked for this job to stop
|
|
197
|
-
out =
|
|
200
|
+
out = went(stop) is not None # pp began sending this stop, and was not refused it
|
|
198
201
|
if facts.held:
|
|
199
202
|
# a run of it is live, so nothing is recorded about how it ended and its exit code says nothing yet:
|
|
200
203
|
# the one thing to do about it is to carry out the stop it carries, or to write a timeout's
|
|
@@ -203,9 +206,12 @@ def decide(job: dict, facts: Facts) -> Action:
|
|
|
203
206
|
if not stop:
|
|
204
207
|
due = (job.get("timeout") and job.get("started")
|
|
205
208
|
and facts.now - float(job["started"]) >= float(job["timeout"]))
|
|
206
|
-
return Action(TIME_UP, t
|
|
209
|
+
return (Action(TIME_UP, stop={"by": "timeout", "timeout": True, "t": facts.now, "grace": grace(job)})
|
|
210
|
+
if due else Action(NOTHING))
|
|
207
211
|
if stop.get("sent") is None:
|
|
208
|
-
|
|
212
|
+
# recorded before anything goes out, so a worker that dies before recording `sent` leaves it, and
|
|
213
|
+
# the next one sends the stop again with its first time kept
|
|
214
|
+
return Action(TERM, stop=None if stop.get("going") else {**stop, "going": facts.now})
|
|
209
215
|
if not stop.get("kill9") and facts.now - float(stop["sent"]) >= float(stop.get("grace") or 0):
|
|
210
216
|
return Action(KILL9)
|
|
211
217
|
return Action(NOTHING) # signalled, and inside the grace it was given
|
|
@@ -215,11 +221,11 @@ def decide(job: dict, facts: Facts) -> Action:
|
|
|
215
221
|
own = None if facts.rc is not None else (f"signal {facts.sig}" if facts.sig else "lost")
|
|
216
222
|
late = facts.rc is None or after(stop, facts.rc_at)
|
|
217
223
|
if asked and out and late:
|
|
218
|
-
# a kill's
|
|
219
|
-
#
|
|
224
|
+
# a kill's stop began going out while it ran: "killed", and no hook. A code the attempt wrote after
|
|
225
|
+
# that is that stop's outcome, and the code is kept
|
|
220
226
|
return _ends(job, facts.rc, "killed", facts.rc_at, sweep, None)
|
|
221
227
|
if asked:
|
|
222
|
-
# it wrote its own code before the stop
|
|
228
|
+
# it wrote its own code before the stop began going out, or pp never began sending that stop:
|
|
223
229
|
# the job ended as it ended, and the hook that outcome owes fires. A job a kill touched is never retried
|
|
224
230
|
return _ends(job, facts.rc, own, facts.rc_at, sweep, "done" if facts.rc == 0 else "failed")
|
|
225
231
|
if facts.rc == 0 and not late:
|
|
@@ -231,18 +237,28 @@ def decide(job: dict, facts: Facts) -> Action:
|
|
|
231
237
|
return _or_again(job, facts, facts.rc, own, facts.rc_at, sweep)
|
|
232
238
|
|
|
233
239
|
|
|
240
|
+
def went(stop: dict) -> float | None:
|
|
241
|
+
"""When pp began sending this stop, or None where it never did or was refused before anything went out.
|
|
242
|
+
|
|
243
|
+
`going` is that time. The decision's transaction records it before anything goes out (`decide`), so a
|
|
244
|
+
worker that dies between sending the stop and recording `sent` leaves it, and the job ends as that stop's
|
|
245
|
+
outcome rather than as its own. A record whose stop carries no `going` (an older worker's) has `sent` or
|
|
246
|
+
`kill9` instead, each read before its signal went out."""
|
|
247
|
+
if stop.get("denied") and not (stop.get("sent") or stop.get("kill9")):
|
|
248
|
+
return None
|
|
249
|
+
return stop.get("going") or stop.get("sent") or stop.get("kill9")
|
|
250
|
+
|
|
251
|
+
|
|
234
252
|
def after(stop: dict, rc_at: float | None) -> bool:
|
|
235
|
-
"""Whether the attempt wrote its exit code after
|
|
253
|
+
"""Whether the attempt wrote its exit code after this stop began going out (`went`).
|
|
236
254
|
|
|
237
255
|
A stop decides the reason: a container runner writes the code of a container the stop command ended (137),
|
|
238
256
|
so without this a kill or a timeout of a container job would read as the job's own failure. The code is
|
|
239
|
-
kept either way, and what it decides is the reason (I10). A stop
|
|
240
|
-
compare against, so no code is ever late for one.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
went = stop.get("sent") or stop.get("kill9")
|
|
245
|
-
return went is not None and rc_at is not None and rc_at >= went
|
|
257
|
+
kept either way, and what it decides is the reason (I10). A stop pp never began sending has no time to
|
|
258
|
+
compare against, so no code is ever late for one. So the window in which a stop command is starting, or a
|
|
259
|
+
signal is on its way, belongs to that stop and not to the job."""
|
|
260
|
+
w = went(stop)
|
|
261
|
+
return w is not None and rc_at is not None and rc_at >= w
|
|
246
262
|
|
|
247
263
|
|
|
248
264
|
def hook_due(job: dict, event: str | None) -> str | None:
|
|
@@ -319,8 +335,9 @@ def merge_stop(existing: dict | None, ask: dict) -> dict:
|
|
|
319
335
|
asked and when.
|
|
320
336
|
|
|
321
337
|
One stop per run: what went out is all the decision and the waiting verb have to go on, so a second ask
|
|
322
|
-
never writes over `t`, `sent` or `kill9`, and keeps only the shorter of the two graces, since a
|
|
323
|
-
hastens the SIGKILL and never delays it. It clears `denied`, so a signal pp was refused is asked
|
|
338
|
+
never writes over `t`, `going`, `sent` or `kill9`, and keeps only the shorter of the two graces, since a
|
|
339
|
+
second ask hastens the SIGKILL and never delays it. It clears `denied`, so a signal pp was refused is asked
|
|
340
|
+
for again, and with it the `going` of a stop nothing went out for, which begins again when it is next sent.
|
|
324
341
|
|
|
325
342
|
A kill's ask on the stop a worker wrote for a timeout becomes the kill's: `by` is the asker, `timeout`
|
|
326
343
|
goes, and the timeout stays in `was`. So a job a kill has touched is ended as a kill, never retried,
|
|
@@ -332,7 +349,8 @@ def merge_stop(existing: dict | None, ask: dict) -> dict:
|
|
|
332
349
|
return dict(ask)
|
|
333
350
|
out = dict(existing)
|
|
334
351
|
out["grace"] = min(float(existing.get("grace") or 0), float(ask.get("grace") or 0))
|
|
335
|
-
out.pop("denied", None)
|
|
352
|
+
if out.pop("denied", None) is not None and went(existing) is None:
|
|
353
|
+
out.pop("going", None)
|
|
336
354
|
if existing.get("timeout") and not ask.get("timeout"):
|
|
337
355
|
out["was"], out["by"] = existing["by"], ask["by"]
|
|
338
356
|
out.pop("timeout")
|
|
@@ -993,9 +993,9 @@ def public(j: dict) -> dict:
|
|
|
993
993
|
attempt's own output begins), cards (the card indices it reserved), container (the name whatever its
|
|
994
994
|
runner started elsewhere carries), envfile (the file that runner reads its environment from),
|
|
995
995
|
notes [{t, by, text}], reported [{t, by, session}], history (one entry per attempt that ended, each with
|
|
996
|
-
that attempt's own rcfile, envfile, container, log_at and cards), stop {by, t, grace, timeout,
|
|
997
|
-
kill9, denied, was, cmd, rc} on a job asked to stop, after [id] on a job that names the jobs it
|
|
998
|
-
for, and from `show` also jobcore.watchers [{pid, verb, session}].
|
|
996
|
+
that attempt's own rcfile, envfile, container, log_at and cards), stop {by, t, grace, timeout, going,
|
|
997
|
+
sent, kill9, denied, was, cmd, rc} on a job asked to stop, after [id] on a job that names the jobs it
|
|
998
|
+
waits for, and from `show` also jobcore.watchers [{pid, verb, session}].
|
|
999
999
|
|
|
1000
1000
|
`env` and `shell_env` keep their keys and give every value as "***". A token or a secret is what the help
|
|
1001
1001
|
says to pass with --env, and an agent reads `--json` to see how its jobs are getting on, so the values
|
|
@@ -1003,11 +1003,13 @@ def public(j: dict) -> dict:
|
|
|
1003
1003
|
disk, which is the job's own, holds what the job runs with.
|
|
1004
1004
|
|
|
1005
1005
|
A stop's `timeout` is set on the one a worker wrote because the job's own time ran out, and on no other,
|
|
1006
|
-
so what a stop is never rests on the name of whoever asked. `
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1006
|
+
so what a stop is never rests on the name of whoever asked. `going` is when pp began sending it, recorded
|
|
1007
|
+
before anything went out, so an exit code the attempt wrote after it is late against it and is that stop's
|
|
1008
|
+
outcome (`jobpolicy.went`). A stop pp was refused before anything went out counts as never begun, whatever
|
|
1009
|
+
its `going` says. `sent` and `kill9` are when each signal went out, read just before it did.
|
|
1010
|
+
`denied` is when pp was refused the job's process group. A stop with `denied` set is not in flight:
|
|
1011
|
+
nothing more goes out for it, and the job runs on until it ends on its own. `was` is who asked for the
|
|
1012
|
+
stop this one landed on, where a kill landed on a timeout's."""
|
|
1011
1013
|
out = {**NOT_YET, **{k: v for k, v in j.items() if not k.startswith("_")}}
|
|
1012
1014
|
for k in ("env", "shell_env"):
|
|
1013
1015
|
if isinstance(out.get(k), dict):
|
|
@@ -1456,7 +1458,8 @@ def cmd_show(a) -> int:
|
|
|
1456
1458
|
# what went out for it, off the record: the project's command where that is what went
|
|
1457
1459
|
# out, and pp's own signal where it is
|
|
1458
1460
|
+ ((f", its stop command went out ({_text(st['cmd'])})" if st.get("cmd")
|
|
1459
|
-
else ", SIGTERM sent") if st.get("sent")
|
|
1461
|
+
else ", SIGTERM sent") if st.get("sent")
|
|
1462
|
+
else ", sending began" if policy.went(st) is not None else ", nothing signalled")
|
|
1460
1463
|
+ (", SIGKILL sent" if st.get("kill9") else "")
|
|
1461
1464
|
+ (f", its command exited {st['rc']}" if st.get("rc") is not None else "")
|
|
1462
1465
|
# a denied stop is not in flight: say so, or it reads as one still being carried out
|
|
@@ -1627,7 +1630,8 @@ def _await_stop(j: dict, grace: float, t: float) -> int:
|
|
|
1627
1630
|
q, waited = j["queue"], ui.human_duration(time.time() - started_at)
|
|
1628
1631
|
went = ("the SIGKILL went out" if stop.get("kill9") else
|
|
1629
1632
|
"the project's stop command went out" if stop.get("cmd") else
|
|
1630
|
-
"the SIGTERM went out" if stop.get("sent") else
|
|
1633
|
+
"the SIGTERM went out" if stop.get("sent") else
|
|
1634
|
+
"sending it began" if policy.went(stop) is not None else "no signal of pp's has gone out")
|
|
1631
1635
|
said = (f"{_PROG}: job {jid} is still running {waited} after it was asked to stop; {went}. "
|
|
1632
1636
|
f"`pp {_PROG} show {jid}` says what went out for it.")
|
|
1633
1637
|
if jobcore.worker_now(q).up:
|
|
@@ -1644,7 +1648,7 @@ def _await_stop(j: dict, grace: float, t: float) -> int:
|
|
|
1644
1648
|
continue # a round of starts took this pass past the deadline: look once more and report
|
|
1645
1649
|
time.sleep(KILL_POLL)
|
|
1646
1650
|
stop = cur.get("stop") or {}
|
|
1647
|
-
if
|
|
1651
|
+
if policy.went(stop) is None:
|
|
1648
1652
|
print(f"job {jid} had already ended: {_ended_line(cur, jid)}")
|
|
1649
1653
|
elif not cur.get("reason"):
|
|
1650
1654
|
# the reason on the record says whose outcome this is: an exit code the attempt wrote after the stop
|
|
@@ -1656,8 +1660,10 @@ def _await_stop(j: dict, grace: float, t: float) -> int:
|
|
|
1656
1660
|
# and what went out for it, off the record and not off the command the job carries: a command that
|
|
1657
1661
|
# could not start ran nothing, and pp's own signal went out for that stop instead
|
|
1658
1662
|
went = ("the project's stop command" if stop.get("cmd") else "SIGTERM")
|
|
1663
|
+
# a stop with nothing marked sent is one whose worker ended while sending it, or whose send found the
|
|
1664
|
+
# run already gone: the record cannot say what went out
|
|
1659
1665
|
how = (f"SIGKILL after {float(stop.get('grace') or 0):g}s" if stop.get("kill9")
|
|
1660
|
-
else f"stopped on {went}")
|
|
1666
|
+
else f"stopped on {went}" if stop.get("sent") else "ended as its stop was being sent")
|
|
1661
1667
|
# a stop command that failed is said with its code: pp will not report a stop it did not achieve
|
|
1662
1668
|
said = "" if not stop.get("rc") else f"; the project's stop command failed (exit {stop['rc']})"
|
|
1663
1669
|
print(f"killed {jid} ({how}){said} {_name(cur)}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ppkit"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2" # the same as pp/__init__.py's __version__; tests/test_cli.py checks
|
|
4
4
|
description = "A CLI kit for user & agents"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -3093,21 +3093,22 @@ class TestTwoAtOnce(QueueCase):
|
|
|
3093
3093
|
self.assertIsNone(rec.get("reason")) # the code it wrote itself, not a kill's
|
|
3094
3094
|
self.assertIsNone(rec.get("history")) # and no second attempt of a job a kill has touched
|
|
3095
3095
|
|
|
3096
|
-
def
|
|
3097
|
-
"""A
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3096
|
+
def test_a_kill_whose_run_ended_before_any_send_is_not_retried(self):
|
|
3097
|
+
"""A kill's stop lands, and the run ends before any worker began sending it: something else ended the
|
|
3098
|
+
group. The next worker ends the job there, and a job a kill has touched is never retried, however
|
|
3099
|
+
little of its stop was carried out.
|
|
3100
|
+
|
|
3101
|
+
No worker recorded a `going` for that stop, so the job is recorded as its own run ended (`lost`)
|
|
3102
|
+
rather than as a kill pp carried out. A worker that dies after it began sending is the other case
|
|
3103
|
+
(test_a_kill_whose_worker_died_before_marking_its_signal_is_still_a_kill)."""
|
|
3103
3104
|
j = self.add("--retries", "3", cmd="sleep 30")
|
|
3104
3105
|
w = jobq.worker_for("default")
|
|
3105
3106
|
w.tick()
|
|
3106
3107
|
with jobstore.txn() as s:
|
|
3107
3108
|
cur = s.job(j, must_be=("running",))
|
|
3108
|
-
cur["stop"] = {"by": "user", "t": time.time(), "grace": 10.0} #
|
|
3109
|
+
cur["stop"] = {"by": "user", "t": time.time(), "grace": 10.0} # asked; no worker began sending it
|
|
3109
3110
|
s.save(cur)
|
|
3110
|
-
proc.signal_group(jobcore._group_handle(cur), signal.SIGKILL) #
|
|
3111
|
+
proc.signal_group(jobcore._group_handle(cur), signal.SIGKILL) # something else ends the run ...
|
|
3111
3112
|
journey.until(lambda: jobq.lock.Claim.holder(jobstore.run_claim("default", j)) is None, timeout=10,
|
|
3112
3113
|
what="the job's run to be over")
|
|
3113
3114
|
jobq.worker_for("default").carry_out() # ... and the next worker reaps it
|
|
@@ -3477,8 +3478,10 @@ class TestTheWorkersWait(QueueCase):
|
|
|
3477
3478
|
self.assertEqual(self.state(j), "running")
|
|
3478
3479
|
|
|
3479
3480
|
|
|
3480
|
-
# what has gone out for a stop, and whether pp was refused it: every state `_send` can leave one in
|
|
3481
|
-
|
|
3481
|
+
# what has gone out for a stop, and whether pp was refused it: every state `_send` can leave one in, with the
|
|
3482
|
+
# `going` the decision's transaction records before it and without it, as a record an older worker wrote is
|
|
3483
|
+
SENT = tuple(f"{going}{out}" for going in ("", "going-")
|
|
3484
|
+
for out in ("unsent", "sent", "sent-kill9", "denied", "sent-denied"))
|
|
3482
3485
|
# and the stop states a record can hold: who asked crossed with those, and no stop at all
|
|
3483
3486
|
STOPS = ("none",) + tuple(f"{by}-{out}" for by in ("kill", "timeout") for out in SENT)
|
|
3484
3487
|
|
|
@@ -3488,23 +3491,24 @@ class TestTheDecision(unittest.TestCase):
|
|
|
3488
3491
|
the one place that says what a second stop on it comes to. Both are pure, so this table needs no process,
|
|
3489
3492
|
no thread and no clock.
|
|
3490
3493
|
|
|
3491
|
-
Every combination of the facts is a row: the run's claim held or free, no exit code or one that is 0 or
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
(its first, and one far enough along that the delay would pass RETRY_DELAY_CAP), attempts
|
|
3495
|
-
the job's time up or not, a signal its child died of or none, and a --retry-if that matches or one
|
|
3496
|
-
cannot. Each row's expected action is written once below, against the rule `pp help queue` states it as,
|
|
3497
|
-
|
|
3498
|
-
|
|
3494
|
+
Every combination of the facts is a row: the run's claim held or free, no exit code or one that is 0 or not
|
|
3495
|
+
(written while the run was live, or after it), the twenty-one states a stop on the record can be in (who asked
|
|
3496
|
+
for it crossed with whether pp began sending it and what has gone out for it, and no stop), the grace up or not,
|
|
3497
|
+
the attempt number (its first, and one far enough along that the delay would pass RETRY_DELAY_CAP), attempts
|
|
3498
|
+
left or none, the job's time up or not, a signal its child died of or none, and a --retry-if that matches or one
|
|
3499
|
+
that cannot. Each row's expected action is written once below, against the rule `pp help queue` states it as, in
|
|
3500
|
+
the order that help states them; a row no rule holds for is a failure, so a new fact or a new action cannot go
|
|
3501
|
+
in without saying what it means everywhere.
|
|
3499
3502
|
|
|
3500
|
-
Each rule is written on the facts a stop carries, namely whether a kill asked for it, whether
|
|
3501
|
-
|
|
3502
|
-
of cannot slip past a rule that matched its name."""
|
|
3503
|
+
Each rule is written on the facts a stop carries, namely whether a kill asked for it, whether pp began
|
|
3504
|
+
sending it, what went out for it and whether pp was refused, and never on the name of a state, so a state
|
|
3505
|
+
nobody thought of cannot slip past a rule that matched its name."""
|
|
3503
3506
|
|
|
3504
3507
|
NOW = 2000.0
|
|
3505
3508
|
GRACE = 10.0
|
|
3506
3509
|
JOB_GRACE = 7.0 # the job's own, which a timeout's stop takes and no constant of pp's does
|
|
3507
3510
|
RC_AT = 1990.0 # when the code was written, which is when the job really ended
|
|
3511
|
+
GOING = 1960.0 # when pp began sending a stop that records it: before anything of it went out
|
|
3508
3512
|
DELAY = 20.0
|
|
3509
3513
|
|
|
3510
3514
|
def stop(self, kind: str, grace_up: bool) -> dict | None:
|
|
@@ -3517,6 +3521,8 @@ class TestTheDecision(unittest.TestCase):
|
|
|
3517
3521
|
st = {"by": "timeout" if by == "timeout" else "user", "t": self.NOW - 60, "grace": self.GRACE}
|
|
3518
3522
|
if by == "timeout":
|
|
3519
3523
|
st["timeout"] = True
|
|
3524
|
+
if "going" in went:
|
|
3525
|
+
st["going"] = self.GOING
|
|
3520
3526
|
if "sent" in went:
|
|
3521
3527
|
st["sent"] = self.NOW - (self.GRACE + 5 if grace_up else 1)
|
|
3522
3528
|
if "kill9" in went:
|
|
@@ -3531,12 +3537,14 @@ class TestTheDecision(unittest.TestCase):
|
|
|
3531
3537
|
whether a kill asked for it (and not a worker's own timeout), whether a signal of pp's went out, and
|
|
3532
3538
|
whether pp was refused the job's group."""
|
|
3533
3539
|
if kind == "none":
|
|
3534
|
-
return {"any": False, "asked": False, "
|
|
3540
|
+
return {"any": False, "asked": False, "going": False, "sent": False, "kill9": False,
|
|
3541
|
+
"denied": False, "out": False}
|
|
3535
3542
|
by, out = kind.split("-", 1)
|
|
3536
3543
|
went = out.split("-") # the field names, so "unsent" is not read as a sent one
|
|
3537
|
-
sent, kill9 = "sent" in went, "kill9" in went
|
|
3538
|
-
|
|
3539
|
-
|
|
3544
|
+
going, sent, kill9, denied = "going" in went, "sent" in went, "kill9" in went, "denied" in went
|
|
3545
|
+
# what went out, or what pp began to send and was not refused: a worker can die between the two
|
|
3546
|
+
return {"any": True, "asked": by != "timeout", "going": going, "sent": sent, "kill9": kill9,
|
|
3547
|
+
"denied": denied, "out": sent or kill9 or (going and not denied)}
|
|
3540
3548
|
|
|
3541
3549
|
# the first attempt, and one far enough along that doubling the delay per attempt would pass the cap
|
|
3542
3550
|
ATTEMPTS = (1, 20)
|
|
@@ -3571,9 +3579,10 @@ class TestTheDecision(unittest.TestCase):
|
|
|
3571
3579
|
|
|
3572
3580
|
# -- what each row's action is -------------------------------------------------------------------------
|
|
3573
3581
|
def went(self, r: dict):
|
|
3574
|
-
"""When
|
|
3582
|
+
"""When pp began sending this row's stop, or None where nothing has gone out for it: its `going`, and
|
|
3583
|
+
for a record without one the first signal's time."""
|
|
3575
3584
|
st = self.stop(r["stop"], r["grace_up"]) or {}
|
|
3576
|
-
return st.get("sent") or st.get("kill9")
|
|
3585
|
+
return (st.get("going") or st.get("sent") or st.get("kill9")) if r["out"] else None
|
|
3577
3586
|
|
|
3578
3587
|
def rc_at(self, r: dict):
|
|
3579
3588
|
"""When the attempt wrote its exit code: a second after the first thing went out for its stop, or a
|
|
@@ -3583,6 +3592,13 @@ class TestTheDecision(unittest.TestCase):
|
|
|
3583
3592
|
went = self.went(r)
|
|
3584
3593
|
return self.RC_AT if went is None else (went + 1 if r["late"] else went - 1)
|
|
3585
3594
|
|
|
3595
|
+
def term(self, r: dict) -> jobpolicy.Action:
|
|
3596
|
+
"""The SIGTERM of a stop nothing is marked sent for. The decision's transaction records `going` on it
|
|
3597
|
+
the first time, and keeps the first one after that, since a stop already begun is sent again."""
|
|
3598
|
+
if r["going"]:
|
|
3599
|
+
return jobpolicy.Action(jobpolicy.TERM)
|
|
3600
|
+
return jobpolicy.Action(jobpolicy.TERM, stop={**self.stop(r["stop"], r["grace_up"]), "going": self.NOW})
|
|
3601
|
+
|
|
3586
3602
|
def late(self, r: dict) -> bool:
|
|
3587
3603
|
"""Whether the code this attempt wrote came after its stop went out, which an attempt that wrote none
|
|
3588
3604
|
counts as, since there is no code of its own to stand instead."""
|
|
@@ -3638,7 +3654,7 @@ class TestTheDecision(unittest.TestCase):
|
|
|
3638
3654
|
lambda r: jobpolicy.Action(jobpolicy.NOTHING)),
|
|
3639
3655
|
("A stop nobody has signalled yet goes out as SIGTERM to the job's process group.",
|
|
3640
3656
|
lambda r: r["held"] and r["any"] and not r["sent"],
|
|
3641
|
-
|
|
3657
|
+
self.term),
|
|
3642
3658
|
("A stop whose grace is up is escalated to SIGKILL, once.",
|
|
3643
3659
|
lambda r: r["held"] and r["sent"] and not r["kill9"] and r["grace_up"],
|
|
3644
3660
|
lambda r: jobpolicy.Action(jobpolicy.KILL9)),
|
|
@@ -3647,27 +3663,28 @@ class TestTheDecision(unittest.TestCase):
|
|
|
3647
3663
|
lambda r: jobpolicy.Action(jobpolicy.NOTHING)),
|
|
3648
3664
|
("A job with no stop whose own timeout has run out gets a timeout's stop.",
|
|
3649
3665
|
lambda r: r["held"] and not r["any"] and r["due"],
|
|
3650
|
-
lambda r: jobpolicy.Action(jobpolicy.TIME_UP,
|
|
3666
|
+
lambda r: jobpolicy.Action(jobpolicy.TIME_UP, stop={"by": "timeout", "timeout": True, "t": self.NOW,
|
|
3667
|
+
"grace": self.JOB_GRACE})),
|
|
3651
3668
|
("A job with no stop whose time is not up is left as it is.",
|
|
3652
3669
|
lambda r: r["held"] and not r["any"],
|
|
3653
3670
|
lambda r: jobpolicy.Action(jobpolicy.NOTHING)),
|
|
3654
3671
|
# its run is over, so its exit code is the command's last word and this is where it ends
|
|
3655
|
-
('A job a kill\'s
|
|
3656
|
-
"wrote after that
|
|
3672
|
+
('A job still running when a kill\'s stop began going out is recorded "killed" and fires no hook; '
|
|
3673
|
+
"an exit code the attempt wrote after that is kept as the attempt's code.",
|
|
3657
3674
|
lambda r: not r["held"] and r["asked"] and r["out"] and self.late(r),
|
|
3658
3675
|
lambda r: self.ends(r["rc"], "killed", self.rc_at(r), self.sweep(r), None)),
|
|
3659
|
-
("A job that wrote its own exit code before a kill's
|
|
3660
|
-
"
|
|
3661
|
-
"
|
|
3676
|
+
("A job that wrote its own exit code before a kill's stop began going out, or whose kill pp never "
|
|
3677
|
+
"began sending, ends with its own outcome, the exit code it wrote or the signal it died of, and "
|
|
3678
|
+
"fires the hook that outcome owes.",
|
|
3662
3679
|
lambda r: not r["held"] and r["asked"],
|
|
3663
3680
|
lambda r: self.ends(r["rc"], self.own(r), self.rc_at(r), self.sweep(r),
|
|
3664
3681
|
"done" if r["rc"] == 0 else "failed")),
|
|
3665
3682
|
("An exit code of 0 is done, and on_done fires.",
|
|
3666
3683
|
lambda r: not r["held"] and r["rc"] == 0 and not self.late(r),
|
|
3667
3684
|
lambda r: self.ends(0, None, self.rc_at(r), self.sweep(r), "done")),
|
|
3668
|
-
("A timeout's stop pp
|
|
3669
|
-
"the attempt wrote after that stop
|
|
3670
|
-
"timeout on_fail fires.",
|
|
3685
|
+
("A timeout's stop pp began sending or was refused is a timeout, which may be retried; an exit "
|
|
3686
|
+
"code the attempt wrote after that stop began going out is kept as the attempt's code, and on a "
|
|
3687
|
+
"final timeout on_fail fires.",
|
|
3671
3688
|
lambda r: not r["held"] and self.late(r) and (r["out"] or r["denied"]),
|
|
3672
3689
|
lambda r: self.failure(r, r["rc"], "timeout", self.rc_at(r), self.sweep(r))),
|
|
3673
3690
|
("Anything else is the job's own failure, the exit code it wrote or the signal it died of, "
|
|
@@ -3688,7 +3705,7 @@ class TestTheDecision(unittest.TestCase):
|
|
|
3688
3705
|
seen.add(what)
|
|
3689
3706
|
job, facts = self.case(r)
|
|
3690
3707
|
self.assertEqual(jobpolicy.decide(job, facts), then(r), f"{what}: {r}")
|
|
3691
|
-
self.assertEqual(n,
|
|
3708
|
+
self.assertEqual(n, 16128) # 2*3*21*2*2*2*2*2*2*2 rows
|
|
3692
3709
|
self.assertEqual(seen, {what for what, _w, _t in rules}) # and every rule is a row of its own
|
|
3693
3710
|
# both sides of the delay's cap are a row here, so neither arm can go quiet under a later constant
|
|
3694
3711
|
self.assertGreater(self.DELAY * 2 ** (max(self.ATTEMPTS) - 1), jobpolicy.RETRY_DELAY_CAP)
|
|
@@ -3780,10 +3797,13 @@ class TestTheDecision(unittest.TestCase):
|
|
|
3780
3797
|
|
|
3781
3798
|
def test_a_second_stop_merges_into_the_one_on_the_record(self):
|
|
3782
3799
|
"""One stop per run of a job: what went out is all the decision and the waiting verb have to go on, so
|
|
3783
|
-
a second ask keeps `t`, `sent` and `kill9` and only shortens the grace. A kill's ask on a
|
|
3784
|
-
stop is a kill, so the job is ended as one and never retried.
|
|
3785
|
-
|
|
3786
|
-
|
|
3800
|
+
a second ask keeps `t`, `going`, `sent` and `kill9` and only shortens the grace. A kill's ask on a
|
|
3801
|
+
timeout's stop is a kill, so the job is ended as one and never retried. A refused stop is asked for
|
|
3802
|
+
again, and where nothing of it went out it begins going out again, so its `going` goes too."""
|
|
3803
|
+
tout = {"by": "timeout", "timeout": True, "t": 100.0, "grace": 300.0, "going": 100.5, "sent": 101.0,
|
|
3804
|
+
"kill9": 102.0}
|
|
3805
|
+
out = {"by": "ann", "t": 100.0, "grace": 300.0, "going": 100.5, "sent": 101.0, "kill9": 102.0}
|
|
3806
|
+
refused = {"by": "ann", "t": 100.0, "grace": 300.0, "going": 100.5, "denied": 101.0}
|
|
3787
3807
|
kill = {"by": "ann", "t": 500.0, "grace": 10.0}
|
|
3788
3808
|
tmo = {"by": "timeout", "timeout": True, "t": 500.0, "grace": 10.0}
|
|
3789
3809
|
rows = [
|
|
@@ -3794,6 +3814,8 @@ class TestTheDecision(unittest.TestCase):
|
|
|
3794
3814
|
{**out, "was": "timeout", "grace": 10.0}),
|
|
3795
3815
|
("a timeout on a kill's stop changes nothing but the grace", out, tmo, {**out, "grace": 10.0}),
|
|
3796
3816
|
("a refused stop is asked for again", {**out, "denied": 103.0}, kill, {**out, "grace": 10.0}),
|
|
3817
|
+
("a stop refused before anything went out begins going out again", refused, kill,
|
|
3818
|
+
{"by": "ann", "t": 100.0, "grace": 10.0}),
|
|
3797
3819
|
("a timeout on a timeout's stop is still the timeout's", tout, tmo, {**tout, "grace": 10.0}),
|
|
3798
3820
|
("an asker called timeout asks as a kill, and the stop stops being a timeout's", tout,
|
|
3799
3821
|
{"by": "timeout", "t": 500.0, "grace": 10.0},
|
|
@@ -4998,46 +5020,6 @@ class TestWatchEvents(unittest.TestCase):
|
|
|
4998
5020
|
self.assertIsNone(self.ev("queued", "queued"))
|
|
4999
5021
|
|
|
5000
5022
|
|
|
5001
|
-
# ---- one test per mechanism invariant, by fault injection -------------------------------------------
|
|
5002
|
-
#
|
|
5003
|
-
# What follows is one test per invariant the mechanism keeps, each by a fault at a named step: a worker that
|
|
5004
|
-
# ends where a SIGKILL would end it, two processes that act at once, a record naming a process id another
|
|
5005
|
-
# process holds, a reader of the store beside a writer of it. The processes the tests need come first, each a
|
|
5006
|
-
# program of a few lines, then the scope they share (InjectedCase), then the tests.
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
# ---- one test per mechanism invariant, by fault injection -------------------------------------------
|
|
5010
|
-
#
|
|
5011
|
-
# What follows is one test per invariant the mechanism keeps, each by a fault at a named step: a worker that
|
|
5012
|
-
# ends where a SIGKILL would end it, two processes that act at once, a record naming a process id another
|
|
5013
|
-
# process holds, a reader of the store beside a writer of it. The processes the tests need come first, each a
|
|
5014
|
-
# program of a few lines, then the scope they share (InjectedCase), then the tests.
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
# ---- one test per mechanism invariant, by fault injection -------------------------------------------
|
|
5018
|
-
#
|
|
5019
|
-
# What follows is one test per invariant the mechanism keeps, each by a fault at a named step: a worker that
|
|
5020
|
-
# ends where a SIGKILL would end it, two processes that act at once, a record naming a process id another
|
|
5021
|
-
# process holds, a reader of the store beside a writer of it. The processes the tests need come first, each a
|
|
5022
|
-
# program of a few lines, then the scope they share (InjectedCase), then the tests.
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
# ---- one test per mechanism invariant, by fault injection -------------------------------------------
|
|
5026
|
-
#
|
|
5027
|
-
# What follows is one test per invariant the mechanism keeps, each by a fault at a named step: a worker that
|
|
5028
|
-
# ends where a SIGKILL would end it, two processes that act at once, a record naming a process id another
|
|
5029
|
-
# process holds, a reader of the store beside a writer of it. The processes the tests need come first, each a
|
|
5030
|
-
# program of a few lines, then the scope they share (InjectedCase), then the tests.
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
# ---- one test per mechanism invariant, by fault injection -------------------------------------------
|
|
5034
|
-
#
|
|
5035
|
-
# What follows is one test per invariant the mechanism keeps, each by a fault at a named step: a worker that
|
|
5036
|
-
# ends where a SIGKILL would end it, two processes that act at once, a record naming a process id another
|
|
5037
|
-
# process holds, a reader of the store beside a writer of it. The processes the tests need come first, each a
|
|
5038
|
-
# program of a few lines, then the scope they share (InjectedCase), then the tests.
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
5023
|
# ---- one test per mechanism invariant, by fault injection -------------------------------------------
|
|
5042
5024
|
#
|
|
5043
5025
|
# What follows is one test per invariant the mechanism keeps, each by a fault at a named step: a worker that
|
|
@@ -5409,11 +5391,13 @@ class TestAWorkerKilledWhileItStops(InjectedCase):
|
|
|
5409
5391
|
stop = jobstore.find(j)["stop"]
|
|
5410
5392
|
self.assertEqual((stop["t"], stop.get("sent"), stop.get("kill9"), stop.get("denied")),
|
|
5411
5393
|
(asked["t"], None, None, None))
|
|
5394
|
+
self.assertIsNotNone(stop["going"]) # the decision's transaction recorded it before the send
|
|
5412
5395
|
self.assertEqual(self.state(j), "running")
|
|
5413
5396
|
went = self.recording()
|
|
5414
5397
|
w.carry_out()
|
|
5415
5398
|
self.assertEqual(went, [signal.SIGTERM])
|
|
5416
5399
|
self.assertIsNotNone(jobstore.find(j)["stop"]["sent"])
|
|
5400
|
+
self.assertEqual(jobstore.find(j)["stop"]["going"], stop["going"]) # the time it first began
|
|
5417
5401
|
for _ in range(3): # inside the grace it was given, so nothing more goes out for it
|
|
5418
5402
|
w.carry_out()
|
|
5419
5403
|
self.assertEqual(went, [signal.SIGTERM])
|
|
@@ -5421,7 +5405,8 @@ class TestAWorkerKilledWhileItStops(InjectedCase):
|
|
|
5421
5405
|
def test_a_signal_that_went_out_is_recorded_before_anything_else_goes_out(self):
|
|
5422
5406
|
"""What went out for a stop is recorded after it went out, so a record that says a signal went out is
|
|
5423
5407
|
a record of one that did. A worker that ends between the signal and the record leaves a stop with
|
|
5424
|
-
nothing marked on it
|
|
5408
|
+
nothing marked sent on it. The `going` the decision recorded before the signal is still on it, which is
|
|
5409
|
+
what keeps the next decision right about that stop (the two tests after this one)."""
|
|
5425
5410
|
j, w = self.a_job_whose_run_is_live()
|
|
5426
5411
|
asked = self.a_kill_asks(j, grace=10.0)
|
|
5427
5412
|
self.assertEqual(self.a_worker_killed_at("the signal"), [f"sent {int(signal.SIGTERM)}"])
|
|
@@ -5487,10 +5472,11 @@ class TestAWorkerKilledWhileItStops(InjectedCase):
|
|
|
5487
5472
|
self.assertNotIn("cmd", group)
|
|
5488
5473
|
|
|
5489
5474
|
def test_a_code_written_while_the_stop_goes_out_is_that_stops_outcome(self):
|
|
5490
|
-
"""A stop decides the reason, and what an exit code is late against is the time that stop
|
|
5491
|
-
That time is
|
|
5492
|
-
is starting belongs to the stop: a container runner writing the code of the
|
|
5493
|
-
ended (137) inside it is that stop's outcome, with the code kept, and not the
|
|
5475
|
+
"""A stop decides the reason, and what an exit code is late against is the time that stop began going
|
|
5476
|
+
out, its `going`. That time is recorded before anything goes out for it, so the window in which the
|
|
5477
|
+
project's stop command is starting belongs to the stop: a container runner writing the code of the
|
|
5478
|
+
container that command ended (137) inside it is that stop's outcome, with the code kept, and not the
|
|
5479
|
+
job's own failure.
|
|
5494
5480
|
|
|
5495
5481
|
The window is driven by a named step, the start of the project's stop command, so the test says
|
|
5496
5482
|
which window it is about instead of hoping a sleep lands in it."""
|
|
@@ -5520,6 +5506,7 @@ class TestAWorkerKilledWhileItStops(InjectedCase):
|
|
|
5520
5506
|
stop = jobstore.find(j)["stop"]
|
|
5521
5507
|
self.assertEqual(len(wrote), 1)
|
|
5522
5508
|
self.assertIsNotNone(stop["sent"])
|
|
5509
|
+
self.assertLessEqual(stop["going"], wrote[0]) # recorded before the stop went out
|
|
5523
5510
|
self.assertLessEqual(stop["sent"], wrote[0]) # read before the stop went out, recorded once it had
|
|
5524
5511
|
# the run ends with no code of its own, so the code written inside the window is the one that is read
|
|
5525
5512
|
proc.signal_group(jobcore._group_handle(jobstore.find(j)), signal.SIGKILL)
|
|
@@ -5529,6 +5516,79 @@ class TestAWorkerKilledWhileItStops(InjectedCase):
|
|
|
5529
5516
|
self.assertEqual((rec["state"], rec["reason"], rec["rc"]), ("failed", "killed", 137))
|
|
5530
5517
|
self.assertIsNone(rec.get("history"), "a job a kill has touched was retried")
|
|
5531
5518
|
|
|
5519
|
+
def test_a_kill_whose_worker_died_before_marking_its_signal_is_still_a_kill(self):
|
|
5520
|
+
"""A worker that ends between a kill's SIGTERM and the record of it leaves a stop with nothing marked
|
|
5521
|
+
sent, and the signal ended the job. The `going` the decision recorded before the signal is on that
|
|
5522
|
+
stop, so the next worker ends the job as the kill's: "killed", and no hook (A11), rather than as the
|
|
5523
|
+
signal the job died of with its on_fail."""
|
|
5524
|
+
f = self.hook_file()
|
|
5525
|
+
j, w = self.a_job_whose_run_is_live("--on-fail", f"echo x >> {f}")
|
|
5526
|
+
self.a_kill_asks(j, grace=10.0)
|
|
5527
|
+
self.assertEqual(self.a_worker_killed_at("the signal of the group"), [f"sent {int(signal.SIGTERM)} alive"])
|
|
5528
|
+
stop = jobstore.find(j)["stop"]
|
|
5529
|
+
self.assertEqual((stop.get("sent"), stop.get("going") is not None), (None, True))
|
|
5530
|
+
self.assertIn("going", jobq.public.__doc__) # it is named where the stop's fields are, and is there
|
|
5531
|
+
self.assertEqual(json.loads(self.call("show", str(j), "--json")[1])["stop"]["going"], stop["going"])
|
|
5532
|
+
self.the_run_is_over(j)
|
|
5533
|
+
w.carry_out()
|
|
5534
|
+
rec = jobstore.find(j)
|
|
5535
|
+
self.assertEqual((rec["state"], rec["reason"], rec.get("rc")), ("failed", "killed", None))
|
|
5536
|
+
self.assertNotIn("hook_due", rec)
|
|
5537
|
+
self.assertEqual(rec.get("hooks") or [], [], "a kill fired the job's hook")
|
|
5538
|
+
|
|
5539
|
+
def test_a_kill_whose_worker_died_as_its_stop_command_started_is_still_a_kill(self):
|
|
5540
|
+
"""The same window where the project's stop command is what goes out: the worker ends once that command
|
|
5541
|
+
has started, the command ends the work, and the runner writes the code the work got (137). That code
|
|
5542
|
+
came after the stop began going out, so it is the kill's outcome with the code kept, and the job's
|
|
5543
|
+
on_fail does not fire."""
|
|
5544
|
+
f, stopped = self.hook_file(), self.gate("stopped")
|
|
5545
|
+
j, w = self.a_job_whose_run_is_live("--on-fail", f"echo x >> {f}",
|
|
5546
|
+
cmd=f"while [ ! -f {stopped} ]; do sleep 0.02; done; exit 137")
|
|
5547
|
+
with jobstore.txn() as s:
|
|
5548
|
+
cur = s.job(j, must_be=("running",))
|
|
5549
|
+
cur["stop_cmd"] = f"touch {stopped}"
|
|
5550
|
+
s.save(cur)
|
|
5551
|
+
self.a_kill_asks(j, grace=10.0)
|
|
5552
|
+
self.a_worker_killed_at("the start") # the stop command started, and the worker ended
|
|
5553
|
+
stop = jobstore.find(j)["stop"]
|
|
5554
|
+
self.assertEqual((stop.get("sent"), stop.get("going") is not None), (None, True))
|
|
5555
|
+
self.the_run_is_over(j)
|
|
5556
|
+
w.carry_out()
|
|
5557
|
+
rec = jobstore.find(j)
|
|
5558
|
+
self.assertEqual((rec["state"], rec["reason"], rec["rc"]), ("failed", "killed", 137))
|
|
5559
|
+
self.assertNotIn("hook_due", rec)
|
|
5560
|
+
self.assertEqual(rec.get("hooks") or [], [], "a kill fired the job's hook")
|
|
5561
|
+
|
|
5562
|
+
def test_a_code_written_before_the_decision_is_the_jobs_own(self):
|
|
5563
|
+
"""The time a stop began going out is read inside the decision's transaction, never with the facts
|
|
5564
|
+
before it. A job that ends on its own after its facts were read and before that transaction is sent
|
|
5565
|
+
its stop on facts that say it runs. It still ends with its own outcome and fires the hook that outcome
|
|
5566
|
+
owes, since its code was written before the stop's `going`.
|
|
5567
|
+
|
|
5568
|
+
The window is driven by a named step, the reading of the facts, so the test says which window it is
|
|
5569
|
+
about instead of hoping a sleep lands in it."""
|
|
5570
|
+
f = self.hook_file()
|
|
5571
|
+
j, w = self.a_job_whose_run_is_live("--on-done", f"echo x >> {f}")
|
|
5572
|
+
real = jobcore.Worker._facts
|
|
5573
|
+
|
|
5574
|
+
def the_job_ends_once_its_facts_are_read(wk, job):
|
|
5575
|
+
facts = real(wk, job)
|
|
5576
|
+
self.assertTrue(facts.held)
|
|
5577
|
+
self.open_the_gate()
|
|
5578
|
+
self.the_run_is_over(j)
|
|
5579
|
+
return facts
|
|
5580
|
+
self.a_kill_asks(j, grace=10.0)
|
|
5581
|
+
with mock.patch.object(jobcore.Worker, "_facts", the_job_ends_once_its_facts_are_read):
|
|
5582
|
+
w.carry_out()
|
|
5583
|
+
rec = jobstore.find(j)
|
|
5584
|
+
at = jobcore.ended_on_its_own(rec)[1]
|
|
5585
|
+
self.assertEqual(rec["state"], "running")
|
|
5586
|
+
self.assertLess(at, rec["stop"]["going"], "the stop began going out before the job's own end")
|
|
5587
|
+
w.carry_out()
|
|
5588
|
+
rec = jobstore.find(j)
|
|
5589
|
+
self.assertEqual((rec["state"], rec.get("reason"), rec["rc"]), ("done", None, 0))
|
|
5590
|
+
self.assertEqual([h["event"] for h in rec.get("hooks") or []], ["done"])
|
|
5591
|
+
|
|
5532
5592
|
def test_a_grace_that_is_up_escalates_once_and_only_once(self):
|
|
5533
5593
|
"""The SIGKILL of a stop whose grace is up goes out once. A worker that ends between the grace being
|
|
5534
5594
|
up and that signal leaves a stop with no SIGKILL marked, the next worker sends it, and no pass after
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|