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.
@@ -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
+ ]
@@ -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