simrun 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.
- simrun/__init__.py +3 -0
- simrun/argo.py +449 -0
- simrun/build_constraints.txt +20 -0
- simrun/cgroup.py +77 -0
- simrun/cli.py +350 -0
- simrun/client.py +407 -0
- simrun/config.py +63 -0
- simrun/cwl.py +287 -0
- simrun/demo.py +106 -0
- simrun/edit.py +408 -0
- simrun/environment.py +370 -0
- simrun/ledger.py +286 -0
- simrun/mcp.py +161 -0
- simrun/planner.py +303 -0
- simrun/plugins.py +149 -0
- simrun/plugins_builtin.py +33 -0
- simrun/podspec.py +166 -0
- simrun/resources.py +270 -0
- simrun/service/__init__.py +1 -0
- simrun/service/app.py +1721 -0
- simrun/service/argo_backend.py +842 -0
- simrun/service/auth.py +93 -0
- simrun/service/backend.py +170 -0
- simrun/service/compare.py +77 -0
- simrun/service/controller.py +788 -0
- simrun/service/edits.py +406 -0
- simrun/service/index.py +292 -0
- simrun/service/logs.py +91 -0
- simrun/service/submission.py +349 -0
- simrun/snapshot.py +196 -0
- simrun/store.py +190 -0
- simrun/testing.py +263 -0
- simrun/worker.py +1097 -0
- simrun/workflow.py +188 -0
- simrun-0.1.0.dist-info/METADATA +107 -0
- simrun-0.1.0.dist-info/RECORD +39 -0
- simrun-0.1.0.dist-info/WHEEL +4 -0
- simrun-0.1.0.dist-info/entry_points.txt +10 -0
- simrun-0.1.0.dist-info/licenses/LICENSE +7 -0
simrun/service/edits.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Edit sessions: how the browser asks the machine with the code to save and run.
|
|
2
|
+
|
|
3
|
+
A session lives in the store under `edits/<id>/`: `session.json`, and in
|
|
4
|
+
`requests/` the `<rid>.json` of a request of the browser and the
|
|
5
|
+
`<rid>.result.json` with the answer of the command `simrun edit`. All files are
|
|
6
|
+
written atomically, and the service keeps nothing in memory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import builtins
|
|
12
|
+
import fcntl
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable, Generator
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from datetime import UTC, datetime, timedelta
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Literal, Self
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, model_validator
|
|
24
|
+
|
|
25
|
+
from simrun.service.auth import Identity
|
|
26
|
+
from simrun.snapshot import SourceProvenance
|
|
27
|
+
from simrun.store import RunSummary
|
|
28
|
+
from simrun.workflow import SplitMode, Workflow
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
EDIT_CONNECTED_S = 30.0
|
|
33
|
+
# how long a save or a run of the browser waits for the answer of the command; a
|
|
34
|
+
# request older than this is no longer waited for
|
|
35
|
+
EDIT_WAIT_S = 60.0
|
|
36
|
+
SESSION_FILE = "session.json"
|
|
37
|
+
LOCK_FILE = ".lock"
|
|
38
|
+
REQUESTS_DIR = "requests"
|
|
39
|
+
RESULT_SUFFIX = ".result.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TreeState(BaseModel):
|
|
43
|
+
"""The live state of the working tree of a source.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
name: Name of the source.
|
|
47
|
+
branch: The branch, none when detached.
|
|
48
|
+
commit: The commit.
|
|
49
|
+
changed_files: The files which differ from the commit.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
branch: str | None
|
|
54
|
+
commit: str
|
|
55
|
+
changed_files: list[str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EditSession(BaseModel):
|
|
59
|
+
"""An edit session.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
id: Id of the session.
|
|
63
|
+
owner: The user of the token which opened it.
|
|
64
|
+
host: The machine of the command.
|
|
65
|
+
path: The absolute path of the workflow file there.
|
|
66
|
+
workflow: The workflow as in the file.
|
|
67
|
+
file_sha256: Hash of the file content, empty for a file which does not exist.
|
|
68
|
+
sources: The provenance of the last snapshots.
|
|
69
|
+
tree: Per source the state of the working tree.
|
|
70
|
+
created: When the session was opened.
|
|
71
|
+
seen: The last poll of the command.
|
|
72
|
+
closed: The command stopped.
|
|
73
|
+
connected: Not closed and seen lately; computed when read.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
owner: str
|
|
78
|
+
host: str
|
|
79
|
+
path: str
|
|
80
|
+
workflow: Workflow
|
|
81
|
+
file_sha256: str
|
|
82
|
+
sources: list[SourceProvenance]
|
|
83
|
+
tree: list[TreeState]
|
|
84
|
+
created: datetime
|
|
85
|
+
seen: datetime
|
|
86
|
+
closed: bool = False
|
|
87
|
+
connected: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OpenEdit(BaseModel):
|
|
91
|
+
"""The body which opens a session."""
|
|
92
|
+
|
|
93
|
+
host: str
|
|
94
|
+
path: str
|
|
95
|
+
workflow: Workflow
|
|
96
|
+
file_sha256: str
|
|
97
|
+
sources: list[SourceProvenance]
|
|
98
|
+
tree: list[TreeState]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class EditState(BaseModel):
|
|
102
|
+
"""What the command read from disk, sent when it changed."""
|
|
103
|
+
|
|
104
|
+
workflow: Workflow
|
|
105
|
+
file_sha256: str
|
|
106
|
+
tree: list[TreeState]
|
|
107
|
+
sources: list[SourceProvenance] | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class EditRequest(BaseModel):
|
|
111
|
+
"""A request of the browser to the command.
|
|
112
|
+
|
|
113
|
+
Attributes:
|
|
114
|
+
id: Id of the request.
|
|
115
|
+
kind: `save` writes the file, `run` submits the workflow.
|
|
116
|
+
workflow: The workflow of the draft.
|
|
117
|
+
base: For a save, the hash of the file the draft started from.
|
|
118
|
+
force: Save although the file changed on disk.
|
|
119
|
+
group: Group of the run.
|
|
120
|
+
split: Split mode of the run.
|
|
121
|
+
reserve: Node reserved for the run.
|
|
122
|
+
created: When the request was made.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
id: str
|
|
126
|
+
kind: Literal["save", "run"]
|
|
127
|
+
workflow: Workflow
|
|
128
|
+
base: str
|
|
129
|
+
force: bool = False
|
|
130
|
+
group: str | None = None
|
|
131
|
+
split: SplitMode | None = None
|
|
132
|
+
reserve: str | None = None
|
|
133
|
+
created: datetime
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class EditResult(BaseModel):
|
|
137
|
+
"""The answer of the command to a request.
|
|
138
|
+
|
|
139
|
+
Attributes:
|
|
140
|
+
ok: Whether the request was done.
|
|
141
|
+
status: The status of a refusal, 4xx or 5xx: 409 conflict, 422 refused.
|
|
142
|
+
detail: The reason of a refusal.
|
|
143
|
+
file_sha256: For a save, the hash of the written file.
|
|
144
|
+
run: For a run, the submitted run.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
ok: bool
|
|
148
|
+
status: int = 200
|
|
149
|
+
detail: str = ""
|
|
150
|
+
file_sha256: str | None = None
|
|
151
|
+
run: RunSummary | None = None
|
|
152
|
+
|
|
153
|
+
@model_validator(mode="after")
|
|
154
|
+
def _refusal_has_an_error_status(self) -> Self:
|
|
155
|
+
if not self.ok and not 400 <= self.status <= 599:
|
|
156
|
+
raise ValueError(f"a refusal needs a 4xx or 5xx status, not {self.status}")
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SaveBody(BaseModel):
|
|
161
|
+
"""The body of a save."""
|
|
162
|
+
|
|
163
|
+
workflow: Workflow
|
|
164
|
+
base: str
|
|
165
|
+
force: bool = False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class RunBody(BaseModel):
|
|
169
|
+
"""The body of a run, with the options of `SubmitRequest`."""
|
|
170
|
+
|
|
171
|
+
workflow: Workflow
|
|
172
|
+
group: str | None = None
|
|
173
|
+
split: SplitMode | None = None
|
|
174
|
+
reserve: str | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _write(path: Path, text: str) -> None:
|
|
178
|
+
"""Write a file atomically (temporary file in the directory, rename)."""
|
|
179
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
tmp = path.parent / f".{path.name}.{secrets.token_hex(4)}.tmp"
|
|
181
|
+
tmp.write_text(text)
|
|
182
|
+
os.replace(tmp, path)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _now() -> datetime:
|
|
186
|
+
return datetime.now(UTC)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class EditStore:
|
|
190
|
+
"""The edit sessions under `<store>/edits`."""
|
|
191
|
+
|
|
192
|
+
def __init__(self, root: Path) -> None:
|
|
193
|
+
"""Create the store.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
root: The store of the service.
|
|
197
|
+
"""
|
|
198
|
+
self.root = root / "edits"
|
|
199
|
+
|
|
200
|
+
def _dir(self, session_id: str) -> Path:
|
|
201
|
+
return self.root / session_id
|
|
202
|
+
|
|
203
|
+
@contextmanager
|
|
204
|
+
def _locked(self, session_id: str) -> Generator[None]:
|
|
205
|
+
"""Hold the lock of a session while its file is read and written."""
|
|
206
|
+
directory = self._dir(session_id)
|
|
207
|
+
if not (directory / SESSION_FILE).is_file():
|
|
208
|
+
raise KeyError(session_id)
|
|
209
|
+
with (directory / LOCK_FILE).open("a") as handle:
|
|
210
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
211
|
+
yield
|
|
212
|
+
|
|
213
|
+
def _load(self, session_id: str) -> EditSession:
|
|
214
|
+
try:
|
|
215
|
+
text = (self._dir(session_id) / SESSION_FILE).read_text()
|
|
216
|
+
except FileNotFoundError:
|
|
217
|
+
raise KeyError(session_id) from None
|
|
218
|
+
return EditSession.model_validate_json(text)
|
|
219
|
+
|
|
220
|
+
def _mutate(
|
|
221
|
+
self, session_id: str, change: Callable[[EditSession], None]
|
|
222
|
+
) -> EditSession:
|
|
223
|
+
with self._locked(session_id):
|
|
224
|
+
session = self._load(session_id)
|
|
225
|
+
change(session)
|
|
226
|
+
_write(
|
|
227
|
+
self._dir(session_id) / SESSION_FILE,
|
|
228
|
+
session.model_dump_json(exclude={"connected"}, indent=2),
|
|
229
|
+
)
|
|
230
|
+
return self._connected(session)
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _connected(session: EditSession) -> EditSession:
|
|
234
|
+
age = _now() - session.seen
|
|
235
|
+
session.connected = not session.closed and age <= timedelta(
|
|
236
|
+
seconds=EDIT_CONNECTED_S
|
|
237
|
+
)
|
|
238
|
+
return session
|
|
239
|
+
|
|
240
|
+
def open(self, identity: Identity, body: OpenEdit) -> EditSession:
|
|
241
|
+
"""Open a session owned by the owner of the identity."""
|
|
242
|
+
now = _now()
|
|
243
|
+
session = EditSession(
|
|
244
|
+
id=uuid.uuid4().hex[:12],
|
|
245
|
+
owner=identity.owner,
|
|
246
|
+
created=now,
|
|
247
|
+
seen=now,
|
|
248
|
+
**body.model_dump(),
|
|
249
|
+
)
|
|
250
|
+
_write(
|
|
251
|
+
self._dir(session.id) / SESSION_FILE,
|
|
252
|
+
session.model_dump_json(exclude={"connected"}, indent=2),
|
|
253
|
+
)
|
|
254
|
+
return self._connected(session)
|
|
255
|
+
|
|
256
|
+
def read(self, session_id: str) -> EditSession:
|
|
257
|
+
"""Read a session, with `connected` computed; `KeyError` when unknown."""
|
|
258
|
+
return self._connected(self._load(session_id))
|
|
259
|
+
|
|
260
|
+
def list(self, owner: str) -> builtins.list[EditSession]:
|
|
261
|
+
"""The sessions of an owner, connected first, newest first."""
|
|
262
|
+
found: list[EditSession] = []
|
|
263
|
+
for path in self.root.glob(f"*/{SESSION_FILE}"):
|
|
264
|
+
try:
|
|
265
|
+
session = self.read(path.parent.name)
|
|
266
|
+
except KeyError, ValueError:
|
|
267
|
+
logger.warning("the edit session %s cannot be read", path.parent.name)
|
|
268
|
+
continue
|
|
269
|
+
if session.owner == owner:
|
|
270
|
+
found.append(session)
|
|
271
|
+
found.sort(key=lambda s: s.created, reverse=True)
|
|
272
|
+
found.sort(key=lambda s: not s.connected)
|
|
273
|
+
return found
|
|
274
|
+
|
|
275
|
+
def update(self, session_id: str, state: EditState) -> EditSession:
|
|
276
|
+
"""Store what the command read from disk."""
|
|
277
|
+
|
|
278
|
+
def change(session: EditSession) -> None:
|
|
279
|
+
session.workflow = state.workflow
|
|
280
|
+
session.file_sha256 = state.file_sha256
|
|
281
|
+
session.tree = state.tree
|
|
282
|
+
if state.sources is not None:
|
|
283
|
+
session.sources = state.sources
|
|
284
|
+
|
|
285
|
+
return self._mutate(session_id, change)
|
|
286
|
+
|
|
287
|
+
def seen(self, session_id: str) -> EditSession:
|
|
288
|
+
"""Record a poll of the command."""
|
|
289
|
+
|
|
290
|
+
def change(session: EditSession) -> None:
|
|
291
|
+
session.seen = _now()
|
|
292
|
+
|
|
293
|
+
return self._mutate(session_id, change)
|
|
294
|
+
|
|
295
|
+
def close(self, session_id: str) -> EditSession:
|
|
296
|
+
"""Close a session, the command stopped."""
|
|
297
|
+
|
|
298
|
+
def change(session: EditSession) -> None:
|
|
299
|
+
session.closed = True
|
|
300
|
+
|
|
301
|
+
return self._mutate(session_id, change)
|
|
302
|
+
|
|
303
|
+
def saved(
|
|
304
|
+
self, session_id: str, workflow: Workflow, sha256: str | None
|
|
305
|
+
) -> EditSession:
|
|
306
|
+
"""Record a save: the workflow, and the hash when the result carried one."""
|
|
307
|
+
|
|
308
|
+
def change(session: EditSession) -> None:
|
|
309
|
+
session.workflow = workflow
|
|
310
|
+
if sha256 is not None:
|
|
311
|
+
session.file_sha256 = sha256
|
|
312
|
+
|
|
313
|
+
return self._mutate(session_id, change)
|
|
314
|
+
|
|
315
|
+
def add_request(
|
|
316
|
+
self,
|
|
317
|
+
session_id: str,
|
|
318
|
+
kind: Literal["save", "run"],
|
|
319
|
+
workflow: Workflow,
|
|
320
|
+
base: str = "",
|
|
321
|
+
force: bool = False,
|
|
322
|
+
group: str | None = None,
|
|
323
|
+
split: SplitMode | None = None,
|
|
324
|
+
reserve: str | None = None,
|
|
325
|
+
) -> EditRequest:
|
|
326
|
+
"""Write a request for the command to answer."""
|
|
327
|
+
self._load(session_id)
|
|
328
|
+
request = EditRequest(
|
|
329
|
+
id=uuid.uuid4().hex,
|
|
330
|
+
kind=kind,
|
|
331
|
+
workflow=workflow,
|
|
332
|
+
base=base,
|
|
333
|
+
force=force,
|
|
334
|
+
group=group,
|
|
335
|
+
split=split,
|
|
336
|
+
reserve=reserve,
|
|
337
|
+
created=_now(),
|
|
338
|
+
)
|
|
339
|
+
_write(
|
|
340
|
+
self._dir(session_id) / REQUESTS_DIR / f"{request.id}.json",
|
|
341
|
+
request.model_dump_json(indent=2),
|
|
342
|
+
)
|
|
343
|
+
return request
|
|
344
|
+
|
|
345
|
+
def requests(self, session_id: str) -> builtins.list[EditRequest]:
|
|
346
|
+
"""The requests without an answer, oldest first.
|
|
347
|
+
|
|
348
|
+
A request older than `EDIT_WAIT_S` by the clock of the service is no longer
|
|
349
|
+
waited for, e.g. one left by a service which stopped while it waited: it is
|
|
350
|
+
dropped instead of returned, so that the command does not do it late.
|
|
351
|
+
"""
|
|
352
|
+
directory = self._dir(session_id) / REQUESTS_DIR
|
|
353
|
+
stale = _now() - timedelta(seconds=EDIT_WAIT_S)
|
|
354
|
+
found: list[EditRequest] = []
|
|
355
|
+
for path in directory.glob("*.json"):
|
|
356
|
+
if path.name.endswith(RESULT_SUFFIX):
|
|
357
|
+
continue
|
|
358
|
+
if (directory / f"{path.stem}{RESULT_SUFFIX}").exists():
|
|
359
|
+
continue
|
|
360
|
+
try:
|
|
361
|
+
request = EditRequest.model_validate_json(path.read_text())
|
|
362
|
+
except FileNotFoundError, ValueError:
|
|
363
|
+
continue # dropped or half gone meanwhile
|
|
364
|
+
if request.created < stale:
|
|
365
|
+
logger.info("the stale edit request %s is dropped", request.id)
|
|
366
|
+
self.drop_request(session_id, request.id)
|
|
367
|
+
continue
|
|
368
|
+
found.append(request)
|
|
369
|
+
found.sort(key=lambda r: r.created)
|
|
370
|
+
return found
|
|
371
|
+
|
|
372
|
+
def answer(self, session_id: str, request_id: str, result: EditResult) -> None:
|
|
373
|
+
"""Store the answer of the command; `KeyError` for an unknown request."""
|
|
374
|
+
directory = self._dir(session_id) / REQUESTS_DIR
|
|
375
|
+
if not (directory / f"{request_id}.json").is_file():
|
|
376
|
+
raise KeyError(request_id)
|
|
377
|
+
_write(directory / f"{request_id}{RESULT_SUFFIX}", result.model_dump_json())
|
|
378
|
+
|
|
379
|
+
def result(self, session_id: str, request_id: str) -> EditResult | None:
|
|
380
|
+
"""The answer to a request, none while there is none."""
|
|
381
|
+
path = self._dir(session_id) / REQUESTS_DIR / f"{request_id}{RESULT_SUFFIX}"
|
|
382
|
+
try:
|
|
383
|
+
return EditResult.model_validate_json(path.read_text())
|
|
384
|
+
except FileNotFoundError:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
def drop_request(self, session_id: str, request_id: str) -> None:
|
|
388
|
+
"""Delete a request and its answer."""
|
|
389
|
+
directory = self._dir(session_id) / REQUESTS_DIR
|
|
390
|
+
for name in (f"{request_id}.json", f"{request_id}{RESULT_SUFFIX}"):
|
|
391
|
+
(directory / name).unlink(missing_ok=True)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
__all__ = [
|
|
395
|
+
"EDIT_CONNECTED_S",
|
|
396
|
+
"EDIT_WAIT_S",
|
|
397
|
+
"EditRequest",
|
|
398
|
+
"EditResult",
|
|
399
|
+
"EditSession",
|
|
400
|
+
"EditState",
|
|
401
|
+
"EditStore",
|
|
402
|
+
"OpenEdit",
|
|
403
|
+
"RunBody",
|
|
404
|
+
"SaveBody",
|
|
405
|
+
"TreeState",
|
|
406
|
+
]
|
simrun/service/index.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""The index of the runs in SQLite (spec section 4), beside the run store.
|
|
2
|
+
|
|
3
|
+
The store stays the source of truth: the index is rebuilt from the records at the
|
|
4
|
+
start of the service and updated after every record the service writes
|
|
5
|
+
(`IndexedRunStore.write`), the record first. It holds what the controller and the
|
|
6
|
+
lists need without reading the records over NFS: the id, the owner, the agent, the
|
|
7
|
+
group, the name of the workflow, the state, the times of creation and end, the
|
|
8
|
+
pending release and cancellation, the snapshots and the summary of every run. The
|
|
9
|
+
controller follows only the live runs (not ended, or with a release or a cancellation
|
|
10
|
+
pending); `GET /runs`, a cleanup and the usage filter in the index.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import sqlite3
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from contextlib import closing
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from simrun.store import RunRecord, RunState, RunStore, RunSummary
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
TERMINAL = (RunState.SUCCEEDED, RunState.FAILED, RunState.CANCELLED)
|
|
27
|
+
COLUMNS = (
|
|
28
|
+
"id, owner, agent, grp, workflow, state, created, finished, pending_release, "
|
|
29
|
+
"pending_cancel, pending_logs, snapshots, summary"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class IndexedRun:
|
|
35
|
+
"""A run as the index has it.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
summary: Its summary.
|
|
39
|
+
owner: Its owner.
|
|
40
|
+
workflow: The name of its workflow.
|
|
41
|
+
pending: Whether a release of its node or a cancellation of its workflow is
|
|
42
|
+
pending.
|
|
43
|
+
snapshots: The snapshots of its sources, relative to the store.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
summary: RunSummary
|
|
47
|
+
owner: str
|
|
48
|
+
workflow: str
|
|
49
|
+
pending: bool
|
|
50
|
+
snapshots: tuple[str, ...]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _row(record: RunRecord) -> tuple[object, ...]:
|
|
54
|
+
return (
|
|
55
|
+
record.id,
|
|
56
|
+
record.owner,
|
|
57
|
+
record.agent,
|
|
58
|
+
record.group,
|
|
59
|
+
record.workflow.name,
|
|
60
|
+
record.state.value,
|
|
61
|
+
record.created.isoformat(),
|
|
62
|
+
None if record.finished is None else record.finished.isoformat(),
|
|
63
|
+
record.pending_release,
|
|
64
|
+
int(record.pending_cancel),
|
|
65
|
+
int(record.pending_logs),
|
|
66
|
+
json.dumps([s.snapshot for s in record.sources]),
|
|
67
|
+
RunSummary.of(record).model_dump_json(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RunIndex:
|
|
72
|
+
"""The index of the runs in a SQLite file on a local volume.
|
|
73
|
+
|
|
74
|
+
A connection is opened per call, so the index is safe to use from several
|
|
75
|
+
threads of one process.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, db: Path) -> None:
|
|
79
|
+
"""Create the table if needed.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
db: Path of the SQLite file.
|
|
83
|
+
"""
|
|
84
|
+
self.db = db
|
|
85
|
+
db.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
with self._connect() as con:
|
|
87
|
+
con.execute("PRAGMA journal_mode=WAL")
|
|
88
|
+
columns = {row[1] for row in con.execute("PRAGMA table_info(runs)")}
|
|
89
|
+
if columns and "pending_logs" not in columns:
|
|
90
|
+
# an index of an earlier version: a cache, rebuilt at the start
|
|
91
|
+
con.execute("DROP TABLE runs")
|
|
92
|
+
con.execute(
|
|
93
|
+
"CREATE TABLE IF NOT EXISTS runs ("
|
|
94
|
+
"id TEXT PRIMARY KEY, owner TEXT NOT NULL, agent TEXT, grp TEXT, "
|
|
95
|
+
"workflow TEXT NOT NULL, state TEXT NOT NULL, created TEXT NOT NULL, "
|
|
96
|
+
"finished TEXT, pending_release TEXT, pending_cancel INTEGER NOT NULL, "
|
|
97
|
+
"pending_logs INTEGER NOT NULL DEFAULT 0, "
|
|
98
|
+
"snapshots TEXT NOT NULL, summary TEXT NOT NULL)"
|
|
99
|
+
)
|
|
100
|
+
con.execute("CREATE INDEX IF NOT EXISTS runs_state ON runs (state)")
|
|
101
|
+
con.commit()
|
|
102
|
+
|
|
103
|
+
def _connect(self) -> closing[sqlite3.Connection]:
|
|
104
|
+
return closing(sqlite3.connect(self.db, timeout=30))
|
|
105
|
+
|
|
106
|
+
def rebuild(self, records: Iterable[RunRecord]) -> None:
|
|
107
|
+
"""Replace the index by the runs of `records`."""
|
|
108
|
+
rows = [_row(r) for r in records]
|
|
109
|
+
with self._connect() as con:
|
|
110
|
+
con.execute("DELETE FROM runs")
|
|
111
|
+
con.executemany(f"INSERT INTO runs ({COLUMNS}) VALUES ({_marks()})", rows)
|
|
112
|
+
con.commit()
|
|
113
|
+
logger.info("the index of the runs was rebuilt: %d runs", len(rows))
|
|
114
|
+
|
|
115
|
+
def put(self, record: RunRecord) -> None:
|
|
116
|
+
"""Add or update a run."""
|
|
117
|
+
with self._connect() as con:
|
|
118
|
+
con.execute(
|
|
119
|
+
f"INSERT OR REPLACE INTO runs ({COLUMNS}) VALUES ({_marks()})",
|
|
120
|
+
_row(record),
|
|
121
|
+
)
|
|
122
|
+
con.commit()
|
|
123
|
+
|
|
124
|
+
def remove(self, run_id: str) -> None:
|
|
125
|
+
"""Remove a run."""
|
|
126
|
+
with self._connect() as con:
|
|
127
|
+
con.execute("DELETE FROM runs WHERE id = ?", (run_id,))
|
|
128
|
+
con.commit()
|
|
129
|
+
|
|
130
|
+
def live_ids(self) -> list[str]:
|
|
131
|
+
"""The ids of the runs the controller follows, sorted.
|
|
132
|
+
|
|
133
|
+
Runs which have not ended, and ended runs with a release of their node, a
|
|
134
|
+
cancellation of their workflow or the logs of their pods pending.
|
|
135
|
+
"""
|
|
136
|
+
terminal = ", ".join("?" for _ in TERMINAL)
|
|
137
|
+
with self._connect() as con:
|
|
138
|
+
rows = con.execute(
|
|
139
|
+
f"SELECT id FROM runs WHERE state NOT IN ({terminal}) "
|
|
140
|
+
"OR pending_release IS NOT NULL OR pending_cancel OR pending_logs "
|
|
141
|
+
"ORDER BY id",
|
|
142
|
+
[s.value for s in TERMINAL],
|
|
143
|
+
).fetchall()
|
|
144
|
+
return [r[0] for r in rows]
|
|
145
|
+
|
|
146
|
+
def runs(
|
|
147
|
+
self,
|
|
148
|
+
group: str | None = None,
|
|
149
|
+
owner: str | None = None,
|
|
150
|
+
agent: str | None = None,
|
|
151
|
+
state: RunState | None = None,
|
|
152
|
+
) -> list[IndexedRun]:
|
|
153
|
+
"""The runs with the given group, owner, agent and state, sorted by id."""
|
|
154
|
+
filters = {"grp": group, "owner": owner, "agent": agent}
|
|
155
|
+
clauses = [f"{column} = ?" for column, v in filters.items() if v is not None]
|
|
156
|
+
values: list[object] = [v for v in filters.values() if v is not None]
|
|
157
|
+
if state is not None:
|
|
158
|
+
clauses.append("state = ?")
|
|
159
|
+
values.append(state.value)
|
|
160
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
161
|
+
with self._connect() as con:
|
|
162
|
+
rows = con.execute(
|
|
163
|
+
"SELECT summary, owner, workflow, pending_release, pending_cancel, "
|
|
164
|
+
f"snapshots FROM runs {where} ORDER BY id",
|
|
165
|
+
values,
|
|
166
|
+
).fetchall()
|
|
167
|
+
return [
|
|
168
|
+
IndexedRun(
|
|
169
|
+
summary=RunSummary.model_validate_json(summary),
|
|
170
|
+
owner=owner_,
|
|
171
|
+
workflow=workflow,
|
|
172
|
+
pending=release is not None or bool(cancel),
|
|
173
|
+
snapshots=tuple(json.loads(snapshots)),
|
|
174
|
+
)
|
|
175
|
+
for summary, owner_, workflow, release, cancel, snapshots in rows
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _marks() -> str:
|
|
180
|
+
return ", ".join("?" for _ in COLUMNS.split(","))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class IndexedRunStore(RunStore):
|
|
184
|
+
"""The run store of the service: every record it writes is indexed too.
|
|
185
|
+
|
|
186
|
+
A record whose index row could not be written (the index raised after the
|
|
187
|
+
record was written) and a record which could not be read when the index was
|
|
188
|
+
rebuilt are kept aside and indexed again at every `reindex`, which the
|
|
189
|
+
controller calls at every poll; the ones which still cannot be read are logged
|
|
190
|
+
then.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, root: Path, index: RunIndex) -> None:
|
|
194
|
+
"""Create the store.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
root: Root directory of the store.
|
|
198
|
+
index: The index of its runs.
|
|
199
|
+
"""
|
|
200
|
+
super().__init__(root)
|
|
201
|
+
self.index = index
|
|
202
|
+
self._lock = threading.Lock()
|
|
203
|
+
self._unindexed: set[str] = set()
|
|
204
|
+
|
|
205
|
+
def write(self, record: RunRecord) -> None:
|
|
206
|
+
"""Write a record, then index it.
|
|
207
|
+
|
|
208
|
+
A failure of the index is logged and the run indexed at the next `reindex`:
|
|
209
|
+
the record is written, so the caller goes on.
|
|
210
|
+
"""
|
|
211
|
+
super().write(record)
|
|
212
|
+
try:
|
|
213
|
+
self.index.put(record)
|
|
214
|
+
except Exception:
|
|
215
|
+
logger.exception("indexing run %s failed, retried later", record.id)
|
|
216
|
+
with self._lock:
|
|
217
|
+
self._unindexed.add(record.id)
|
|
218
|
+
return
|
|
219
|
+
with self._lock:
|
|
220
|
+
self._unindexed.discard(record.id)
|
|
221
|
+
|
|
222
|
+
def forget(self, run_id: str) -> None:
|
|
223
|
+
"""Remove a run from the index, once it was moved out of the runs."""
|
|
224
|
+
with self._lock:
|
|
225
|
+
self._unindexed.discard(run_id)
|
|
226
|
+
self.index.remove(run_id)
|
|
227
|
+
|
|
228
|
+
def ids(self) -> list[str]:
|
|
229
|
+
"""The ids of the runs in the store, sorted; hidden directories are no runs."""
|
|
230
|
+
runs = self.root / "runs"
|
|
231
|
+
if not runs.is_dir():
|
|
232
|
+
return []
|
|
233
|
+
return sorted(p.name for p in runs.iterdir() if not p.name.startswith("."))
|
|
234
|
+
|
|
235
|
+
def rebuild(self) -> None:
|
|
236
|
+
"""Rebuild the index from the records.
|
|
237
|
+
|
|
238
|
+
A record which cannot be read is logged, left out and tried again at every
|
|
239
|
+
`reindex`.
|
|
240
|
+
"""
|
|
241
|
+
records: list[RunRecord] = []
|
|
242
|
+
unreadable: set[str] = set()
|
|
243
|
+
for run_id in self.ids():
|
|
244
|
+
try:
|
|
245
|
+
records.append(self.read(run_id))
|
|
246
|
+
except KeyError:
|
|
247
|
+
continue
|
|
248
|
+
except (ValueError, OSError) as error:
|
|
249
|
+
logger.warning("the record of run %s cannot be read: %s", run_id, error)
|
|
250
|
+
unreadable.add(run_id)
|
|
251
|
+
self.index.rebuild(records)
|
|
252
|
+
with self._lock:
|
|
253
|
+
self._unindexed = unreadable
|
|
254
|
+
|
|
255
|
+
def reindex(self) -> list[str]:
|
|
256
|
+
"""Index the runs whose index row is missing, from their records.
|
|
257
|
+
|
|
258
|
+
A record which cannot be read is logged at every call, so a run the
|
|
259
|
+
controller cannot follow is never silent. Only the index row is written,
|
|
260
|
+
never the record file, so the caller holds the lock of the record writes
|
|
261
|
+
around the call: a record is read and indexed without a write in between.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
The runs whose records were read but whose index rows still cannot be
|
|
265
|
+
written, which the controller follows besides the live runs.
|
|
266
|
+
"""
|
|
267
|
+
with self._lock:
|
|
268
|
+
pending = sorted(self._unindexed)
|
|
269
|
+
unindexed: list[str] = []
|
|
270
|
+
for run_id in pending:
|
|
271
|
+
try:
|
|
272
|
+
record = self.read(run_id)
|
|
273
|
+
except KeyError:
|
|
274
|
+
with self._lock:
|
|
275
|
+
self._unindexed.discard(run_id)
|
|
276
|
+
continue
|
|
277
|
+
except (ValueError, OSError) as error:
|
|
278
|
+
logger.warning(
|
|
279
|
+
"the record of run %s cannot be read, the run is not followed: %s",
|
|
280
|
+
run_id,
|
|
281
|
+
error,
|
|
282
|
+
)
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
self.index.put(record)
|
|
286
|
+
except Exception:
|
|
287
|
+
logger.exception("indexing run %s failed, retried later", run_id)
|
|
288
|
+
unindexed.append(run_id)
|
|
289
|
+
continue
|
|
290
|
+
with self._lock:
|
|
291
|
+
self._unindexed.discard(run_id)
|
|
292
|
+
return unindexed
|