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. pmap is list(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_JOBS is the whole knob. 1 forces serial — straight tracebacks, honest profiles, concurrency suspicions ruled in or out without an edit; N caps 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 --check at 2.3× — the command a contributor actually waits on, since it is the one with the network in it.
  • Every pmap call site is a marked seam: the inventory of what runs wide is grep 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=1 is 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.