Architecture¶
How the pieces fit together, and which module owns what. Pair this with
design.md, which records why the data model looks the way it does.
Module map¶
expr_tracker/
├── __init__.py public names re-exported for `import expr_tracker as et`
├── tracker.py the functional API (init/log/history/finish/artifacts/summary)
├── run.py Run object + global singleton + backend fan-out
├── summary.py run.summary mapping, persisted as summary.json
├── artifacts.py Artifact + project-scoped ArtifactStore
├── encoders.py JSON coercion (numpy/torch/pydantic/dataclasses/...)
├── cli.py `et history` / `et rules` / `et alert`
├── history/
│ ├── store.py HistoryStore: open-row assembly, cache, query planning
│ ├── writer.py JsonlWriter: buffered append, sparse index, meta sidecar
│ ├── reader.py JsonlReader + the offline read_history entry points
│ ├── codec.py RecordCodec: metric values -> JSON lines, warn-once
│ ├── series.py MetricSeries: per-metric numeric buffers
│ └── frame.py projection + dict/pandas/polars output
└── alerts/
├── __init__.py public alert API + config resolution + engine wiring
├── models.py AlertLevel/Message/ChannelConfig/WebhookPolicy/AlertRule
├── engine.py rule compilation, state machine, watchdog
├── dispatch.py routing, rate limiting, dedup, retry, async worker
├── backends/ lark, slack, dingtalk, wecom, webhook, email, callable
└── expr/ lexer, parser, AST nodes + builder, functions, evaluator
Dependency direction¶
tracker.py ──> run.py ──> history/ (always)
└──> alerts/ (lazily, to avoid an import cycle)
└──> artifacts.py, summary.py
alerts/expr ──> history/series.py (read-only: evaluation needs metric windows)
Rules:
history/never imports fromalerts/. The alert engine readsMetricSeries, which lives inhistory/because it is populated on the write path.alerts/never importsrunat module scope;alerts/__init__imports it inside functions sorun.pycan importalertslazily without a cycle.expr/is self-contained apart fromMetricSeries, so the DSL can be parsed, validated and replayed without a live run (this is whatet rules testuses).
Write path¶
et.log(data, step, commit)
│
▼
Run.log ──> Summary.observe(data) # last value per metric
└──> HistoryStore.log
│ encode once (jsonable_encoder)
│ merge into the open row for the current step
▼ commit when the step advances / commit=True / finish / timeout
HistoryStore._emit
├── _store_row(record, line) (holds the store lock)
│ ├── cache.append((row, step, line)) # ordinal allocated here
│ ├── MetricSeries.add(...) # feeds alert evaluation
│ └── JsonlWriter.enqueue(step, row, line)
├── writer.flush() / schedule_timer (outside the lock)
├── _evict()
└── _notify(record) # screen + alert engine
│
▼
on_commit ──> AlertEngine.on_step(record) ──> Dispatcher (async)
└──> other backends (wandb, trackio, custom)
The ordinal allocation and the writer enqueue happen under one lock so that row ordinal N is always physical line N; the query planner depends on it.
HistoryStore.log returns the _step the metrics landed on, or None when the call
was rejected. Run.log forwards that resolved step (and the resolved commit flag) to
the remote backends, so a backend's row layout matches the local history instead of
drifting on its own counter — two log() calls for one step stay one step everywhere.
Test the result with is None, since step 0 is falsy.
log() never branches on the open row directly: _switch_open_row(step) points it
at the requested step (or reuses it when no step is given) and hands back whatever row
that displaced. JsonlWriter._track_line is likewise the single place that folds a line
into the counters and index, shared by live appends and by resume rescans — so the
two can never drift apart.
Read path¶
et.history(n, ...)
│
▼
HistoryStore.get ──> _collect ──> _collect_tail | _collect_range
│ │ │
│ │ ├─ _view_* one lock: cache rows + boundary
│ │ └─ _older_* only when the view is incomplete
│ ├─ _open_rows the uncommitted row, if wanted and in range
│ └─ _take_steps merge by step, keep the newest n
▼
frame.project(...) ──> frame.to_output(dict | pandas | polars)
Both query kinds share one shape: take a _CacheView under a single lock, and touch
the disk only when the view reports it cannot answer on its own.
view = self._view_tail(steps) # or _view_range(step_range)
records = view.records()
if view.complete:
return records
return self._older_tail(view, steps, records) + records
_CacheView.complete folds together the three reasons the disk can be skipped: the
cache holds an older step, nothing was ever evicted, or there is no writer. The
scans themselves (_scan_tail, _scan_range, _nearer_front, _newest_steps) are
plain functions over a sequence, so they hold no lock and are tested directly.
et.history(run=...) bypasses the store entirely and reads through JsonlReader
(read_history), which is what makes offline analysis and et rules test possible.
Rows versus steps¶
A step normally occupies one physical row, but a max_open_seconds timeout followed
by more data for that step writes a patch line, so one step can span several rows.
The two are kept strictly separate in the read path:
JsonlReader.tail_rows(n)returns physical rows;JsonlReader.tail(n)returns merged steps and widens its own read until it has one whole step to spare.HistoryStore._collect_tail(steps)works in rows, then_take_stepsmerges and trims by step, soget(n)never returns a half-merged oldest row.- The cache stores each row's step next to its bytes, so
_view_tailsnapshots the exact rows covering n steps without parsing any JSON, and stops as soon as one older step proves nothing is truncated. parse_rowsdrops lines that are corrupt or carry no integer_step, so every record a reader returns can be ordered and merged by step.
Query cost¶
Queries never scan the whole cache:
| query | cost |
|---|---|
history(n) |
O(rows returned), independent of cache size |
history(step_range=...) |
O(distance from the nearer end of the cache) |
history(-1) |
O(run length) — it has to materialise everything |
Disk fallback only happens once rows have been evicted or the run was resumed;
stats()["disk_prefix"] reports whether that is the case.
Concurrency model¶
| Lock / thread | Owner | Protects |
|---|---|---|
HistoryStore._lock (RLock) |
store | open row, cache, series, row ordinal, writer enqueue |
JsonlWriter._lock (RLock) |
writer | buffer, index, meta fields |
JsonlWriter._write_lock |
writer | batch swap + file append (ordering) |
open-row threading.Timer |
store | commits a stale open row; carries a generation token |
buffer threading.Timer |
writer | flushes records that sat in memory too long |
CompiledRule.lock |
engine | one rule's state machine transitions |
| dispatch worker thread | dispatcher | drains the send queue; drained at exit |
| watchdog thread | engine | evaluates time-based rules when no logs arrive |
Lock order is always _write_lock → _lock; nothing acquires them the other way.
Extension points¶
| I want to add... | Where it goes |
|---|---|
| a notification channel | subclass AlertBackend, call register_backend("type", cls) |
| an expression function | add to expr/functions.py (WINDOW_FUNCS / SCALAR_FUNCS / SPECIAL_FUNCS) |
| an output format | add a branch in history/frame.to_output |
| a metrics backend | pass any object with init/log/finish in backends=[...] |
| an artifact storage mode | extend ArtifactStore._materialise (default is copy: link shares the caller's inode) |
| a value type to encode | extend history/codec.py (and encoders.py for the coercion) |
| a history tunable | add a field to HistoryOptions; it is validated and documented automatically |
Configuration¶
Every history tunable lives on HistoryOptions, a frozen dataclass validated once in
HistoryStore.init(). Unknown names raise TypeError listing the valid ones, so a
typo like cache_byte= fails loudly instead of silently keeping the default. Run
state is initialised in exactly one place, HistoryStore._reset(), which both
__init__ and init() call — re-initialising cannot leave a field behind.
Invariants¶
See design.md §G. The short version: metadata is written last,
undurable rows are never evicted, the cache/disk boundary is addressed by row
ordinal, alerts are evaluated once per committed step, and expression evaluation
degrades to UNKNOWN instead of raising.
Naming¶
The write path reads as one sentence, so the stages are named after what they do to the open row:
Two words are reserved and mean exactly one thing each:
| word | meaning |
|---|---|
| merge | combining rows that share a _step (merge_steps, _needs_merge). Never used for folding metrics into a row — that is _update_open_row. |
| row | one physical JSONL line. A step may span several rows, so tail_rows/parse_rows return rows while tail/parse return merged steps. |
stats() distinguishes rows_on_disk (lines in the file) from rows_logged
(ordinals this process handed out); they differ only after records are dropped.