ADR-026: One ordered pmap over threads; width is an environment variable
Status Active · Filed 2026-08-04 · Issue #7 · Influenced by ADR-012
Context
Everything Luria does is embarrassingly parallel at some unit and shares no state: a scheme’s view is a pure function of the tree, a journal’s books likewise, a file’s scan reads, a probe HEADs a URL. #7 asks for the parallelism now, while the machinery is young — the argument being that seams are cheap to cut early and expensive to retrofit — with the strategy left open.
Measured before deciding (luria on this repo, 24 ADRs / 9 DPs / 11
entries): index 0.92s, lint 2.68s, remotes --check 6.59s. The check is
serial network round-trips; the rest is file reads and regex.
Decision
One primitive, parallel.pmap(fn, items) — a thread pool returning results
in input order — applied at three seams:
- Render units in
outputs(): each scheme and each journal renders as one unit; the merged dict is assembled from ordered results. - Per-file scans in the bare-reference lint.
- Per-URL probes in
remotes --check.
Three properties are the decision; the pool is incidental:
- Ordered, not as-completed. The staleness check diffs rendered text and
the lint prints a report; both must read identically at any width, or
parallelism would trade determinism for milliseconds.
pmapislist(map(...))with a pool inside — same order, same exception behaviour — so going wide is a one-word change at a call site, never a restructuring. - Threads, not processes. The workload that wins is I/O (network round-trips overlap; so do file reads), and threads share the config cache for free. A process pool would add pickling and spawn cost for no measured gain at this size.
LURIA_JOBSis the whole knob.1forces serial — straight tracebacks, honest profiles, concurrency suspicions ruled in or out without an edit;Ncaps the pool; unset means a fixed default of 8, chosen because the width should track latency overlap, not cores.
Measured after, honestly: remotes --check 6.59s → 2.86s — the
round-trips were the wall-clock, and overlapping them is the whole win.
index and lint are a wash (lint median 2.74s serial vs 2.89s wide):
scanning is regex-CPU, and under the GIL thread-width buys CPU work nothing
while costing pool overhead. Those two seams are structure, bought now at
~5% of a three-second command, against the growth the issue anticipates —
and the seam is exactly where a ProcessPoolExecutor (or free-threaded
Python) swaps in if render units ever measure in seconds.
Alternatives considered
- asyncio. Right shape for the probes, wrong cost for the codebase:
every caller between the CLI and the HEAD request would need an async
signature, for the same overlap a thread pool gives behind an unchanged
map. An event loop earns its rewrite at thousands of sockets, not twenty. - Processes now. The only work a process pool would speed up is the regex scanning, and at 2.7s total the pickling and spawn overhead eats the margin. The honest sequencing is: cut the seam with threads, swap the executor when a measurement says so.
- Parallelize inside units (per-book, per-tag-page). Finer grain, more scheduling, same total work — and units already outnumber the pool width only in the scans, where the grain is right at one file.
- Nothing until it hurts. The issue’s own argument against: the seams are one function today and a refactor later. The probes alone already pay for the module.
Consequences
luria remotes --checkat 2.3× — the command a contributor actually waits on, since it is the one with the network in it.- Every
pmapcall site is a marked seam: the inventory of what runs wide isgrep pmap, and each site is one word away from serial. - Renderers and scanners must stay effectively pure — reads of the config
cache and the tree, no shared mutation. They already were; the seam makes
it a requirement rather than a habit, which is worth stating: a future
renderer that writes global state will fail at width, and
LURIA_JOBS=1is the first diagnostic to reach for. - The fixed default width is a guess with a knob, not a measurement — if a project’s corpus makes 8 wrong in either direction, the environment variable is the remedy and a config key can follow demand.