sequentialist-core 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.
- sequentialist/core/__init__.py +84 -0
- sequentialist/core/audit.py +134 -0
- sequentialist/core/canonical.py +50 -0
- sequentialist/core/cli.py +95 -0
- sequentialist/core/engine.py +589 -0
- sequentialist/core/errors.py +40 -0
- sequentialist/core/privacy.py +30 -0
- sequentialist/core/py.typed +0 -0
- sequentialist/core/replay.py +40 -0
- sequentialist/core/report.py +141 -0
- sequentialist/core/schema.py +410 -0
- sequentialist/core/simulate.py +58 -0
- sequentialist/core/sinks.py +98 -0
- sequentialist/core/spec.py +120 -0
- sequentialist/core/storage/__init__.py +24 -0
- sequentialist/core/storage/_lock.py +91 -0
- sequentialist/core/storage/base.py +102 -0
- sequentialist/core/storage/files.py +174 -0
- sequentialist/core/storage/memory.py +64 -0
- sequentialist/core/storage/sqlite.py +150 -0
- sequentialist/core/version.py +4 -0
- sequentialist_core-0.1.0.dist-info/METADATA +129 -0
- sequentialist_core-0.1.0.dist-info/RECORD +28 -0
- sequentialist_core-0.1.0.dist-info/WHEEL +5 -0
- sequentialist_core-0.1.0.dist-info/entry_points.txt +6 -0
- sequentialist_core-0.1.0.dist-info/licenses/COMMERCIAL.md +89 -0
- sequentialist_core-0.1.0.dist-info/licenses/LICENSE +661 -0
- sequentialist_core-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 Novikov Laboratories LLC
|
|
3
|
+
# Commercial licenses for use outside the AGPL: see COMMERCIAL.md
|
|
4
|
+
"""The engine: tasks that take records, feed sequential-analysis runs, decide, and survive crashes.
|
|
5
|
+
|
|
6
|
+
A *task* (an experiment, a monitor, a trial) is described by a spec and
|
|
7
|
+
driven by a `Handler`, the domain logic of a library. The handler declares
|
|
8
|
+
the task's *streams*: each stream is one `Run` of sequential-analysis. The
|
|
9
|
+
engine gives the handler the records that arrive, the passing of time and
|
|
10
|
+
the commands of people, in one serial order; the handler feeds its streams
|
|
11
|
+
and records decisions in the audit log.
|
|
12
|
+
|
|
13
|
+
What the engine guarantees, for every storage:
|
|
14
|
+
|
|
15
|
+
* **Idempotent intake.** A record is identified by its source and its
|
|
16
|
+
sequence number in that source (an offset, a row id, a counter). A
|
|
17
|
+
record seen before is dropped, however often it is sent (`Delivery` of
|
|
18
|
+
sequential-analysis keeps the positions; sequence numbers should be dense
|
|
19
|
+
per source).
|
|
20
|
+
* **Nothing accepted is lost.** A batch is written to the journal before
|
|
21
|
+
any of it is processed. The task is checkpointed every
|
|
22
|
+
`checkpoint_every` records, after every decision and on `close`; opening
|
|
23
|
+
the task restores the checkpoint and replays the journal. The handler
|
|
24
|
+
must be deterministic (a function of its state and its inputs), and then
|
|
25
|
+
the reopened task is the uninterrupted one, bit for bit: same states,
|
|
26
|
+
same history digests, same decisions.
|
|
27
|
+
* **Decisions are recorded once.** An audit entry is written before the
|
|
28
|
+
checkpoint that follows it, and a replay that reproduces it finds it
|
|
29
|
+
already written (and checks that it is the same decision: a handler that
|
|
30
|
+
is not deterministic is caught here). Sinks are notified after the
|
|
31
|
+
checkpoint; a crash between the two loses a notification, never an
|
|
32
|
+
entry.
|
|
33
|
+
* **One writer per task**, by the storage's lock and by the version check
|
|
34
|
+
on every save.
|
|
35
|
+
|
|
36
|
+
Time: records may carry an event time. The *watermark* is the largest
|
|
37
|
+
event time seen (or set by `advance`); when it moves, the handler's
|
|
38
|
+
`on_time` runs before the record that moved it. With ``clock="event"``
|
|
39
|
+
the engine's clock is the watermark, so a replay of recorded data gives
|
|
40
|
+
the same audit log, timestamps included.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import hashlib
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
import math
|
|
48
|
+
import time as _time
|
|
49
|
+
from abc import ABC, abstractmethod
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
from typing import Any, Callable, Iterable, Mapping, Sequence, Union
|
|
52
|
+
|
|
53
|
+
from sequential_analysis import Delivery, Observation, OutputEvent, Procedure, Run
|
|
54
|
+
from sequential_analysis.topology import Topology
|
|
55
|
+
|
|
56
|
+
from .audit import GENESIS, AuditEntry, AuditError, Evidence
|
|
57
|
+
from .canonical import digest, is_plain
|
|
58
|
+
from .errors import InvalidRecord, StorageError, TaskError
|
|
59
|
+
from .storage.base import Storage, check_task_id
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger("sequentialist")
|
|
62
|
+
|
|
63
|
+
STATE_FORMAT = "sequentialist.task/1"
|
|
64
|
+
_MAX_EXACT = 2**53
|
|
65
|
+
|
|
66
|
+
Clock = Union[Callable[[], float], str]
|
|
67
|
+
Sink = Callable[[AuditEntry], None]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Stream:
|
|
72
|
+
"""One run of sequential-analysis inside a task."""
|
|
73
|
+
|
|
74
|
+
procedure: Procedure
|
|
75
|
+
topology: Callable[[], Topology] | None = None # a factory: every segment needs a fresh topology
|
|
76
|
+
seed: int | None = None # for randomized procedures
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Record:
|
|
81
|
+
"""One arriving fact: its identity (source, seq), its content and its event time."""
|
|
82
|
+
|
|
83
|
+
source: str
|
|
84
|
+
seq: int
|
|
85
|
+
data: dict[str, Any]
|
|
86
|
+
time: float | None = None
|
|
87
|
+
|
|
88
|
+
def to_json(self) -> dict[str, Any]:
|
|
89
|
+
return {"source": self.source, "seq": self.seq, "data": self.data, "time": self.time}
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_json(cls, data: Mapping[str, Any]) -> Record:
|
|
93
|
+
return cls(data["source"], data["seq"], data["data"], data.get("time"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Command:
|
|
98
|
+
"""An action of a person on a task: stop an experiment, acknowledge an alarm."""
|
|
99
|
+
|
|
100
|
+
name: str
|
|
101
|
+
args: dict[str, Any]
|
|
102
|
+
actor: str
|
|
103
|
+
reason: str
|
|
104
|
+
|
|
105
|
+
def to_json(self) -> dict[str, Any]:
|
|
106
|
+
return {"name": self.name, "args": self.args, "actor": self.actor, "reason": self.reason}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class IngestResult:
|
|
111
|
+
accepted: int = 0
|
|
112
|
+
duplicates: int = 0
|
|
113
|
+
entries: list[AuditEntry] = field(default_factory=list) # audit entries written by this call
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Handler(ABC):
|
|
117
|
+
"""The domain logic of one kind of task. It must be deterministic: no wall clock, no unseeded randomness."""
|
|
118
|
+
|
|
119
|
+
#: The commands `on_command` accepts. Others are refused before they reach the journal: a command
|
|
120
|
+
#: that fails in the handler would fail again on every replay.
|
|
121
|
+
commands: Sequence[str] = ()
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
def streams(self) -> Mapping[str, Stream]:
|
|
125
|
+
"""The task's streams by name; built from the spec, the same every time the task is opened."""
|
|
126
|
+
|
|
127
|
+
def state(self) -> Any:
|
|
128
|
+
"""The handler's own state as plain JSON (stored in every checkpoint)."""
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def restore(self, state: Any) -> None: # noqa: B027 - optional
|
|
132
|
+
"""Take back what `state` returned."""
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def on_record(self, record: Record, ctx: Context) -> None:
|
|
136
|
+
"""Process one new record."""
|
|
137
|
+
|
|
138
|
+
def on_time(self, time: float, ctx: Context) -> None: # noqa: B027 - optional
|
|
139
|
+
"""The watermark moved to `time`."""
|
|
140
|
+
|
|
141
|
+
def on_command(self, command: Command, ctx: Context) -> None:
|
|
142
|
+
raise TaskError(f"unknown command {command.name!r}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Context:
|
|
146
|
+
"""What a handler may do while it processes an input."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, task: Task):
|
|
149
|
+
self._task = task
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def now(self) -> float | None:
|
|
153
|
+
"""The watermark: the latest event time seen (None before any)."""
|
|
154
|
+
return self._task.watermark
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def task_id(self) -> str:
|
|
158
|
+
return self._task.task_id
|
|
159
|
+
|
|
160
|
+
def run(self, stream: str) -> Run:
|
|
161
|
+
return self._task.run(stream)
|
|
162
|
+
|
|
163
|
+
def last_output(self, stream: str) -> Any:
|
|
164
|
+
return self._task.last_output(stream)
|
|
165
|
+
|
|
166
|
+
def step(self, stream: str, x: Mapping[str, Any]) -> OutputEvent | None:
|
|
167
|
+
"""Give a stream one statistical input (bypassing its topology)."""
|
|
168
|
+
event = self._task.run(stream).step(x)
|
|
169
|
+
if event is not None:
|
|
170
|
+
self._task._outputs[stream] = (True, event.output)
|
|
171
|
+
return event
|
|
172
|
+
|
|
173
|
+
def offer(self, stream: str, obs: Observation) -> list[OutputEvent]:
|
|
174
|
+
"""Give a stream one observation (through its delivery and topology)."""
|
|
175
|
+
events = self._task.run(stream).offer(obs)
|
|
176
|
+
if events:
|
|
177
|
+
self._task._outputs[stream] = (True, events[-1].output)
|
|
178
|
+
return events
|
|
179
|
+
|
|
180
|
+
def evidence(self, stream: str) -> dict[str, Any]:
|
|
181
|
+
return Evidence.of(stream, self._task.run(stream), self._task.last_output(stream)).to_json()
|
|
182
|
+
|
|
183
|
+
def record(self, kind: str, decision: str | None = None, *, basis: Mapping[str, Any] | None = None,
|
|
184
|
+
evidence: Sequence[str] = (), actor: str | None = None, reason: str | None = None) -> AuditEntry:
|
|
185
|
+
"""Write an audit entry; `evidence` names the streams the entry rests on."""
|
|
186
|
+
return self._task._audit(kind, decision, dict(basis or {}), [self.evidence(s) for s in evidence],
|
|
187
|
+
actor, reason)
|
|
188
|
+
|
|
189
|
+
def restart(self, stream: str, reason: str) -> None:
|
|
190
|
+
"""End the stream's segment and begin a new run of the same procedure (after an alarm, a new window)."""
|
|
191
|
+
self._task._restart(stream, reason)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Task:
|
|
195
|
+
"""An open task: the only writer of its state until `close`."""
|
|
196
|
+
|
|
197
|
+
def __init__(self, engine: Engine, task_id: str, kind: str, spec: dict[str, Any], handler: Handler, lock: Any):
|
|
198
|
+
self.engine, self.task_id, self.kind, self.spec = engine, task_id, kind, spec
|
|
199
|
+
self.spec_id = digest(spec)
|
|
200
|
+
self.handler = handler
|
|
201
|
+
self._lock = lock
|
|
202
|
+
self._storage = engine.storage
|
|
203
|
+
self._streams = dict(handler.streams())
|
|
204
|
+
self._runs: dict[str, Run] = {}
|
|
205
|
+
self._segments: dict[str, int] = {}
|
|
206
|
+
self._outputs: dict[str, tuple[bool, Any]] = {} # (raw?, output): encoded lazily, kept in checkpoints
|
|
207
|
+
self.intake = Delivery("arrival", dedup=True)
|
|
208
|
+
self.watermark: float | None = None
|
|
209
|
+
self.counters = {"records": 0, "duplicates": 0, "ticks": 0, "commands": 0}
|
|
210
|
+
self.created_at: float = 0.0
|
|
211
|
+
self._version = 0
|
|
212
|
+
self._audit_n = 0
|
|
213
|
+
self._audit_last = ""
|
|
214
|
+
self._stored_audit: list[AuditEntry] = [] # entries written after the last checkpoint, met again on replay
|
|
215
|
+
self._since_checkpoint = 0
|
|
216
|
+
self._pending: list[AuditEntry] = [] # written, not yet given to sinks
|
|
217
|
+
self.failed: str | None = None
|
|
218
|
+
self.closed = False
|
|
219
|
+
self._ctx = Context(self)
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------ streams
|
|
222
|
+
|
|
223
|
+
def streams(self) -> list[str]:
|
|
224
|
+
return sorted(self._streams)
|
|
225
|
+
|
|
226
|
+
def run(self, stream: str) -> Run:
|
|
227
|
+
try:
|
|
228
|
+
return self._runs[stream]
|
|
229
|
+
except KeyError:
|
|
230
|
+
raise TaskError(f"task {self.task_id!r} has no stream {stream!r}") from None
|
|
231
|
+
|
|
232
|
+
def segment(self, stream: str) -> int:
|
|
233
|
+
self.run(stream)
|
|
234
|
+
return self._segments[stream]
|
|
235
|
+
|
|
236
|
+
def last_output(self, stream: str) -> Any:
|
|
237
|
+
"""The stream's latest output as plain JSON (the procedure's encode_output), or None before its first."""
|
|
238
|
+
run = self.run(stream)
|
|
239
|
+
found = self._outputs.get(stream)
|
|
240
|
+
if found is None:
|
|
241
|
+
return None
|
|
242
|
+
raw, output = found
|
|
243
|
+
if raw:
|
|
244
|
+
output = json.loads(json.dumps(run.procedure.encode_output(output)))
|
|
245
|
+
self._outputs[stream] = (False, output)
|
|
246
|
+
return output
|
|
247
|
+
|
|
248
|
+
def _run_id(self, stream: str, segment: int) -> str:
|
|
249
|
+
return f"{self.task_id}/{stream}/{segment}"
|
|
250
|
+
|
|
251
|
+
def _new_run(self, name: str, segment: int, initialized_from: Any) -> Run:
|
|
252
|
+
s = self._streams[name]
|
|
253
|
+
run = Run(s.procedure, topology=s.topology() if s.topology else None, seed=s.seed,
|
|
254
|
+
run_id=self._run_id(name, segment), initialized_from=initialized_from, clock=self.engine._now(self))
|
|
255
|
+
run.start(runtime="sequentialist")
|
|
256
|
+
return run
|
|
257
|
+
|
|
258
|
+
def _restart(self, name: str, reason: str) -> None:
|
|
259
|
+
old = self.run(name)
|
|
260
|
+
final = old.checkpoint()
|
|
261
|
+
segment = self._segments[name]
|
|
262
|
+
self._storage.put_document(self.task_id, f"segments/{name}/{segment}", final)
|
|
263
|
+
self._audit("segment", None, {"stream": name, "ended": segment, "reason": reason},
|
|
264
|
+
[Evidence.of(name, old, self.last_output(name)).to_json()], None, None)
|
|
265
|
+
old.close()
|
|
266
|
+
self._segments[name] = segment + 1
|
|
267
|
+
self._runs[name] = self._new_run(name, segment + 1, {"previous": old.run_id, "history_digest":
|
|
268
|
+
old.history_digest, "reason": reason})
|
|
269
|
+
self._outputs.pop(name, None)
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------ audit
|
|
272
|
+
|
|
273
|
+
def audit(self) -> list[AuditEntry]:
|
|
274
|
+
return [AuditEntry.from_json(e) for e in self._storage.audit_read(self.task_id)]
|
|
275
|
+
|
|
276
|
+
def _audit(self, kind: str, decision: str | None, basis: dict[str, Any], evidence: list[dict[str, Any]],
|
|
277
|
+
actor: str | None, reason: str | None) -> AuditEntry:
|
|
278
|
+
if not is_plain(basis):
|
|
279
|
+
raise TaskError(f"audit basis must be plain JSON: {basis!r}")
|
|
280
|
+
entry = AuditEntry(self._audit_n, self.task_id, self.spec_id, kind, decision, self.engine._time(self),
|
|
281
|
+
basis, tuple(evidence), actor, reason, prev=self._audit_last or GENESIS).sealed()
|
|
282
|
+
stored = self._stored_audit[0] if self._stored_audit else None
|
|
283
|
+
if stored is not None: # a replay meets an entry it wrote before the crash
|
|
284
|
+
if not stored.same_event(entry):
|
|
285
|
+
raise AuditError(f"task {self.task_id!r}: replay produced a different audit entry {entry.n} than "
|
|
286
|
+
f"the one stored ({stored.kind} {stored.decision} vs {entry.kind} "
|
|
287
|
+
f"{entry.decision}); the handler is not deterministic")
|
|
288
|
+
self._stored_audit.pop(0)
|
|
289
|
+
entry = stored
|
|
290
|
+
else:
|
|
291
|
+
self._storage.audit_append(self.task_id, entry.to_json())
|
|
292
|
+
self._audit_n += 1
|
|
293
|
+
self._audit_last = entry.hash
|
|
294
|
+
self._pending.append(entry)
|
|
295
|
+
return entry
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------ inputs
|
|
298
|
+
|
|
299
|
+
def ingest(self, records: Iterable[Record]) -> IngestResult:
|
|
300
|
+
"""Accept a batch of records: all of it or, if one is invalid, none (InvalidRecord)."""
|
|
301
|
+
self._usable()
|
|
302
|
+
batch = list(records)
|
|
303
|
+
for i, r in enumerate(batch):
|
|
304
|
+
problem = _record_problem(r)
|
|
305
|
+
if problem:
|
|
306
|
+
raise InvalidRecord(f"record {i} of the batch: {problem}; nothing of the batch was accepted")
|
|
307
|
+
result = IngestResult()
|
|
308
|
+
fresh: list[Record] = []
|
|
309
|
+
keys = set()
|
|
310
|
+
for r in batch:
|
|
311
|
+
key = (r.source, r.seq)
|
|
312
|
+
if key in keys or self.intake.delivered(_observation(r)):
|
|
313
|
+
result.duplicates += 1
|
|
314
|
+
continue
|
|
315
|
+
keys.add(key)
|
|
316
|
+
fresh.append(r)
|
|
317
|
+
self.counters["duplicates"] += result.duplicates
|
|
318
|
+
if fresh:
|
|
319
|
+
self._storage.journal_append(self.task_id, [{"record": r.to_json()} for r in fresh])
|
|
320
|
+
return self._process([("record", r) for r in fresh], result)
|
|
321
|
+
|
|
322
|
+
def advance(self, time: float) -> IngestResult:
|
|
323
|
+
"""Move the watermark to `time` without a record (time passes with no data)."""
|
|
324
|
+
self._usable()
|
|
325
|
+
if type(time) not in (int, float) or not math.isfinite(time):
|
|
326
|
+
raise InvalidRecord(f"time must be a finite number, got {time!r}")
|
|
327
|
+
self._storage.journal_append(self.task_id, [{"tick": time}])
|
|
328
|
+
return self._process([("tick", time)], IngestResult())
|
|
329
|
+
|
|
330
|
+
def command(self, name: str, args: Mapping[str, Any] | None = None, *, actor: str, reason: str) -> IngestResult:
|
|
331
|
+
"""An action of a person; the handler records it in the audit log with the actor and the reason."""
|
|
332
|
+
self._usable()
|
|
333
|
+
command = Command(name, dict(args or {}), actor, reason)
|
|
334
|
+
if name not in self.handler.commands:
|
|
335
|
+
raise TaskError(f"unknown command {name!r}; this task accepts {sorted(self.handler.commands)}")
|
|
336
|
+
if not (actor and reason and is_plain(command.to_json())):
|
|
337
|
+
raise InvalidRecord("a command needs an actor, a reason and plain JSON arguments")
|
|
338
|
+
# numbered, so that a replay never applies a command twice (records are deduplicated by their seq)
|
|
339
|
+
self._storage.journal_append(self.task_id, [{"command": command.to_json(), "n": self.counters["commands"]}])
|
|
340
|
+
return self._process([("command", command)], IngestResult())
|
|
341
|
+
|
|
342
|
+
def _process(self, inputs: list[tuple[str, Any]], result: IngestResult) -> IngestResult:
|
|
343
|
+
start = len(self._pending)
|
|
344
|
+
try:
|
|
345
|
+
for kind, item in inputs:
|
|
346
|
+
self._apply(kind, item)
|
|
347
|
+
except BaseException as error:
|
|
348
|
+
self.failed = f"{type(error).__name__}: {error}"
|
|
349
|
+
logger.error("task %s failed: %s", self.task_id, self.failed)
|
|
350
|
+
raise
|
|
351
|
+
result.accepted = sum(1 for kind, _ in inputs if kind == "record")
|
|
352
|
+
result.entries = self._pending[start:]
|
|
353
|
+
if len(self._pending) > start or self._since_checkpoint >= self.engine.checkpoint_every:
|
|
354
|
+
self.checkpoint()
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
def _apply(self, kind: str, item: Any) -> None:
|
|
358
|
+
if kind == "record":
|
|
359
|
+
record: Record = item
|
|
360
|
+
self.intake.push(_observation(record))
|
|
361
|
+
self.counters["records"] += 1
|
|
362
|
+
self._since_checkpoint += 1
|
|
363
|
+
if record.time is not None:
|
|
364
|
+
self._move_watermark(record.time)
|
|
365
|
+
self.handler.on_record(record, self._ctx)
|
|
366
|
+
elif kind == "tick":
|
|
367
|
+
self.counters["ticks"] += 1
|
|
368
|
+
self._move_watermark(item)
|
|
369
|
+
else:
|
|
370
|
+
self.counters["commands"] += 1
|
|
371
|
+
self.handler.on_command(item, self._ctx)
|
|
372
|
+
|
|
373
|
+
def _move_watermark(self, time: float) -> None:
|
|
374
|
+
if self.watermark is None or time > self.watermark:
|
|
375
|
+
self.watermark = float(time)
|
|
376
|
+
self.handler.on_time(self.watermark, self._ctx)
|
|
377
|
+
|
|
378
|
+
def _usable(self) -> None:
|
|
379
|
+
if self.closed:
|
|
380
|
+
raise TaskError(f"task {self.task_id!r} is closed")
|
|
381
|
+
if self.failed is not None:
|
|
382
|
+
raise TaskError(f"task {self.task_id!r} has failed ({self.failed}); reopen it to replay, or inspect it")
|
|
383
|
+
|
|
384
|
+
# ------------------------------------------------------------------ persistence
|
|
385
|
+
|
|
386
|
+
def _state(self) -> dict[str, Any]:
|
|
387
|
+
return {
|
|
388
|
+
"format": STATE_FORMAT,
|
|
389
|
+
"task_id": self.task_id,
|
|
390
|
+
"kind": self.kind,
|
|
391
|
+
"spec_id": self.spec_id,
|
|
392
|
+
"spec": self.spec,
|
|
393
|
+
"created_at": self.created_at,
|
|
394
|
+
"intake": {"spec": self.intake.spec(), "state": self.intake.state()},
|
|
395
|
+
"watermark": self.watermark,
|
|
396
|
+
"counters": dict(self.counters),
|
|
397
|
+
"streams": {name: {"segment": self._segments[name], "checkpoint": run.checkpoint()}
|
|
398
|
+
for name, run in sorted(self._runs.items())},
|
|
399
|
+
"outputs": {name: self.last_output(name) for name in sorted(self._outputs)},
|
|
400
|
+
"handler": self.handler.state(),
|
|
401
|
+
"audit": {"n": self._audit_n, "last": self._audit_last},
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
def checkpoint(self) -> None:
|
|
405
|
+
"""Save the whole task and empty the journal; then hand new audit entries to the sinks."""
|
|
406
|
+
if self.failed is not None:
|
|
407
|
+
raise TaskError(f"task {self.task_id!r} has failed; a failed task is not checkpointed")
|
|
408
|
+
state = self._state()
|
|
409
|
+
if not is_plain(state):
|
|
410
|
+
raise TaskError(f"task {self.task_id!r}: the handler's state is not plain JSON")
|
|
411
|
+
self._version = self._storage.save(self.task_id, state, expected=self._version, clear_journal=True)
|
|
412
|
+
self._since_checkpoint = 0
|
|
413
|
+
pending, self._pending = self._pending, []
|
|
414
|
+
self.engine._notify(pending)
|
|
415
|
+
|
|
416
|
+
def close(self) -> None:
|
|
417
|
+
if self.closed:
|
|
418
|
+
return
|
|
419
|
+
try:
|
|
420
|
+
if self.failed is None:
|
|
421
|
+
self.checkpoint()
|
|
422
|
+
finally:
|
|
423
|
+
self.closed = True
|
|
424
|
+
self._lock.release()
|
|
425
|
+
|
|
426
|
+
def __enter__(self) -> Task:
|
|
427
|
+
return self
|
|
428
|
+
|
|
429
|
+
def __exit__(self, *exc: object) -> None:
|
|
430
|
+
self.close()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _observation(record: Record) -> Observation:
|
|
434
|
+
return Observation("record", None, source=record.source, seq=record.seq)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _record_problem(r: Any) -> str | None:
|
|
438
|
+
if not isinstance(r, Record):
|
|
439
|
+
return f"expected a Record, got {type(r).__name__}"
|
|
440
|
+
if not isinstance(r.source, str) or not r.source or len(r.source) > 256 or not is_plain(r.source):
|
|
441
|
+
return f"source {r.source!r} must be a non-empty string of at most 256 characters"
|
|
442
|
+
if type(r.seq) is not int or not 0 <= r.seq <= _MAX_EXACT:
|
|
443
|
+
return f"seq {r.seq!r} must be an integer from 0 to 2**53"
|
|
444
|
+
if r.time is not None and (type(r.time) not in (int, float) or not math.isfinite(r.time)):
|
|
445
|
+
return f"time {r.time!r} must be a finite number of seconds, or None"
|
|
446
|
+
if not isinstance(r.data, dict) or not is_plain(r.data):
|
|
447
|
+
return "data must be a JSON object of plain values (finite numbers, text, lists, objects)"
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def shard_of(task_id: str, shards: int) -> int:
|
|
452
|
+
"""The shard that owns a task: a stable hash of its id, so every process agrees."""
|
|
453
|
+
if type(shards) is not int or shards < 1:
|
|
454
|
+
raise ValueError("shards must be a positive int")
|
|
455
|
+
return int(hashlib.sha256(task_id.encode("utf-8")).hexdigest()[:16], 16) % shards
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class Engine:
|
|
459
|
+
"""Opens tasks in a storage. One engine per process and shard; a task is open in one engine at a time."""
|
|
460
|
+
|
|
461
|
+
def __init__(self, storage: Storage, *, shard: tuple[int, int] = (0, 1), sinks: Sequence[Sink] = (),
|
|
462
|
+
checkpoint_every: int = 1000, clock: Clock = _time.time):
|
|
463
|
+
index, count = shard
|
|
464
|
+
if type(count) is not int or count < 1 or type(index) is not int or not 0 <= index < count:
|
|
465
|
+
raise ValueError("shard must be (index, count) with 0 <= index < count")
|
|
466
|
+
if type(checkpoint_every) is not int or checkpoint_every < 1:
|
|
467
|
+
raise ValueError("checkpoint_every must be a positive int")
|
|
468
|
+
if not (callable(clock) or clock == "event"):
|
|
469
|
+
raise ValueError("clock must be a function returning seconds, or 'event'")
|
|
470
|
+
self.storage, self.shard, self.sinks = storage, (index, count), list(sinks)
|
|
471
|
+
self.checkpoint_every, self.clock = checkpoint_every, clock
|
|
472
|
+
|
|
473
|
+
# the clock of the audit log and of the runs' provenance
|
|
474
|
+
def _time(self, task: Task) -> float:
|
|
475
|
+
if self.clock == "event":
|
|
476
|
+
return task.watermark if task.watermark is not None else 0.0
|
|
477
|
+
return float(self.clock()) # type: ignore[operator]
|
|
478
|
+
|
|
479
|
+
def _now(self, task: Task) -> Callable[[], float]:
|
|
480
|
+
return lambda: self._time(task)
|
|
481
|
+
|
|
482
|
+
def _notify(self, entries: Sequence[AuditEntry]) -> None:
|
|
483
|
+
for entry in entries:
|
|
484
|
+
for sink in self.sinks:
|
|
485
|
+
try:
|
|
486
|
+
sink(entry)
|
|
487
|
+
except Exception:
|
|
488
|
+
logger.exception("sink %s failed on audit entry %d of task %s", type(sink).__name__, entry.n,
|
|
489
|
+
entry.task_id)
|
|
490
|
+
|
|
491
|
+
def _check_shard(self, task_id: str) -> None:
|
|
492
|
+
check_task_id(task_id)
|
|
493
|
+
if shard_of(task_id, self.shard[1]) != self.shard[0]:
|
|
494
|
+
raise TaskError(f"task {task_id!r} belongs to shard {shard_of(task_id, self.shard[1])} of "
|
|
495
|
+
f"{self.shard[1]}, this engine serves shard {self.shard[0]}")
|
|
496
|
+
|
|
497
|
+
def exists(self, task_id: str) -> bool:
|
|
498
|
+
return self.storage.load(check_task_id(task_id)) is not None
|
|
499
|
+
|
|
500
|
+
def stored(self, task_id: str) -> dict[str, Any]:
|
|
501
|
+
"""The kind and spec of a stored task, to build its handler before `open`."""
|
|
502
|
+
loaded = self.storage.load(check_task_id(task_id))
|
|
503
|
+
if loaded is None:
|
|
504
|
+
raise StorageError(f"no task {task_id!r}")
|
|
505
|
+
state = loaded[1]
|
|
506
|
+
return {"kind": state["kind"], "spec": state["spec"], "spec_id": state["spec_id"]}
|
|
507
|
+
|
|
508
|
+
def create(self, task_id: str, kind: str, spec: dict[str, Any], handler: Handler) -> Task:
|
|
509
|
+
"""A new task; TaskError if the id is taken."""
|
|
510
|
+
self._check_shard(task_id)
|
|
511
|
+
if not is_plain(spec):
|
|
512
|
+
raise TaskError("the spec must be plain JSON")
|
|
513
|
+
lock = self.storage.lock(task_id)
|
|
514
|
+
try:
|
|
515
|
+
if self.storage.load(task_id) is not None:
|
|
516
|
+
raise TaskError(f"task {task_id!r} exists already; open it")
|
|
517
|
+
task = Task(self, task_id, kind, spec, handler, lock)
|
|
518
|
+
# a create that crashed before its first checkpoint may have left its "created" entry
|
|
519
|
+
task._stored_audit = [AuditEntry.from_json(e) for e in self.storage.audit_read(task_id)]
|
|
520
|
+
task.created_at = self._time(task)
|
|
521
|
+
for name in sorted(task._streams):
|
|
522
|
+
task._segments[name] = 0
|
|
523
|
+
task._runs[name] = task._new_run(name, 0, "default")
|
|
524
|
+
task._audit("created", None, {"kind": kind, "spec_id": task.spec_id}, [], None, None)
|
|
525
|
+
if task._stored_audit:
|
|
526
|
+
raise AuditError(f"task {task_id!r} has no state but an audit log; it is not a new task")
|
|
527
|
+
task.checkpoint()
|
|
528
|
+
return task
|
|
529
|
+
except BaseException:
|
|
530
|
+
lock.release()
|
|
531
|
+
raise
|
|
532
|
+
|
|
533
|
+
def open(self, task_id: str, factory: Callable[[dict[str, Any]], Handler]) -> Task:
|
|
534
|
+
"""Reopen a stored task: restore its checkpoint, replay its journal. `factory(spec)` builds the handler."""
|
|
535
|
+
self._check_shard(task_id)
|
|
536
|
+
lock = self.storage.lock(task_id)
|
|
537
|
+
try:
|
|
538
|
+
loaded = self.storage.load(task_id)
|
|
539
|
+
if loaded is None:
|
|
540
|
+
raise StorageError(f"no task {task_id!r}")
|
|
541
|
+
version, state = loaded
|
|
542
|
+
if state.get("format") != STATE_FORMAT or state.get("task_id") != task_id:
|
|
543
|
+
raise StorageError(f"task {task_id!r}: stored state has format {state.get('format')!r}")
|
|
544
|
+
task = Task(self, task_id, state["kind"], state["spec"], factory(state["spec"]), lock)
|
|
545
|
+
if task.spec_id != state["spec_id"]:
|
|
546
|
+
raise StorageError(f"task {task_id!r}: the stored spec does not match its id")
|
|
547
|
+
task._version = version
|
|
548
|
+
task.created_at = state["created_at"]
|
|
549
|
+
stored_streams = state["streams"]
|
|
550
|
+
if sorted(stored_streams) != sorted(task._streams):
|
|
551
|
+
raise TaskError(f"task {task_id!r}: the handler declares streams {sorted(task._streams)}, "
|
|
552
|
+
f"the stored task has {sorted(stored_streams)}")
|
|
553
|
+
for name, saved in sorted(stored_streams.items()):
|
|
554
|
+
s = task._streams[name]
|
|
555
|
+
run = Run.restore(s.procedure, saved["checkpoint"], topology=s.topology() if s.topology else None,
|
|
556
|
+
clock=self._now(task))
|
|
557
|
+
run.start(runtime="sequentialist")
|
|
558
|
+
task._runs[name], task._segments[name] = run, saved["segment"]
|
|
559
|
+
task.intake.restore(state["intake"]["state"])
|
|
560
|
+
task.watermark = state["watermark"]
|
|
561
|
+
task.counters = dict(state["counters"])
|
|
562
|
+
task._outputs = {name: (False, out) for name, out in state["outputs"].items()}
|
|
563
|
+
task.handler.restore(state["handler"])
|
|
564
|
+
task._audit_n, task._audit_last = state["audit"]["n"], state["audit"]["last"]
|
|
565
|
+
stored_audit = self.storage.audit_read(task_id)
|
|
566
|
+
task._stored_audit = [AuditEntry.from_json(e) for e in stored_audit[task._audit_n:]]
|
|
567
|
+
journal = self.storage.journal_read(task_id)
|
|
568
|
+
inputs: list[tuple[str, Any]] = []
|
|
569
|
+
for entry in journal:
|
|
570
|
+
if "record" in entry:
|
|
571
|
+
record = Record.from_json(entry["record"])
|
|
572
|
+
if not task.intake.delivered(_observation(record)):
|
|
573
|
+
inputs.append(("record", record))
|
|
574
|
+
elif "tick" in entry:
|
|
575
|
+
inputs.append(("tick", entry["tick"]))
|
|
576
|
+
elif entry["n"] >= task.counters["commands"]:
|
|
577
|
+
c = entry["command"]
|
|
578
|
+
inputs.append(("command", Command(c["name"], c["args"], c["actor"], c["reason"])))
|
|
579
|
+
if inputs:
|
|
580
|
+
logger.info("task %s: replaying %d journal entries", task_id, len(inputs))
|
|
581
|
+
task._process(inputs, IngestResult())
|
|
582
|
+
if task._stored_audit:
|
|
583
|
+
raise AuditError(f"task {task_id!r}: {len(task._stored_audit)} audit entries were written after the "
|
|
584
|
+
f"checkpoint and not reproduced by the replay")
|
|
585
|
+
task.checkpoint()
|
|
586
|
+
return task
|
|
587
|
+
except BaseException:
|
|
588
|
+
lock.release()
|
|
589
|
+
raise
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 Novikov Laboratories LLC
|
|
3
|
+
# Commercial licenses for use outside the AGPL: see COMMERCIAL.md
|
|
4
|
+
"""Exceptions of the sequentialist libraries."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SequentialistError(Exception):
|
|
9
|
+
"""Base class of every error raised on purpose by a sequentialist library."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SpecError(SequentialistError, ValueError):
|
|
13
|
+
"""A spec is invalid. `errors` lists every problem found, each as (JSON pointer, message)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, errors: list[tuple[str, str]], source: str = ""):
|
|
16
|
+
self.errors = list(errors)
|
|
17
|
+
self.source = source
|
|
18
|
+
head = f"{source}: " if source else ""
|
|
19
|
+
lines = [f"{path or '/'}: {message}" for path, message in self.errors]
|
|
20
|
+
super().__init__(head + ("; ".join(lines) if len(lines) < 4 else "\n " + "\n ".join(lines)))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StorageError(SequentialistError):
|
|
24
|
+
"""The storage cannot be used: a missing task, a malformed file, a wrong format."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TaskLocked(StorageError):
|
|
28
|
+
"""Another writer holds the task. A task has one writer at a time."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConflictError(StorageError):
|
|
32
|
+
"""The stored state changed since it was read: another writer saved it."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InvalidRecord(SequentialistError, ValueError):
|
|
36
|
+
"""A record cannot be accepted (no part of its batch was)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TaskError(SequentialistError):
|
|
40
|
+
"""A task cannot do what was asked: wrong shard, unknown stream, a closed or failed task."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 Novikov Laboratories LLC
|
|
3
|
+
# Commercial licenses for use outside the AGPL: see COMMERCIAL.md
|
|
4
|
+
"""Pseudonyms for identifiers of people, made before data reach a task.
|
|
5
|
+
|
|
6
|
+
The libraries store what they are given. Give them pseudonyms, not user
|
|
7
|
+
or patient ids: `pseudonymize` is HMAC-SHA-256 under a key that stays with
|
|
8
|
+
the data owner, so the pseudonym cannot be traced back without the key,
|
|
9
|
+
and the same id always gets the same pseudonym (which assignment and
|
|
10
|
+
aggregation need).
|
|
11
|
+
|
|
12
|
+
Under the GDPR and Federal Law No. 152-FZ, pseudonymised data remain
|
|
13
|
+
personal data as long as someone holds the key; only aggregates without
|
|
14
|
+
identifiers are anonymous. Keep the key out of the storage of the tasks,
|
|
15
|
+
and delete it when the pseudonyms must no longer be linkable.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import hmac
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pseudonymize(identifier: str, key: bytes | str) -> str:
|
|
24
|
+
"""64 hexadecimal digits; the same identifier and key always give the same pseudonym."""
|
|
25
|
+
if not isinstance(identifier, str) or not identifier:
|
|
26
|
+
raise ValueError("identifier must be a non-empty string")
|
|
27
|
+
secret = key.encode("utf-8") if isinstance(key, str) else bytes(key)
|
|
28
|
+
if len(secret) < 16:
|
|
29
|
+
raise ValueError("the key must have at least 16 bytes")
|
|
30
|
+
return hmac.new(secret, identifier.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
File without changes
|