ADR 0039: Bounded concurrency over chunks
Status: accepted. Amended by [0041 the consolidation pass is
decide-then-emit](0041-the-consolidation-pass-is-decide-then-emit.md), which
moves CallLimiter from extraction to domain so consolidation can share
one ceiling with the pipeline. The Decision below stands unchanged.
Decision: ExtractionPipeline and build_graph take a concurrency
parameter (default 1). Chunks are extracted in consecutive wavefront
batches of that size โ every chunk in a batch fires with the same prompt,
computed once before the batch runs, and the batch is awaited together.
Carryover accumulates between batches, from each batch's results in chunk
order, never as an individual call returns. A single CallLimiter is passed
alongside concurrency and admits at most that many callers at once against
every call the pipeline makes to the endpoint โ gleaning's extra call per
chunk and build_graph's embedding call included, not only the chunk
extraction loop.
Context
The module docstring for src/redstring/extraction/pipeline.py had already
argued, before this decision, that the pipeline should not fire chunks
concurrently: the reference deployment is a single-GPU llama.cpp server, and
firing ten requests at once "converts a queue into ten timeouts". That
argument was never wrong. It was an argument for a bound on how many calls
may be in flight at once, and until now it had been implemented as the
tightest possible bound โ the constant 1, wired into a plain sequential
loop with no parameter naming it as a choice.
bench/BASELINE.md turned the argument into a number: a 32,790-character
document, chunked at the library's default size, is 14 strictly serial calls
at roughly 24 seconds each โ on the order of six minutes for one document,
entirely a function of how many calls the backend can be asked to do at once,
not of anything the model itself is slow at per call. That is the forcing
measurement behind this ADR; the reasoning behind the bound was already
correct, and what changed is that "one" stopped being the only value anyone
could argue for.
Why carryover survives concurrency, and why this is a wavefront rather than a `gather`
Entity identity in this library is derived from the name โ
extraction.mapping.entity_id_for hashes `(tenant, source, entity type,
normalized name)` โ so a chunk that spells a name differently from an earlier
chunk does not get deduplicated by the fold; it manufactures a second entity,
which consolidation then pays a model call to resolve. The pipeline's
carryover mechanism exists to suppress exactly that: each chunk after the
first is told, in its system prompt, a bounded list of (name, entity_type)
pairs the chunks before it found, so a later mention has something to spell
consistently against.
Firing every chunk in the document at once would delete that signal
entirely โ every chunk would be blind to every other, and the naming drift
carryover exists to prevent would recur at the rate a fully concurrent
extraction produces boundaries, which is every chunk boundary in the
document rather than only the ones inside one wavefront. A batch of size
concurrency bounds how much of the document a chunk is blind to: it sees
carryover from every batch before it, and only chunks in its own batch are
invisible to each other. That is why the pipeline groups chunks into
wavefronts and folds carryover in between them rather than reaching for
asyncio.gather over the whole chunk list โ the two are the same amount of
code and very different amounts of naming drift.
What made it safe
Nothing about extraction's correctness depends on the order chunks are
processed in, provided the merge that combines their results is
order-independent โ and that was already a decision this project had made
and documented, not a new one made for this change.
0010 one total order for preference
establishes that domain.preference is a total order over one id bucket, and
that totality is what makes merge_extractions insensitive to the sequence
in which chunk results arrive. Before this change that property was
asserted as a fact about a serial pipeline where the sequence never varied
in practice; concurrency makes it load-bearing in a way it was not before,
because a bounded wavefront genuinely can complete its members in more than
one order depending on which one the backend answers first. What backs the
decision is not new reasoning but an old one now being exercised: the
property is asserted directly by
tests/unit/extraction/test_merging.py::test_the_fold_does_not_depend_on_the_order_of_its_parts,
which generates chunk results and checks the fold agrees across permutations
of the order they are combined in โ the same shape as the two totality
properties ADR 0010 already required of preference and
relationship_preference, aimed at the fold that consumes them rather than
at the order itself.
The ceiling is on calls in flight, not on batch size
concurrency sets the batch size for chunk extraction. It is not the whole
story of what may be talking to the endpoint at once: gleaning
(redstring.extraction.gleaning) fires a second call per chunk when enabled,
and build_graph runs an embedding call after an extraction it does not
own. A batch-size parameter alone would leave those two paths unbounded,
which defeats the argument that motivated the bound in the first place โ the
backend's queue depth does not care which code path issued the call. So the
bound is expressed as a CallLimiter, a single object constructed from
concurrency and threaded through every call site that reaches the
endpoint, admitting at most concurrency callers through it at once
regardless of which of those call sites they came from.
Consequences
**concurrency=1 is byte-identical to the pipeline before this parameter
existed.** A batch of size one is the same calls, the same prompts, in the
same order, so every existing caller is unaffected and the default did not
move.
**A caller raising concurrency trades naming stability for wall clock โ
and the measurement says the trade is not the one that binds.** Widening the
wavefront shortens the run at the cost of more chunks being mutually blind to
each other's carryover within a batch. bench/CONCURRENCY.md now records the
sweep, and it does not show that: naming drift did not track concurrency in
any readable way. The drift that is measurable tracks chunk size
instead, rising as chunks shrink โ because a smaller chunk means more
boundaries for a name to drift across, not because a wider batch does. The
measurements are in that file rather than here, per
.claude/rules/recurring-defects.md ยง5.
The consequence that replaced it is arithmetic, and callers hit it first:
effective concurrency is min(K, chunks in the batch), so raising K
past a document's chunk count does nothing at all. The two knobs cannot be
tuned independently, which docs/how-to/tune-ingestion-throughput.md covers
for callers.
The ceiling composes with a backend serving other tenants. Because the
bound is calls in flight rather than a property of one pipeline run, a
caller who knows their endpoint's real capacity can set concurrency to
share it correctly across concurrent callers of the library, rather than
each caller needing to reason about every other caller's batch size.
CallLimiter widens the public surface by one name, per ADR 0006's gate:
ExtractionPipeline.__init__ takes limiter: CallLimiter | None, so the type
had to be exported or the signature would reference something a caller cannot
construct. Exporting it is also the thing that makes the previous paragraph
possible for a caller to act on directly โ sharing one ceiling across several
build_graph calls against one backend needs the type in hand, not only the
parameter name.
What this does not do
No per-chunk progress reporting โ the pipeline still returns from one
opaque await, and giving a caller visibility into a run in flight is a
separate deliverable. No concurrency across documents โ the bound is scoped
to one pipeline's calls, and running multiple documents through one
CallLimiter is a composition question left to the caller. No adaptive
tuning of the bound โ concurrency is a number the caller sets and the
pipeline does not infer or adjust it from observed latency or failures.