nnnotes 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/orchestrate.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""The local orchestrator: the stages of a pipeline over their subjects, with a pool of worker processes.
|
|
2
|
+
|
|
3
|
+
Stages run in the order given; a stage's tasks are described once the stages before it are done (Env: the facts of
|
|
4
|
+
the caller plus the keys of the tasks already done), so every input is known when a task is described. Per stage:
|
|
5
|
+
a task whose result is in the store is a hit; the others run in parallel, then the next stage starts.
|
|
6
|
+
|
|
7
|
+
The pool: up to `workers` spawned processes (no more than a stage has tasks to run), long-lived (each imports
|
|
8
|
+
what its stages need once), one task at a time each. Tasks start in order of decreasing estimated CPU (longest
|
|
9
|
+
first), a task only when the estimated peak memory of the running tasks plus its own fits `memory` less
|
|
10
|
+
`worker_bytes` per worker (what the processes hold before any task); a task estimated above that budget runs alone
|
|
11
|
+
and is reported. A worker is replaced after `recycle_tasks` tasks, or when its resident memory after a task is
|
|
12
|
+
above `recycle_rss`; the workers are stopped together at the end. `workers=0` runs the tasks in this process, one
|
|
13
|
+
after another.
|
|
14
|
+
|
|
15
|
+
Failures: a failed object is an item of its task's result, cached with it. An exception out of a stage, an input
|
|
16
|
+
that cannot be read, or a worker that dies fails the task: nothing of it is cached, the run records it
|
|
17
|
+
(failures(), the run log) and goes on; a task that reads the result of a failed task fails as `upstream.failed`.
|
|
18
|
+
A ConfigError (a setting or tool the run needs) stops the run: no new task starts, and run() raises it once the
|
|
19
|
+
running tasks have ended (their results stay valid).
|
|
20
|
+
Stage results do not depend on the number of workers, their order or their timing: a run with one worker and a run
|
|
21
|
+
with many leave identical cas/ and ac/ trees and the same run manifest.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import multiprocessing as mp
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import traceback
|
|
30
|
+
from collections import Counter
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from multiprocessing.connection import wait
|
|
33
|
+
|
|
34
|
+
from . import contract
|
|
35
|
+
from .config import ConfigError
|
|
36
|
+
from .contract import Task
|
|
37
|
+
from .stages import Env, Pending, Stage, describe, execute
|
|
38
|
+
|
|
39
|
+
WORKER_BYTES = 70 << 20 # resident memory of an idle worker process (interpreter and imports), per worker
|
|
40
|
+
# failure codes of tasks (failed items carry the codes of their stages)
|
|
41
|
+
FAILURE_CODES = ("task.error", "task.input", "task.io", "task.memory", "task.conflict", "task.incompatible",
|
|
42
|
+
"task.worker_died", "task.describe", "upstream.failed", "upstream.missing", "config")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _log(m: str) -> None:
|
|
46
|
+
print(f"[{time.strftime('%H:%M:%S')}] {m}", file=sys.stderr, flush=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------- process memory
|
|
50
|
+
def rss() -> int | None:
|
|
51
|
+
"""This process's resident memory in bytes (None where the platform does not tell)."""
|
|
52
|
+
try:
|
|
53
|
+
with open("/proc/self/statm") as f:
|
|
54
|
+
return int(f.read().split()[1]) * os.sysconf("SC_PAGE_SIZE")
|
|
55
|
+
except (OSError, ValueError, AttributeError):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def reset_peak() -> None:
|
|
60
|
+
"""Restart the peak-memory counter of this process (Linux; elsewhere nothing)."""
|
|
61
|
+
try:
|
|
62
|
+
with open("/proc/self/clear_refs", "w") as f:
|
|
63
|
+
f.write("5")
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def peak_rss() -> int | None:
|
|
69
|
+
"""The peak resident memory of this process since reset_peak (Linux), or None."""
|
|
70
|
+
try:
|
|
71
|
+
with open("/proc/self/status") as f:
|
|
72
|
+
for line in f:
|
|
73
|
+
if line.startswith("VmHWM:"):
|
|
74
|
+
return int(line.split()[1]) * 1024
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
pass
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------- one task, here or in a worker
|
|
81
|
+
def failure(task: str, stage: str, code: str, message: str, inp: str | None = None, obj: str | None = None) -> dict:
|
|
82
|
+
"""A failures/1 entry."""
|
|
83
|
+
return {"task": task, "stage": stage, "input": inp, "object": obj, "code": code, "message": message}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _error_code(e: BaseException) -> str:
|
|
87
|
+
from .contract import IncompatibleTask
|
|
88
|
+
from .store import InputMissing, StoreConflict
|
|
89
|
+
if isinstance(e, IncompatibleTask):
|
|
90
|
+
return "task.incompatible"
|
|
91
|
+
if isinstance(e, InputMissing):
|
|
92
|
+
return "task.input"
|
|
93
|
+
if isinstance(e, StoreConflict):
|
|
94
|
+
return "task.conflict"
|
|
95
|
+
if isinstance(e, MemoryError):
|
|
96
|
+
return "task.memory"
|
|
97
|
+
if isinstance(e, OSError):
|
|
98
|
+
return "task.io"
|
|
99
|
+
return "task.error"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run_one(task: Task, store, stages: dict, force: bool) -> dict:
|
|
103
|
+
"""Execute one task and measure it: the reply a worker sends (status, result status, cost, or error). The
|
|
104
|
+
cost's peak memory is the process's, with the resident memory at the task's start (what the process held from
|
|
105
|
+
its imports and earlier tasks) beside it."""
|
|
106
|
+
start = rss()
|
|
107
|
+
reset_peak()
|
|
108
|
+
try:
|
|
109
|
+
ex = execute(task, store, stages, force=force)
|
|
110
|
+
except ConfigError as e:
|
|
111
|
+
return {"ok": False, "code": "config", "message": str(e), "trace": "", "rss": rss()}
|
|
112
|
+
except Exception as e:
|
|
113
|
+
return {"ok": False, "code": _error_code(e), "message": f"{type(e).__name__}: {e}"[:2000],
|
|
114
|
+
"trace": "".join(traceback.format_exception(type(e), e, e.__traceback__))[-4000:], "rss": rss()}
|
|
115
|
+
reply = {"ok": True, "status": ex.status, "resultStatus": ex.result_status, "rss": rss()}
|
|
116
|
+
if ex.cost is not None:
|
|
117
|
+
cost = dict(ex.cost, peakRssBytes=peak_rss(), startRssBytes=start)
|
|
118
|
+
store.write_cost(task.key, cost)
|
|
119
|
+
reply["cost"] = cost
|
|
120
|
+
return reply
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _worker_main(conn, store, stages: dict, initializer, initargs) -> None:
|
|
124
|
+
if initializer is not None:
|
|
125
|
+
initializer(store, *initargs)
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
msg = conn.recv()
|
|
129
|
+
except EOFError:
|
|
130
|
+
return
|
|
131
|
+
if msg is None:
|
|
132
|
+
return
|
|
133
|
+
doc, force = msg
|
|
134
|
+
try:
|
|
135
|
+
task = Task.from_json(doc)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
conn.send({"ok": False, "code": "task.incompatible", "message": str(e), "trace": "", "rss": rss()})
|
|
138
|
+
continue
|
|
139
|
+
conn.send(run_one(task, store, stages, force))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _Worker:
|
|
143
|
+
def __init__(self, ctx, n: int, store, stages, initializer, initargs):
|
|
144
|
+
self.n = n
|
|
145
|
+
self.conn, child = ctx.Pipe()
|
|
146
|
+
self.proc = ctx.Process(target=_worker_main, args=(child, store, stages, initializer, initargs),
|
|
147
|
+
name=f"nnnotes-worker-{n}", daemon=True)
|
|
148
|
+
self.proc.start()
|
|
149
|
+
child.close()
|
|
150
|
+
self.task: Task | None = None
|
|
151
|
+
self.done = 0
|
|
152
|
+
|
|
153
|
+
def send(self, task: Task, force: bool) -> None:
|
|
154
|
+
self.task = task
|
|
155
|
+
self.conn.send((task.to_json(), force))
|
|
156
|
+
|
|
157
|
+
def ask_stop(self) -> None:
|
|
158
|
+
try:
|
|
159
|
+
self.conn.send(None)
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
def join(self, deadline: float) -> None:
|
|
164
|
+
"""Wait for the process to end until `deadline` (time.monotonic()), then kill it."""
|
|
165
|
+
self.proc.join(max(0.0, deadline - time.monotonic()))
|
|
166
|
+
if self.proc.is_alive():
|
|
167
|
+
self.proc.kill()
|
|
168
|
+
self.proc.join()
|
|
169
|
+
self.conn.close()
|
|
170
|
+
|
|
171
|
+
def stop(self, wait_s: float = 10.0) -> None:
|
|
172
|
+
self.ask_stop()
|
|
173
|
+
self.join(time.monotonic() + wait_s)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------- scheduling
|
|
177
|
+
class Scheduler:
|
|
178
|
+
"""The start order of a stage's tasks: decreasing estimated CPU (then id), admitted while the estimated peak
|
|
179
|
+
memory of the running tasks plus the next one fits `budget` (bytes; None: no limit). A task estimated above the
|
|
180
|
+
budget waits until nothing runs, then runs alone; no task behind it in the order starts before it."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, tasks, budget: int | None):
|
|
183
|
+
self.queue = sorted(tasks, key=lambda t: (-_cpu(t), t.id))
|
|
184
|
+
self.budget = budget
|
|
185
|
+
self.running: dict[str, int] = {}
|
|
186
|
+
self.oversize: list[str] = []
|
|
187
|
+
|
|
188
|
+
def used(self) -> int:
|
|
189
|
+
return sum(self.running.values())
|
|
190
|
+
|
|
191
|
+
def next(self) -> Task | None:
|
|
192
|
+
used = self.used()
|
|
193
|
+
for i, t in enumerate(self.queue):
|
|
194
|
+
need = _peak(t)
|
|
195
|
+
if self.budget is None or used + need <= self.budget:
|
|
196
|
+
return self._take(i)
|
|
197
|
+
if need > self.budget:
|
|
198
|
+
if self.running:
|
|
199
|
+
return None
|
|
200
|
+
self.oversize.append(t.id)
|
|
201
|
+
return self._take(i)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
def _take(self, i: int) -> Task:
|
|
205
|
+
t = self.queue.pop(i)
|
|
206
|
+
self.running[t.id] = _peak(t)
|
|
207
|
+
return t
|
|
208
|
+
|
|
209
|
+
def finished(self, tid: str) -> None:
|
|
210
|
+
self.running.pop(tid, None)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _cpu(t: Task) -> float:
|
|
214
|
+
return t.cost.cpu_seconds if t.cost is not None else 0.0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _peak(t: Task) -> int:
|
|
218
|
+
return t.cost.peak_bytes if t.cost is not None else 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------- the run
|
|
222
|
+
@dataclass
|
|
223
|
+
class Run:
|
|
224
|
+
"""What an orchestrated run did: per task id its key and status (hit, ran, failed), the failures, the log.
|
|
225
|
+
The reports (summary, failures, coverage) come from one reading of every result (scan), kept while the tasks
|
|
226
|
+
stay the same."""
|
|
227
|
+
store: object
|
|
228
|
+
context: dict
|
|
229
|
+
selection: list
|
|
230
|
+
params: dict
|
|
231
|
+
stages: dict
|
|
232
|
+
tasks: dict = field(default_factory=dict) # task id -> {"id", "key", "status"}
|
|
233
|
+
failures_: list = field(default_factory=list)
|
|
234
|
+
log: list = field(default_factory=list)
|
|
235
|
+
oversize: list = field(default_factory=list)
|
|
236
|
+
_scan: tuple | None = field(default=None, init=False, repr=False, compare=False)
|
|
237
|
+
|
|
238
|
+
def results(self):
|
|
239
|
+
"""(task id, result document) of every task with a result, by task id."""
|
|
240
|
+
for tid in sorted(self.tasks):
|
|
241
|
+
t = self.tasks[tid]
|
|
242
|
+
if t["status"] != "failed":
|
|
243
|
+
yield tid, self.store.result(t["key"])
|
|
244
|
+
|
|
245
|
+
def scan(self, visit=None, census=None) -> None:
|
|
246
|
+
"""Read every result once, by task id, for the reports; `visit(task id, result)` sees each as well (what
|
|
247
|
+
else the caller needs of the results, without reading them again). With `census()` (called after the
|
|
248
|
+
reading: the census totals of the coverage report), the coverage document is made at once and the
|
|
249
|
+
per-object state it needs (about 0.2 GB for a full catalog) is not kept."""
|
|
250
|
+
digest = Digest()
|
|
251
|
+
for tid, doc in self.results():
|
|
252
|
+
digest.add(tid, doc)
|
|
253
|
+
if visit is not None:
|
|
254
|
+
visit(tid, doc)
|
|
255
|
+
if census is not None:
|
|
256
|
+
digest.settle(census())
|
|
257
|
+
self._scan = (self._tasks_now(), digest)
|
|
258
|
+
|
|
259
|
+
def _tasks_now(self) -> tuple:
|
|
260
|
+
return tuple((tid, t["key"], t["status"]) for tid, t in sorted(self.tasks.items()))
|
|
261
|
+
|
|
262
|
+
def _digest(self) -> Digest:
|
|
263
|
+
if self._scan is None or self._scan[0] != self._tasks_now():
|
|
264
|
+
self.scan()
|
|
265
|
+
return self._scan[1]
|
|
266
|
+
|
|
267
|
+
def failures(self) -> list[dict]:
|
|
268
|
+
"""Task failures and failed items, sorted (task, object)."""
|
|
269
|
+
out = list(self.failures_) + self._digest().failed
|
|
270
|
+
return sorted(out, key=lambda f: (f["task"], f["object"] or "", f["code"]))
|
|
271
|
+
|
|
272
|
+
def summary(self) -> dict:
|
|
273
|
+
statuses = Counter(t["status"] for t in self.tasks.values())
|
|
274
|
+
d = self._digest()
|
|
275
|
+
return {"tasks": len(self.tasks), **{s: statuses.get(s, 0) for s in contract.RUN_TASK_STATUSES},
|
|
276
|
+
"items": {s: d.items.get(s, 0) for s in contract.ITEM_STATUSES}, "artifacts": d.artifacts}
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def exit_code(self) -> int:
|
|
280
|
+
"""0 when every task and item succeeded (unsupported objects allowed), else 1."""
|
|
281
|
+
return 1 if self.failures() else 0
|
|
282
|
+
|
|
283
|
+
def manifest(self, layouts: dict | None = None, placement: dict | None = None) -> dict:
|
|
284
|
+
return contract.run_doc(context=self.context, selection=self.selection, params=self.params,
|
|
285
|
+
stages=self.stages, tasks=list(self.tasks.values()), layouts=layouts or {},
|
|
286
|
+
summary=self.summary(), placement=placement)
|
|
287
|
+
|
|
288
|
+
def write(self, layouts: dict | None = None, placement: dict | None = None) -> dict:
|
|
289
|
+
"""Write the run manifest (layouts: {name: sha256 of its layout manifest}; placement: how their files were
|
|
290
|
+
made) and this execution's log."""
|
|
291
|
+
doc = self.manifest(layouts, placement)
|
|
292
|
+
self.store.write_run(doc, self.log)
|
|
293
|
+
return doc
|
|
294
|
+
|
|
295
|
+
def coverage(self, census: dict | None = None) -> dict:
|
|
296
|
+
d = self._digest()
|
|
297
|
+
if d.settled is not None and d.settled[0] != census:
|
|
298
|
+
self.scan(census=lambda: census)
|
|
299
|
+
d = self._digest()
|
|
300
|
+
return d.coverage(census)
|
|
301
|
+
|
|
302
|
+
def failures_doc(self) -> dict:
|
|
303
|
+
return {"schema": contract.FAILURES, "failures": self.failures()}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
SEVERITY = ("failed", "unsupported", "generic", "exported", "contained") # most severe first
|
|
307
|
+
_SEVERITY = {s: i for i, s in enumerate(SEVERITY)}
|
|
308
|
+
_PATH_IDS = 1 << 64
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class Digest:
|
|
312
|
+
"""What the reports need of results, each read once and not kept: item and artifact counts, the failed items,
|
|
313
|
+
and per object its most severe item (coverage) as one small number, its severity plus 8 times the index of its
|
|
314
|
+
(class, reason code). A full catalog has about 2 M items; as dicts they take GBs."""
|
|
315
|
+
|
|
316
|
+
def __init__(self):
|
|
317
|
+
self.items: Counter = Counter()
|
|
318
|
+
self.artifacts = 0
|
|
319
|
+
self.failed: list[dict] = []
|
|
320
|
+
self.best: dict = {} # object key -> severity + 8 * kind
|
|
321
|
+
self.kinds: dict = {} # (class, reason code or None) -> kind
|
|
322
|
+
self.reasoned: dict = {} # object key -> object id, where the most severe item has a reason
|
|
323
|
+
self._files: dict = {} # serialized file -> number (object keys)
|
|
324
|
+
self.settled: tuple | None = None # (census, coverage document) once settle() dropped the object state
|
|
325
|
+
|
|
326
|
+
def _key(self, oid: str):
|
|
327
|
+
"""An object id as a number (its serialized file's number and its pathId), smaller than the string; the
|
|
328
|
+
string itself when it is not <file>:<64-bit pathId> as contract.object_id writes it."""
|
|
329
|
+
file, _, pid = oid.rpartition(":")
|
|
330
|
+
try:
|
|
331
|
+
n = int(pid)
|
|
332
|
+
except ValueError:
|
|
333
|
+
return oid
|
|
334
|
+
if not file or not -_PATH_IDS // 2 <= n < _PATH_IDS // 2 or str(n) != pid:
|
|
335
|
+
return oid
|
|
336
|
+
return self._files.setdefault(file, len(self._files)) * _PATH_IDS + n % _PATH_IDS
|
|
337
|
+
|
|
338
|
+
def add(self, tid: str, doc: dict) -> None:
|
|
339
|
+
self.artifacts += len(doc.get("artifacts", ()))
|
|
340
|
+
best, kinds = self.best, self.kinds
|
|
341
|
+
for it in doc["items"]:
|
|
342
|
+
status = it["status"]
|
|
343
|
+
self.items[status] += 1
|
|
344
|
+
reason = it.get("reason")
|
|
345
|
+
if status == "failed":
|
|
346
|
+
self.failed.append(failure(tid, contract.parse_task_id(tid)[0], reason["code"], reason["message"],
|
|
347
|
+
obj=it["object"]))
|
|
348
|
+
sev, k = _SEVERITY[status], self._key(it["object"])
|
|
349
|
+
old = best.get(k)
|
|
350
|
+
if old is None or sev < old % 8:
|
|
351
|
+
code = reason["code"] if reason is not None else None
|
|
352
|
+
best[k] = sev + 8 * kinds.setdefault((it.get("class", "?"), code), len(kinds))
|
|
353
|
+
if code is None:
|
|
354
|
+
self.reasoned.pop(k, None)
|
|
355
|
+
else:
|
|
356
|
+
self.reasoned[k] = it["object"]
|
|
357
|
+
|
|
358
|
+
def settle(self, census: dict | None) -> None:
|
|
359
|
+
"""Make the coverage document for `census` now and drop the per-object state (no object can be added)."""
|
|
360
|
+
self.settled = (census, self.coverage(census))
|
|
361
|
+
self.best = self.kinds = self.reasoned = self._files = None
|
|
362
|
+
|
|
363
|
+
def coverage(self, census: dict | None = None) -> dict:
|
|
364
|
+
"""The coverage document (nnnotes.coverage/1): per class the count of every item status and the census
|
|
365
|
+
total (`census`: {class: objects}; default: the item count; `missing` = total - objects with an item); per
|
|
366
|
+
reason code its count and the first five object ids (sorted). An object with items in several results (a
|
|
367
|
+
sprite whose image sprite.crop makes) counts once, with the most severe of its statuses (SEVERITY) and
|
|
368
|
+
that item's reason (of the first such item)."""
|
|
369
|
+
if self.settled is not None:
|
|
370
|
+
if self.settled[0] != census:
|
|
371
|
+
raise ValueError("coverage of another census after settle()")
|
|
372
|
+
return self.settled[1]
|
|
373
|
+
kinds = list(self.kinds)
|
|
374
|
+
classes: dict[str, Counter] = {}
|
|
375
|
+
for v, n in Counter(self.best.values()).items():
|
|
376
|
+
classes.setdefault(kinds[v // 8][0], Counter())[SEVERITY[v % 8]] += n
|
|
377
|
+
reasons: dict[str, list] = {}
|
|
378
|
+
for k, oid in self.reasoned.items():
|
|
379
|
+
reasons.setdefault(kinds[self.best[k] // 8][1], []).append(oid)
|
|
380
|
+
out_classes = {}
|
|
381
|
+
for cls in sorted(set(classes) | set(census or {})):
|
|
382
|
+
c = classes.get(cls, Counter())
|
|
383
|
+
n = sum(c.values())
|
|
384
|
+
total = (census or {}).get(cls, n)
|
|
385
|
+
out_classes[cls] = {"total": total, "missing": total - n,
|
|
386
|
+
**{s: c.get(s, 0) for s in contract.ITEM_STATUSES}}
|
|
387
|
+
return {"schema": contract.COVERAGE, "classes": out_classes,
|
|
388
|
+
"reasons": {code: {"count": len(ids), "examples": sorted(ids)[:5]}
|
|
389
|
+
for code, ids in sorted(reasons.items())}}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def coverage(results, census: dict | None = None) -> dict:
|
|
393
|
+
"""The coverage document (Digest.coverage) of results [(task id, result)]."""
|
|
394
|
+
d = Digest()
|
|
395
|
+
for tid, doc in results:
|
|
396
|
+
d.add(tid, doc)
|
|
397
|
+
return d.coverage(census)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class Orchestrator:
|
|
401
|
+
"""Runs pipelines of stages into a store (see the module documentation). `initializer(store, *initargs)` runs
|
|
402
|
+
first in every worker (settings, caches, the store's fetcher); it must be importable (spawned processes)."""
|
|
403
|
+
|
|
404
|
+
def __init__(self, store, stages: dict[str, Stage], *, workers: int = 0, memory: int | None = None,
|
|
405
|
+
worker_bytes: int = WORKER_BYTES, recycle_tasks: int | None = None, recycle_rss: int | None = None,
|
|
406
|
+
initializer=None, initargs: tuple = (), log=_log):
|
|
407
|
+
self.store, self.stages = store, stages
|
|
408
|
+
self.workers, self.memory, self.worker_bytes = max(0, int(workers)), memory, int(worker_bytes)
|
|
409
|
+
self.recycle_tasks, self.recycle_rss = recycle_tasks, recycle_rss
|
|
410
|
+
self.initializer, self.initargs = initializer, tuple(initargs)
|
|
411
|
+
self.log = log
|
|
412
|
+
self._pool: list[_Worker] = []
|
|
413
|
+
self._started = 0
|
|
414
|
+
|
|
415
|
+
def explain(self) -> list[str]:
|
|
416
|
+
"""Every knob of the run, with its value."""
|
|
417
|
+
gib = lambda b: "none" if b is None else f"{b / (1 << 30):.1f} GiB" # noqa: E731
|
|
418
|
+
return [f"workers: {self.workers or 'none (tasks run in this process)'}",
|
|
419
|
+
f"memory budget: {gib(self.memory)}, of which {gib(self.task_budget())} for the running tasks' "
|
|
420
|
+
f"estimated peaks ({gib(self.worker_bytes)} per worker process)",
|
|
421
|
+
"order: longest estimated CPU first",
|
|
422
|
+
f"worker recycling: after {self.recycle_tasks or 'unlimited'} tasks, "
|
|
423
|
+
f"above {gib(self.recycle_rss)} resident"
|
|
424
|
+
+ ("" if rss() is not None else " (resident memory not measurable here: size limit inactive)")]
|
|
425
|
+
|
|
426
|
+
def task_budget(self) -> int | None:
|
|
427
|
+
"""The memory the running tasks' estimates may sum to: the budget less what the worker processes hold."""
|
|
428
|
+
if self.memory is None:
|
|
429
|
+
return None
|
|
430
|
+
return max(0, self.memory - max(1, self.workers) * self.worker_bytes)
|
|
431
|
+
|
|
432
|
+
# ---------------------------------------------------------------- pipeline
|
|
433
|
+
def run(self, pipeline: list[str], *, params: dict | None = None, facts: dict | None = None,
|
|
434
|
+
context: dict | None = None, selection=(), force=()) -> Run:
|
|
435
|
+
"""Run the stages named in `pipeline`, in order. `params`: {stage: parameters}; `facts`: the planning
|
|
436
|
+
facts; `context` / `selection`: recorded in the run manifest; `force`: task ids or stage names ("*": all)
|
|
437
|
+
run again even when their result is in the store (the new result must be identical)."""
|
|
438
|
+
params = params or {}
|
|
439
|
+
unknown = [n for n in pipeline if n not in self.stages]
|
|
440
|
+
if unknown:
|
|
441
|
+
raise ValueError(f"unknown stages: {', '.join(unknown)}")
|
|
442
|
+
for i, n in enumerate(pipeline):
|
|
443
|
+
late = [a for a in self.stages[n].after if a in pipeline and pipeline.index(a) > i]
|
|
444
|
+
if late:
|
|
445
|
+
raise ValueError(f"stage {n} runs before {', '.join(late)}, which it reads")
|
|
446
|
+
norm = {n: self.stages[n].normalize(params.get(n)) for n in pipeline}
|
|
447
|
+
run = Run(self.store, dict(context or {}), list(selection), norm,
|
|
448
|
+
{n: self.stages[n].version for n in pipeline})
|
|
449
|
+
env = Env(self.store, facts)
|
|
450
|
+
force = set(force)
|
|
451
|
+
for line in self.explain():
|
|
452
|
+
run.log.append({"event": "knob", "value": line})
|
|
453
|
+
try:
|
|
454
|
+
for name in pipeline:
|
|
455
|
+
self._stage(self.stages[name], norm[name], env, force, run)
|
|
456
|
+
except ConfigError as e:
|
|
457
|
+
e.run = run # what ran before the stop: its results stay valid
|
|
458
|
+
raise
|
|
459
|
+
finally:
|
|
460
|
+
self.close()
|
|
461
|
+
return run
|
|
462
|
+
|
|
463
|
+
def _stage(self, stage: Stage, params: dict, env: Env, force: set, run: Run) -> None:
|
|
464
|
+
t0 = time.perf_counter()
|
|
465
|
+
tasks: list[Task] = []
|
|
466
|
+
try:
|
|
467
|
+
subjects = stage.subjects(env)
|
|
468
|
+
except Pending as p:
|
|
469
|
+
self._fail(run, env, contract.task_id(stage.name, "*"), None, stage.name,
|
|
470
|
+
"upstream.failed" if p.failed else "upstream.missing", f"needs {p.task}: {p}")
|
|
471
|
+
return
|
|
472
|
+
for subject in subjects:
|
|
473
|
+
tid = contract.task_id(stage.name, subject)
|
|
474
|
+
try:
|
|
475
|
+
tasks.append(describe(stage, subject, params, env))
|
|
476
|
+
except Pending as p:
|
|
477
|
+
self._fail(run, env, tid, None, stage.name,
|
|
478
|
+
"upstream.failed" if p.failed else "upstream.missing", f"needs {p.task}: {p}")
|
|
479
|
+
except Exception as e:
|
|
480
|
+
self._fail(run, env, tid, None, stage.name, "task.describe", f"{type(e).__name__}: {e}"[:2000])
|
|
481
|
+
forced = "*" in force or stage.name in force
|
|
482
|
+
todo = []
|
|
483
|
+
for t in sorted(tasks, key=lambda t: t.id):
|
|
484
|
+
if not (forced or t.id in force) and self.store.has_result(t.key):
|
|
485
|
+
run.tasks[t.id] = {"id": t.id, "key": t.key, "status": "hit"}
|
|
486
|
+
env.done(t.id, t.key)
|
|
487
|
+
else:
|
|
488
|
+
todo.append(t)
|
|
489
|
+
replies = self._execute(todo, force=lambda t: forced or t.id in force, run=run)
|
|
490
|
+
stop = next((r for r in replies.values() if r.get("code") == "config"), None)
|
|
491
|
+
for t in todo:
|
|
492
|
+
reply = replies.get(t.id) or {"ok": False, "code": "config", "message": "not started: the run stopped"}
|
|
493
|
+
if reply["ok"]:
|
|
494
|
+
run.tasks[t.id] = {"id": t.id, "key": t.key, "status": "ran"}
|
|
495
|
+
env.done(t.id, t.key)
|
|
496
|
+
else:
|
|
497
|
+
self._fail(run, env, t.id, t.key, stage.name, reply["code"], reply["message"], _input_name(t))
|
|
498
|
+
n = Counter(run.tasks[t.id]["status"] for t in tasks)
|
|
499
|
+
self.log(f"{stage.name}: {len(tasks)} tasks, {n['hit']} hits, {n['ran']} ran, {n['failed']} failed "
|
|
500
|
+
f"({time.perf_counter() - t0:.1f} s)")
|
|
501
|
+
if stop is not None:
|
|
502
|
+
raise ConfigError(stop["message"])
|
|
503
|
+
|
|
504
|
+
@staticmethod
|
|
505
|
+
def _fail(run: Run, env: Env, tid: str, key, stage: str, code: str, message: str, inp=None) -> None:
|
|
506
|
+
run.tasks[tid] = {"id": tid, "key": key, "status": "failed"}
|
|
507
|
+
run.failures_.append(failure(tid, stage, code, message, inp))
|
|
508
|
+
env.waiting(tid, failed=True)
|
|
509
|
+
|
|
510
|
+
# ---------------------------------------------------------------- execution
|
|
511
|
+
def _execute(self, tasks: list[Task], force, run: Run) -> dict[str, dict]:
|
|
512
|
+
"""Run `tasks`; {task id: reply}."""
|
|
513
|
+
if not tasks:
|
|
514
|
+
return {}
|
|
515
|
+
sched = Scheduler(tasks, self.task_budget())
|
|
516
|
+
replies: dict[str, dict] = {}
|
|
517
|
+
if self.workers == 0:
|
|
518
|
+
while True:
|
|
519
|
+
t = sched.next()
|
|
520
|
+
if t is None:
|
|
521
|
+
break
|
|
522
|
+
self._record(run, t, sched, None)
|
|
523
|
+
replies[t.id] = run_one(t, self.store, self.stages, force(t))
|
|
524
|
+
self._done(run, t, replies[t.id], None)
|
|
525
|
+
sched.finished(t.id)
|
|
526
|
+
if replies[t.id].get("code") == "config":
|
|
527
|
+
sched.queue.clear()
|
|
528
|
+
else:
|
|
529
|
+
self._pool_run(sched, force, replies, run)
|
|
530
|
+
run.oversize += sched.oversize
|
|
531
|
+
return replies
|
|
532
|
+
|
|
533
|
+
def _spawn(self) -> _Worker:
|
|
534
|
+
self._started += 1
|
|
535
|
+
return _Worker(mp.get_context("spawn"), self._started, self.store, self.stages, self.initializer,
|
|
536
|
+
self.initargs)
|
|
537
|
+
|
|
538
|
+
def _pool_run(self, sched: Scheduler, force, replies: dict, run: Run) -> None:
|
|
539
|
+
while len(self._pool) < min(self.workers, len(sched.queue)): # no more processes than tasks
|
|
540
|
+
self._pool.append(self._spawn())
|
|
541
|
+
try:
|
|
542
|
+
while sched.queue or sched.running:
|
|
543
|
+
for i in range(len(self._pool)):
|
|
544
|
+
if self._pool[i].task is None:
|
|
545
|
+
t = sched.next()
|
|
546
|
+
if t is None:
|
|
547
|
+
break
|
|
548
|
+
if not self._pool[i].proc.is_alive(): # died while idle
|
|
549
|
+
self._replace(self._pool[i])
|
|
550
|
+
w = self._pool[i]
|
|
551
|
+
self._record(run, t, sched, w)
|
|
552
|
+
w.send(t, force(t))
|
|
553
|
+
busy = [w for w in self._pool if w.task is not None]
|
|
554
|
+
ready = wait([w.conn for w in busy] + [w.proc.sentinel for w in busy])
|
|
555
|
+
for w in busy:
|
|
556
|
+
if w.conn not in ready and w.proc.sentinel not in ready:
|
|
557
|
+
continue
|
|
558
|
+
t = w.task
|
|
559
|
+
reply = None
|
|
560
|
+
try:
|
|
561
|
+
if w.conn.poll():
|
|
562
|
+
reply = w.conn.recv()
|
|
563
|
+
except (EOFError, OSError):
|
|
564
|
+
reply = None
|
|
565
|
+
if reply is None:
|
|
566
|
+
w.proc.join(5)
|
|
567
|
+
reply = {"ok": False, "code": "task.worker_died",
|
|
568
|
+
"message": f"worker exited with code {w.proc.exitcode} while running the task",
|
|
569
|
+
"trace": "", "rss": None}
|
|
570
|
+
replies[t.id] = reply
|
|
571
|
+
self._done(run, t, reply, w)
|
|
572
|
+
sched.finished(t.id)
|
|
573
|
+
if reply.get("code") == "config":
|
|
574
|
+
sched.queue.clear()
|
|
575
|
+
w.task = None
|
|
576
|
+
w.done += 1
|
|
577
|
+
if not w.proc.is_alive() or reply.get("code") == "task.worker_died" or self._recycle(w, reply):
|
|
578
|
+
self._replace(w)
|
|
579
|
+
except BaseException:
|
|
580
|
+
self.close(kill=True)
|
|
581
|
+
raise
|
|
582
|
+
|
|
583
|
+
def _recycle(self, w: _Worker, reply: dict) -> bool:
|
|
584
|
+
if self.recycle_tasks and w.done >= self.recycle_tasks:
|
|
585
|
+
return True
|
|
586
|
+
r = reply.get("rss")
|
|
587
|
+
return bool(self.recycle_rss and r is not None and r > self.recycle_rss)
|
|
588
|
+
|
|
589
|
+
def _replace(self, w: _Worker) -> None:
|
|
590
|
+
if w.proc.is_alive():
|
|
591
|
+
w.stop()
|
|
592
|
+
else:
|
|
593
|
+
w.proc.join(5)
|
|
594
|
+
w.conn.close()
|
|
595
|
+
self._pool[self._pool.index(w)] = self._spawn()
|
|
596
|
+
|
|
597
|
+
def _record(self, run: Run, t: Task, sched: Scheduler, w) -> None:
|
|
598
|
+
run.log.append({"event": "start", "task": t.id, "key": t.key, "worker": None if w is None else w.n,
|
|
599
|
+
"estimate": t.cost.to_json() if t.cost is not None else None,
|
|
600
|
+
"runningBytes": sched.used() - _peak(t), "budget": sched.budget})
|
|
601
|
+
|
|
602
|
+
def _done(self, run: Run, t: Task, reply: dict, w) -> None:
|
|
603
|
+
e = {"event": "end", "task": t.id, "worker": None if w is None else w.n, "ok": reply["ok"],
|
|
604
|
+
"rss": reply.get("rss")}
|
|
605
|
+
if reply["ok"]:
|
|
606
|
+
e.update(status=reply["status"], cost=reply.get("cost"))
|
|
607
|
+
else:
|
|
608
|
+
e.update(code=reply["code"], message=reply["message"], trace=reply.get("trace", ""))
|
|
609
|
+
self.log(f"{t.id}: failed: {reply['message'][:300]}")
|
|
610
|
+
run.log.append(e)
|
|
611
|
+
|
|
612
|
+
def close(self, kill: bool = False) -> None:
|
|
613
|
+
"""Stop the worker processes (all asked first, then waited for: they end in parallel)."""
|
|
614
|
+
pool, self._pool = self._pool, []
|
|
615
|
+
if kill:
|
|
616
|
+
for w in pool:
|
|
617
|
+
w.proc.kill()
|
|
618
|
+
else:
|
|
619
|
+
for w in pool:
|
|
620
|
+
w.ask_stop()
|
|
621
|
+
deadline = time.monotonic() + 10.0
|
|
622
|
+
for w in pool:
|
|
623
|
+
w.join(deadline)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _input_name(t: Task) -> str | None:
|
|
627
|
+
if not t.inputs:
|
|
628
|
+
return None
|
|
629
|
+
i = t.inputs[0]
|
|
630
|
+
return i.name or i.sha256
|