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.
Files changed (54) hide show
  1. {ppkit-0.2.1 → ppkit-0.2.2}/CHANGELOG.md +24 -0
  2. {ppkit-0.2.1 → ppkit-0.2.2}/CONTRIBUTING.md +10 -6
  3. {ppkit-0.2.1 → ppkit-0.2.2}/PKG-INFO +1 -1
  4. {ppkit-0.2.1 → ppkit-0.2.2}/pp/__init__.py +1 -1
  5. {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/queue.py +16 -14
  6. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/jobcore.py +14 -13
  7. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/jobpolicy.py +47 -29
  8. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/jobq.py +18 -12
  9. {ppkit-0.2.1 → ppkit-0.2.2}/pyproject.toml +1 -1
  10. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_queue.py +148 -88
  11. {ppkit-0.2.1 → ppkit-0.2.2}/.pp.toml +0 -0
  12. {ppkit-0.2.1 → ppkit-0.2.2}/LICENSE +0 -0
  13. {ppkit-0.2.1 → ppkit-0.2.2}/README.md +0 -0
  14. {ppkit-0.2.1 → ppkit-0.2.2}/bin/pp +0 -0
  15. {ppkit-0.2.1 → ppkit-0.2.2}/ci/check +0 -0
  16. {ppkit-0.2.1 → ppkit-0.2.2}/ci/package +0 -0
  17. {ppkit-0.2.1 → ppkit-0.2.2}/pp/__main__.py +0 -0
  18. {ppkit-0.2.1 → ppkit-0.2.2}/pp/cli.py +0 -0
  19. {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/__init__.py +0 -0
  20. {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/batch.py +0 -0
  21. {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/cmd.py +0 -0
  22. {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/completion.py +0 -0
  23. {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/http.py +0 -0
  24. {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/kill.py +0 -0
  25. {ppkit-0.2.1 → ppkit-0.2.2}/pp/commands/promote.py +0 -0
  26. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/__init__.py +0 -0
  27. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/config.py +0 -0
  28. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/jobstore.py +0 -0
  29. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/lock.py +0 -0
  30. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/paths.py +0 -0
  31. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/proc.py +0 -0
  32. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/session.py +0 -0
  33. {ppkit-0.2.1 → ppkit-0.2.2}/pp/lib/ui.py +0 -0
  34. {ppkit-0.2.1 → ppkit-0.2.2}/tests/journey.py +0 -0
  35. {ppkit-0.2.1 → ppkit-0.2.2}/tests/sandbox.py +0 -0
  36. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_batch.py +0 -0
  37. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_cli.py +0 -0
  38. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_cmd.py +0 -0
  39. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_constructs.py +0 -0
  40. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_http.py +0 -0
  41. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_batch.py +0 -0
  42. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_cli.py +0 -0
  43. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_cmd.py +0 -0
  44. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_http.py +0 -0
  45. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_kill.py +0 -0
  46. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_promote.py +0 -0
  47. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_journey_queue.py +0 -0
  48. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_kill.py +0 -0
  49. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_lock.py +0 -0
  50. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_paths.py +0 -0
  51. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_proc.py +0 -0
  52. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_promote.py +0 -0
  53. {ppkit-0.2.1 → ppkit-0.2.2}/tests/test_session.py +0 -0
  54. {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 -> the project's stop command,
355
- or the SIGTERM to the group, then txn marks `sent`
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
- Two more are for contributors: which of the two a stop is comes from the
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; and an exit code the attempt wrote after its stop went
373
- out is that stop's outcome, with the code kept, since a container runner writes
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ppkit
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: A CLI kit for user & agents
5
5
  Keywords: cli,job queue,gpu,agents,toolkit
6
6
  Author: The pp Authors
@@ -1,4 +1,4 @@
1
1
  """pp: a CLI kit for user & agents. One file per subcommand, discovered at runtime."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
4
4
  TAGLINE = "a CLI kit for user & agents" # pp -h's header; README and pyproject say it too (tests/test_cli.py)
@@ -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 signal stopped is recorded "killed" and fires no hook; an exit code the attempt wrote after that
260
- signal went out is kept as the attempt's code.
261
- A job that wrote its own exit code before a kill's signal went out, or that a kill wrote a stop for that no signal
262
- of pp's ever reached, ends with its own outcome, the exit code it wrote or the signal it died of, and fires the
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 signalled or was refused is a timeout, which may be retried; an exit code the attempt wrote
266
- after that stop went out is kept as the attempt's code, and on a final timeout on_fail fires.
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. Where a job's work is reached
273
- by the project's stop command, that command goes out in place of the SIGTERM, and `stop.rc: null` means pp has not
274
- seen it end, not that it worked. Anything left in the job's group when its run is over goes with it, and a run that
275
- ended with no exit code of its own, or that a stop was asked of, runs that stop command once more, so nothing the
276
- runner started outlives its attempt. Nobody asked for that last run, so its output goes where the worker's own lines
277
- go (its log, or the output of a `pp queue work`), and the job's log holds the job and the stops asked of it.
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, now)))
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, now: float) -> policy.Facts:
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, now=now)
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, now=now,
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.do == policy.TIME_UP:
265
- cur["stop"] = {"by": "timeout", "timeout": True, "t": a.t, "grace": a.grace}
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, which is all the decision about this job has to go on. The time recorded with it is read before
296
- it goes out, because that time is what an exit code is late against (I10): a code the attempt wrote
297
- while this stop was on its way out is that stop's outcome, and a time read afterwards would leave the
298
- window the stop command takes to start reading as the job's own end.
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 facts were read at, which is the only clock the decision has.
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 t, grace write {by: timeout, timeout: True, t, grace} on it
76
- term SIGTERM to its group
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
- hook is the event whose hook is owed ("failed", "done"), or None where the outcome owes none or the job
82
- carries no such hook. sweep says anything left in the job's process group goes with it. history is the
83
- entry a requeue writes for the attempt that ended, and clear the keys it takes off the record, so the
84
- store writes what this says and works nothing out again.
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
- t: float | None = None
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, the time those were read at, and whether
130
- --retry-if matched the tail of its log.
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 = bool(stop.get("sent")) or bool(stop.get("kill9")) # a signal of pp's went out for this stop
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=facts.now, grace=grace(job)) if due else Action(NOTHING)
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
- return Action(TERM)
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 signal stopped it: "killed", and no hook. A code the attempt wrote after that signal went
219
- # out is that stop's outcome, and the code is kept
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 went out, or no signal of pp's ever went out for that 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 the first thing went out for this stop.
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 nothing has gone out for has no time to
240
- compare against, so no code is ever late for one.
241
-
242
- The time to compare against is read before the stop goes out and recorded once it has (`Worker._send`),
243
- so the window in which a stop command is starting belongs to that stop and not to the job."""
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 second ask
323
- hastens the SIGKILL and never delays it. It clears `denied`, so a signal pp was refused is asked for again.
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, sent,
997
- kill9, denied, was, cmd, rc} on a job asked to stop, after [id] on a job that names the jobs it waits
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. `sent` and `kill9` are when each signal went
1007
- out, read just before it did, so an exit code the attempt wrote while that stop was going out is late
1008
- against it and is that stop's outcome. `denied` is when pp was refused the job's process group. A stop with `denied` set is not in
1009
- flight: nothing more goes out for it, and the job runs on until it ends on its own. `was` is who asked
1010
- for the stop this one landed on, where a kill landed on a timeout's."""
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") else ", nothing signalled")
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 "no signal of pp's has gone out")
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 not stop.get("sent"):
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.1" # the same as pp/__init__.py's __version__; tests/test_cli.py checks
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 test_a_stop_whose_worker_died_before_it_recorded_the_signal_is_not_retried(self):
3097
- """A worker killed between signalling a job's group and recording that it did leaves a stop with
3098
- nothing marked on it and a run that is over. The next worker ends the job there: a stop is never
3099
- retried, however little of it was recorded.
3100
-
3101
- What the record says went out is all the decision has, and it says nothing did, so the job is
3102
- recorded as its own run ended rather than as a kill pp carried out."""
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} # written; nothing recorded yet
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) # the signal went out ...
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
- SENT = ("unsent", "sent", "sent-kill9", "denied", "sent-denied")
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
- not (written while the run was live, or after it), the eleven states a stop on the record can be in (who
3493
- asked for it crossed with what has gone out for it, and no stop), the grace up or not, the attempt number
3494
- (its first, and one far enough along that the delay would pass RETRY_DELAY_CAP), attempts left or none,
3495
- the job's time up or not, a signal its child died of or none, and a --retry-if that matches or one that
3496
- cannot. Each row's expected action is written once below, against the rule `pp help queue` states it as,
3497
- in the order that help states them; a row no rule holds for is a failure, so a new fact or a new action
3498
- cannot go in without saying what it means everywhere.
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 a signal
3501
- of pp's went out and whether pp was refused, and never on the name of a state, so a state nobody thought
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, "sent": False, "kill9": False, "denied": False, "out": 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
- return {"any": True, "asked": by != "timeout", "sent": sent, "kill9": kill9,
3539
- "denied": "denied" in went, "out": sent or kill9}
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 the first thing went out for this row's stop, or None where nothing has."""
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
- lambda r: jobpolicy.Action(jobpolicy.TERM)),
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, t=self.NOW, grace=self.JOB_GRACE)),
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 signal stopped is recorded "killed" and fires no hook; an exit code the attempt '
3656
- "wrote after that signal went out is kept as the attempt's code.",
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 signal went out, or that a kill wrote a stop "
3660
- "for that no signal of pp's ever reached, ends with its own outcome, the exit code it wrote or "
3661
- "the signal it died of, and fires the hook that outcome owes.",
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 signalled or was refused is a timeout, which may be retried; an exit code "
3669
- "the attempt wrote after that stop went out is kept as the attempt's code, and on a final "
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, 8448) # 2*3*11*2*2*2*2*2*2*2 rows
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 timeout's
3784
- stop is a kill, so the job is ended as one and never retried."""
3785
- tout = {"by": "timeout", "timeout": True, "t": 100.0, "grace": 300.0, "sent": 101.0, "kill9": 102.0}
3786
- out = {"by": "ann", "t": 100.0, "grace": 300.0, "sent": 101.0, "kill9": 102.0}
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, which is the one side of that order a decision can be wrong on."""
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 went out.
5491
- That time is read before anything goes out for it, so the window in which the project's stop command
5492
- is starting belongs to the stop: a container runner writing the code of the container that command
5493
- ended (137) inside it is that stop's outcome, with the code kept, and not the job's own failure.
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