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