v0.5.0 Plan — Structural Quality¶
Status: implemented (branch v0.5-structural-quality). This
document is the implementation contract for v0.5.0: every feature
carries its API design, semantics, edge cases, test plan, and the files
it touches. Deviations during implementation are recorded in the review
amendments log below, not silently absorbed.
Read together with the Roadmap and DevX Principles.
Theme¶
v0.4 made pyarallel correct for the rate-limited-API niche (after two adversarial review cycles). v0.5 makes it structurally sound: streaming that is actually streaming, early abort on dead APIs, the metadata API jobs need for accounting, and the two debts deferred from the v0.4 review (checkpoint keys that survive reordering; the four-fold decorator signature duplication).
Release note: v0.4.0 was never published to PyPI. v0.5.0 will be the first public release of the repositioned library and includes everything from both versions.
Phase order and rationale¶
| Phase | Work | Why this order |
|---|---|---|
| 1 | Sliding-window streaming engine (sync + async) | Biggest structural change; phases 2–3 build on the new engine |
| 2 | ordered=True + on_progress for streaming |
Nearly free once the window engine exists |
| 3 | max_errors |
Touches the same collect loops the engine rewrite just settled |
| 4 | Result metadata: ItemResult.attempts/.duration, ParallelResult.ok_values() |
Independent; needs the retry wrapper contract change |
| 5 | checkpoint_key= |
Independent; schema version bump |
| 6 | Parity batch: sequential=, async timeout=, contextvars, worker_init=, max_tasks_per_worker= |
Small pass-throughs, safest near the end |
| 7 | Decorator signature dedup via Unpack[TypedDict] |
Last — do it once, after every signature above has settled |
| 8 | Docs, roadmap refresh, version bump, review cycle | Gate before merge |
Each phase lands as its own commit with regression tests, on a single
v0.5-structural-quality branch. Two adversarial review cycles are part
of the plan, not an afterthought: one after Phase 3 (the engine rewrite
is the risk concentration), one before merge.
Phase 1 — Sliding-window streaming engine¶
Problem (from the v0.4 adversarial review)¶
parallel_iter / async_parallel_iter process in batches with a barrier:
one straggler stalls the entire next batch. Worse, without
batch_size the sync version materializes the whole input iterator and
the async version builds a full list — the "constant memory" claim is
only true with batch_size set.
Design¶
Replace the batch loop with a bounded in-flight window:
- Maintain at most
windowsubmitted-but-unyielded items. - When any task completes, yield its result and submit the next input item. No barriers; a straggler delays only itself (unordered mode).
- Input is consumed lazily, exactly
windowahead of yields — true for generators with nobatch_sizerequired.
Window size. No new parameter. window = batch_size when given
(preserving batch_size as the memory-bound knob, now documented as
"maximum items in flight"), else 2 × effective_workers. The factor 2
keeps workers saturated while the driver loop runs. (Review amendment:)
effective_workers is workers when the caller set it, else the
stdlib default — parallel_iter(..., workers=2) must window at 4, not at
the thread-pool default of 64:
def _effective_workers(workers: int | None, executor: ExecutorType) -> int:
if workers is not None:
return workers
if executor == "thread":
return min(32, (os.cpu_count() or 1) + 4) # ThreadPoolExecutor default
return os.cpu_count() or 1 # ProcessPoolExecutor default
Sync driver (core.py): as_completed cannot accept new futures, so
the loop uses concurrent.futures.wait(..., return_when=FIRST_COMPLETED):
in_flight: dict[Future, int] = {}
indexed = enumerate(items_iterator)
# prime the window (rate limiter consulted per submit, as today)
while in_flight:
done, _ = wait(in_flight, return_when=FIRST_COMPLETED)
for future in done:
idx = in_flight.pop(future)
yield _item_result(idx, future)
submit_next_if_any()
Async driver (aio.py): same shape with asyncio.wait(...,
return_when=FIRST_COMPLETED) over tasks; the internal queue and
per-chunk accounting are deleted.
Cleanup contract (amended for honesty): caller breaking out of the
loop closes the generator; finally stops submitting, cancels
not-yet-started futures, and shuts the pool down without waiting.
Sync tasks already running in a thread/process cannot be interrupted
and finish in the background — only async tasks are truly cancelled.
Docs and docstrings use exactly this wording; no "cancels in-flight"
overclaim.
Behavior changes to document loudly¶
batch_sizefor the streaming APIs changes meaning from "chunk size with a barrier between chunks" to "in-flight bound, no barrier". Pre-1.0, no deprecation dance — changelog + docs state it plainly.- Streaming without
batch_sizeno longer materializes input. Strictly better, still called out. parallel_mapbatching is untouched in this phase — the collected path keeps its existing plan/batch machinery.
Edge cases¶
- Empty input → generator yields nothing, pool never does work.
retry/rate_limit/task_timeout(async) compose exactly as today — they wrap the task fn, not the driver.- Exception raised by
itemsiterator mid-stream → propagate to the caller after cancelling in-flight work (add a test; today's behavior is accidental).
Tests¶
- Straggler does not stall: item 0 sleeps 0.5s, items 1–9 are fast → first 9 yields arrive before item 0 (assert by receipt order + a generous elapsed bound).
- Laziness: instrumented generator; after consuming 3 results, assert the source advanced ≤ 3 + window.
- Early
break: no new submissions after generator close, and pending unstarted futures are cancelled; already-running work is bounded by the window size (assert completed-after-break ≤ window). - Empty input; retry composition; limiter composition; async mirrors of all of the above.
Phase 2 — ordered=True and streaming progress¶
ordered=True¶
New keyword on parallel_iter, async_parallel_iter, .stream():
yield in input order instead of completion order.
- Implementation: reorder buffer on top of the Phase-1 driver.
next_to_yieldcounter + dict of completed-but-early results; on each completion, drain the run of consecutive indices. - Memory bound (review amendment — the naive claim was false): refilling on every completion is unbounded: with item 0 slow and successors fast, each completion would admit new work while results pile up behind index 0. The enforced invariant is
len(in_flight) + len(reorder_buffer) <= window
Submission is gated on that sum, not on completions: after draining
yieldable results, submit only while the sum is below the window. When
a straggler blocks the buffer, admission stalls — that is the
backpressure working, not a bug. Invariant stated in the docstring.
- Default stays ordered=False (completion order), matching today.
on_progress for streaming¶
Same callback contract as parallel_map: callback(done, total) fired
per completed item (before its yield); total is items seen so far for
unsized inputs — same caveat, same wording in docs.
Tests¶
ordered=Truewith reversed completion times yields input order.- Reorder buffer bound: slow-FIRST workload over a large input, assert
peak
in_flight + buffered≤ window (instrumented driver hook), and that the source iterator advanced ≤ yields + window. - Progress: final call is
(n, n)for sized input; monotonedone.
Phase 3 — max_errors¶
Stop paying for a dead API: abort after N failures, return partial results.
API¶
parallel_map(fn, items, max_errors=10) # sync map
await async_parallel_map(fn, items, max_errors=10)
parallel_iter(fn, items, max_errors=10) # streaming: stop yielding
max_errors: int | None = None;None= today's behavior. Validated>= 1.- A "failure" is counted after retries are exhausted (a retried-then- succeeded item is not a failure).
Semantics¶
- Collected (map) — requires an admission change (review amendment):
today the non-batched collected path submits/creates every task
before observing a single completion, so "stop submitting at N
failures" would be a no-op for a 10k-item dead API. Therefore: when
max_errorsis set, collected map admits work through the Phase-1 bounded window (window =2 × effective_workers; async uses the sameasyncio.wait(FIRST_COMPLETED)admission loop instead of creating all tasks upfront). Withoutmax_errors, submission behavior is unchanged. On reaching the threshold: stop admitting, cancel pending unstarted work, mark every not-completed slot with_Failure(Aborted(...)), return the partialParallelResult— mirror of thetimeout=machinery. - New exception type:
class Aborted(RuntimeError)inresult.py, exported. Message:"aborted after N failures (max_errors=N)". Items that never ran are distinguishable from items that failed —isinstance(exc, Aborted). - Streaming: yield the failures that occurred, then end the stream (no placeholder items for unseen input — the stream just stops). Document that a consumer can detect abort by counting error items.
- Async: children can't be reaped mid-
TaskGroupwithout help — a sentinel_AbortRunexception raised by the counting task cancels siblings; caught withexcept* _AbortRun, then unfinished slots are marked. This mirrors theexcept* CheckpointErrorpattern from v0.4.
Edge cases¶
max_errorslarger than input size → never triggers.- Interaction with
timeout=: whichever fires first wins; both mark their own distinguishable failure types. - Interaction with
checkpoint=: completed successes are already persisted; the aborted/unrun items simply run next time. (This combo — checkpoint + max_errors — is the "dead API overnight job" story; add an integration test for exactly it.)
Tests¶
- Sync/async map: first 10 items fail instantly, 10k total,
max_errors=10→ wall time bounded,failures()contains ~10 real + restAborted,successes()preserved for anything that finished. - Admission bound (the point of the feature): instrumented source iterator proves total submissions ≤ abort-trigger point + window — a dead API costs tens of calls, not thousands.
- Retries don't double-count; success-after-retry counts as success.
- Streaming stops; checkpoint+max_errors resume test.
Phase 4 — Result metadata¶
ItemResult.attempts and ItemResult.duration¶
@dataclass(init=False, frozen=True, slots=True)
class ItemResult[R]:
index: int
value: R | None
error: Exception | None
attempts: int = 1 # attempts actually made (1 = no retry)
duration: float = 0.0 # seconds from first attempt to outcome, incl. backoff sleeps
- Definition of
duration: wall time inside the task wrapper — from the start of attempt 1 to final success/failure, including retry sleeps, excluding queue wait. That is the number you need for latency accounting; state the definition in the docstring. - Plumbing: the engines wrap every task in a timing shim returning an
internal
_Outcome(value_or_failure, attempts, duration)(a small picklable dataclass — module-level, works underexecutor="process")._run_with_retryreports attempts through it instead of returning the bare value. Streaming buildsItemResultfrom_Outcomedirectly. - Scope decision, explicit: collected
parallel_mapdoes not grow a per-item metadata API in v0.5.ParallelResultstores raw values; bolting metadata onto it means a parallel array or a wrapper type — real surface for unproven demand. Revisit if users ask. (Recorded so the next reviewer knows it was a choice, not an omission.)
ParallelResult.ok_values()¶
def ok_values(self) -> list[R]:
"""Values of successful tasks only, in input order. Never raises."""
return [v for _, v in self.successes()]
The most common partial-failure path ([v for _, v in result.successes()])
becomes one call. Update docs examples that currently spell out the
comprehension.
Tests¶
- attempts/duration on streaming success and failure, with and without
retry; duration ≥ known backoff sleep; process-executor pickling of
_Outcome. ok_values()on full success, partial failure, empty.
Phase 5 — checkpoint_key=¶
Problem (deferred from v0.4 review)¶
Checkpoint rows are keyed by position. Prepending one item to the input shifts every index; the fingerprint guard then forces a full recompute — safe, but the resume value evaporates exactly when jobs evolve.
API¶
parallel_map(fetch, users, checkpoint="run.ckpt",
checkpoint_key=lambda u: u.id)
checkpoint_key: Callable[[T], str | int | bytes] | None = Noneonparallel_map,async_parallel_map,.map(). Requirescheckpoint=(setting it alone raisesValueError).Nonekeeps positional keying semantics (same behavior, new on-disk encoding — see schema below).
Semantics¶
- Row key becomes the user key. Encoding is type-tagged and reversible
(review amendment — plain TEXT normalization would collide
1,"1", andb"1"):i:<decimal>for ints,s:<text>for strings,b:<base64>for bytes. Positional mode writesi:<index>. The item fingerprint check stays: key = which row, fingerprint = has the payload changed. A changed item under the same key recomputes. - Duplicate keys are an error. Track keys seen during submit;
a duplicate raises
CheckpointErrornaming the key — last-write-wins silently corrupting resume is exactly what this library refuses to do. - Key function errors propagate at submit (fail loud, run aborts).
- The key function itself is not part of the task signature: changing it changes the keys, which simply misses the cache — safe by construction.
Schema migration — one rule, no contradictions (review amendment)¶
- Schema v2:
results(key TEXT PRIMARY KEY, fingerprint BLOB, value BLOB)+metarowstask_signatureandschema_version = '2'. - Any file without
schema_version = '2'fails closed with aCheckpointErrorinstructing deletion — including v0.4-era positional files. There is no "old positional files stay valid" path and no silent migration: one rule. v0.4.0 was never released, so real-world exposure is nil; the guard is for our own dogfooding files.
Tests¶
- Prepend an item with
checkpoint_key→ only the new item runs. - Reorder inputs → zero recompute.
- Duplicate key →
CheckpointError; raising key fn → propagates. - Cross-type non-collision: keys
1,"1",b"1"produce three distinct rows. - Changed payload under same key → that row recomputes.
- v0.4-format file (positional, no schema_version) → fail-closed error.
Phase 6 — Parity batch¶
Five small items. Each is a pass-through or a bypass; none may disturb the engine loops beyond a parameter.
6.1 sequential=True (sync only)¶
- On
parallel_map,parallel_starmap,parallel_iter, and sync.map()/.starmap()/.stream(). Runs every item inline in the calling thread: no pool, real stack traces, working breakpoints, deterministic ordering. - Honors
rate_limit,retry,checkpoint,on_progress,max_errors.timeout=is checked between items only (an in-flight item cannot be interrupted — documented).workersis ignored withsequential=True; passing both is allowed (no error) so a single env flag can flip production code into debug mode. - Async: not needed —
concurrency=1already serializes; docs say so.
6.2 Async timeout= (total wall-clock parity)¶
async_parallel_map(..., timeout=30.0)wraps the batch loop inasyncio.timeout(). On expiry: cancelled children raiseCancelledError(aBaseException— deliberately not caught by the itemexcept Exception),_PENDINGslots become_Failure(TimeoutError(...))via the existing_mark_timeout_indiceshelpers, unseen lazy input appends timeout failures — exact mirror of the sync contract, same failure text.
6.3 Context variable propagation (sync thread executor)¶
- Capture location is the correctness pivot (review amendment):
contextvars.copy_context()is called in the submitting thread, once per item, at submit time —pool.submit(ctx.run, task_fn, item). Calling it inside the worker would copy the worker's (empty) context and change nothing. Per-item capture is also what makes concurrent execution legal: a singleContextcannot be entered twice at once, andContexthas no public.copy()— fresh capture per submit is the mechanism. Cost: one HAMT copy per item, negligible. - Precedence vs
worker_init: the initializer runs in the worker's own context; contextvars it sets are invisible to tasks (which run under the caller's captured context). Per-worker state belongs in globals or thread-locals, not contextvars — documented. - Default on, no parameter. Today worker threads see an empty context — correlation IDs silently vanish; propagation is strictly more correct and matches what asyncio tasks already do. Writes inside tasks stay isolated (they land in the copy) — document.
- Process executor: contexts don't pickle; skipped, documented.
6.4 worker_init=¶
worker_init: Callable[[], None] | None = None→ executorinitializer=. Thread and process (process requires picklable — reuse the fail-fast pickle probe pattern fromRetry).- Use case in docs: open one DB connection / load one model per worker.
6.5 max_tasks_per_worker=¶
max_tasks_per_worker: int | None = None→ ProcessPoolExecutormax_tasks_per_child=. Withexecutor="thread"→ValueError(explicit semantics beat silent ignore).
Surface accounting (owed to the v0.4 review)¶
parallel_map grows from 8 keywords to 13. That is the workhorse
function of a policies library and each keyword is orthogonal, boolean-
or-scalar, and independently documented — but the maintenance cost of
wide signatures is exactly what Phase 7 pays down. No config object:
explicit keywords remain the interface (DevX principle 2; a config
object would be indirection without removing any concept).
Tests¶
sequential=True: breakpointability isn't testable, but determinism is — ordering, single-thread identity (threading.get_ident()equal across items), rate-limit pacing still applied, checkpoint works.- Async timeout: partial results, slot marking, lazy-input remainder, parity of failure text with sync.
- contextvars: a
ContextVarset by the caller is visible in tasks (thread executor); writes in tasks don't leak back; process executor documented-skip test asserts no crash. worker_init: initializer ran per worker (count via file/queue); unpicklable init + process → fail-fastValueError.max_tasks_per_worker: accepted with process;ValueErrorwith thread.
Phase 7 — Decorator signature dedup¶
Problem (deferred from v0.4 review)¶
_BoundParallel.map, _ParallelFunc.map, _BoundAsyncParallel.map,
_AsyncParallelFunc.map hand-copy the same keyword list; a signature
change touches five places. After Phases 3–6 add keywords, that cost
compounds.
Design — Unpack[TypedDict], not a config object¶
class SyncMapOptions(TypedDict, total=False):
workers: int | None
executor: ExecutorType
rate_limit: Limiter | RateLimit | float | None
timeout: float | None
on_progress: Callable[[int, int], None] | None
batch_size: int | None
retry: Retry | None
checkpoint: str | Path | None
checkpoint_key: Callable[[Any], str | int | bytes] | None
max_errors: int | None
sequential: bool
worker_init: Callable[[], None] | None
max_tasks_per_worker: int | None
class _ParallelFunc[**P, R]:
def map(self, items: Iterable[Any], **opts: Unpack[SyncMapOptions]) -> ParallelResult[R]:
return parallel_map(..., **_merge_opts(self._defaults, **opts))
- One options TypedDict per engine (
SyncMapOptions,AsyncMapOptions, streaming variants), defined next to the engine function — the engine's explicit signature remains the source of truth; the TypedDicts are declared to match andtyping_assertions.pygrows checks that keep them honest (assert_typeon decorator calls with each option). - IDE autocomplete and mypy checking survive (
Unpackis fully supported by mypy ≥ 1.5); the four-fold copy collapses to one dict type per engine. - Every override key keeps
| Nonein the TypedDict (review amendment): today's decorator methods accept an explicitNonemeaning "inherit the decorator default" (_merge_optsskipsNone), and existing call sites may pass it. Dropping| Nonewould be a mypy-visible regression. Soexecutor: ExecutorType | None, etc. — runtime semantics unchanged: absent key and explicitNoneboth mean "inherit". The known limitation that an explicitNonecannot override a non-None decorator default remains, unchanged and documented.
Tests¶
- Existing decorator tests already cover behavior; add mypy assertions
for each option key on
.map()/.stream(), an explicit-Noneinheritance assertion, and a negative (# type: ignore[typeddict-unknown-key]) proving typos are caught.
Phase 8 — Docs, roadmap, release gate¶
- Docs per feature (not a trailing batch — each phase's commit
includes its docs): API reference tables, advanced-features sections
for
ordered/max_errors/sequential/checkpoint_key, best-practices additions ("checkpoint_key for evolving inputs", "max_errors for overnight jobs"), quickstart untouched (already teaser-dense). - Roadmap: move shipped items into Current, delete the v0.5 section,
re-examine v0.6 (free-threading CI,
executor="interpreter"). - Version: 0.5.0 in
pyproject.toml+__init__.py. - Examples directory: refresh
examples/*.pyto 0.5 idioms (it still shows 0.3-era usage — flagged during the docs audit). - Review gate (hard requirement): the four-review cadence from v0.4 — Codex review + Codex adversarial + one independent code reviewer + one adversarial design reviewer — once after Phase 3, once before merge. Findings triaged into: fix-now / plan-amendment / explicitly- rejected-with-reason (recorded in this file).
- Merge: single PR, merge commit to
main, then tagv0.5.0and publish (PyPI +make docs-deploy) as a separate, explicit decision.
Acceptance criteria¶
- All new behavior covered by regression tests written against the review findings style: each test names the failure it prevents.
pytestgreen,ruffclean,mypy --strictclean (package + typing assertions),mkdocs build --strictclean.- No timing-flaky tests: fake clocks for limiter/backoff logic; real- time tests use ≥ 0.2s margins and appear only where wall-clock is the contract.
- The streaming rewrite passes a dedicated stress check (e.g. 10k items,
window 32, random 0–5ms sleeps, straggler injection) run locally
before review — not committed as a test, recorded in the PR body.
Bound claims are measured, not asserted: peak
in_flight + reorder_buffer, source-iterator advancement, andmax_errorsadmission counts all have instrumented tests. - Both review cycles complete; no unresolved high-severity findings.
Explicitly out of scope for v0.5¶
| Cut | Reason |
|---|---|
Per-item metadata on collected ParallelResult |
Real surface, unproven demand — revisit on request |
| Config/options object on the public API | Explicit keywords are the interface; Phase 7 solves the duplication without indirection |
| Checkpoint for starmap / streaming | Starmap items embed the callable (thread path) — needs its own design; streaming resume is a different contract |
.then(), circuit breaker, shared kwargs |
Roadmap "Not Planned" — unchanged |
Free-threaded CI, executor="interpreter" |
v0.6 |
| Comparison page, recipes, write-up | Distribution tier, after release |
Review amendments log¶
Round 1 — Codex adversarial review of this plan (pre-implementation,
2026-07-05). Seven findings, all accepted and folded into the phases
above: (1) ordered-mode memory bound restated as an enforced
in_flight + buffer ≤ window admission invariant — the original
refill-on-completion proof was false; (2) max_errors on collected map
requires windowed admission, otherwise eager submission defeats the
feature; (3) sync cancel-on-break reworded to best-effort (running
thread/process tasks cannot be interrupted); (4) checkpoint schema:
type-tagged reversible key encoding + single fail-closed rule for all
pre-v2 files, resolving the positional-files contradiction; (5)
contextvars capture pinned to the submitting thread per item, with
worker_init precedence defined; (6) TypedDict options keep | None
to preserve explicit-None inheritance typing; (7) streaming window
honors an explicit workers= cap.
Round 2 — mid-plan four-review cycle after Phase 3 (Codex review, Codex adversarial, Opus code reviewer, Sonnet adversarial design reviewer; 2026-07-06). Opus: no critical/major correctness findings across 300+ randomized stress trials per engine. Triage:
Fixed: (1) windowed collected path admitted work under an
already-expired timeout — deadline now checked before admission
[Codex P2]; (2) abort/timeout aftermath drained unsized sources —
source is never touched post-stop; sized inputs fill by count, unsized
return a shorter, documented result [Codex adversarial high]; (3) sync
driver blocked in rate-limit waits while filling the window — collected
engine absorbs completions between paced submissions, streaming engine
peeks done futures in _admit [Codex adversarial medium]; (4) a
CheckpointError escaping the sync windowed engine drained the window
while the async twin cancelled — exception-aware shutdown, and the
poison-iterator case now surfaces promptly [Opus minor / Sonnet major];
(5) ParallelResult refuses construction around a leaked _PENDING
[Opus minor]; (6) async cancel-on-break docs were false for a bare
break — docs now prescribe contextlib.aclosing [Sonnet major];
(7) batch_size acting as the admission window when max_errors is
set is now documented as a coupling, not left silent [Sonnet major].
Contract decisions, documented + tested rather than redesigned:
ordered+max_errors delivers the ending failure in input order; a task
that never completes therefore blocks the ordered stream — inherent to
uncancellable threads and identical to every other sync call; escape
hatches (in-function timeouts, async task_timeout, unordered mode)
documented, and a regression test proves task_timeout unblocks the
async case [Sonnet critical → triaged]. Completed-but-unyielded
successes behind the ending failure are discarded — documented [Sonnet
minor]. Async engines deliberately have no mid-fill peek: filling never
blocks and abort cancels limiter-queued tasks; asymmetry commented in
code and cost-bound tested [Sonnet drift note].
Accepted, not acted on: checkpoint cached-prefix scan does not
re-check the timeout deadline mid-scan (local sqlite reads only, no
remote cost); collected-map barrier vs windowed unification deferred —
candidate for v0.6.
Round 3 — final pre-merge four-review gate (Codex review, Codex
adversarial, Opus code reviewer, Sonnet design reviewer; 2026-07-06).
Opus: no unresolved high-severity findings, merge not blocked — every
cross-feature composition (checkpoint_key+max_errors resume,
CheckpointError through the async timeout wrapper, ordered+max_errors
delivery, _PENDING leak-freedom) verified empirically; all six
TypedDict surfaces match their engine signatures exactly. Triage:
Fixed: (1) sync timeout= was bypassed while the driver blocked in
bucket.wait() — Limiter.wait() gained a deadline-aware timeout=
that gives up (consuming nothing) when the predicted grant falls past
the budget, threaded through the plain, windowed, and sequential
engines; regression tests pin all three [Codex adversarial high];
(2) async timeout=0 created tasks before asyncio.timeout could
cancel (first-suspension-point gap) — both async paths pre-check the
expired deadline and admit no work, mirroring sync [Codex P2 / Sonnet
moderate]; (3) sequential=True ran pool validation, so a
process-configured call with a local worker_init broke the one-flag
debug promise — validation skipped when the pool is never built
[Codex P2 / Sonnet minor]; (4) corrupted or non-SQLite checkpoint files
leaked raw sqlite3 exceptions — every unusable file now fails closed
with CheckpointError, and an empty meta alongside a foreign-shaped
results table is refused rather than adopted [Sonnet moderate +
minor]; (5) the roadmap claimed CI-enforced typing with no CI in the
repo — a GitHub Actions workflow (ruff, mypy --strict, pytest, mkdocs
--strict on 3.12/3.13) now makes the claim true [Sonnet moderate];
(6) stale uv.lock regenerated; sequential docstring states
executor= is ignored too [Opus low].
Recorded as by-design: max_errors may admit up to one extra window
past the trigger (the documented bound); _RunCheckpoint._rows retains
entries for failed items until the call returns (bounded by input
size); the per-run duplicate-key set is intentionally per-run.
Risks¶
| Risk | Mitigation |
|---|---|
| Streaming rewrite regresses subtle behavior (cancel-on-break, retry composition) | It is Phase 1 — maximum soak time on the branch; mid-plan review cycle targets it specifically |
batch_size semantic change surprises an existing streaming user |
Pre-1.0 + ~zero installed base; changelog + docstring + docs all state it |
| contextvars-by-default changes observable behavior | Strictly-more-correct argument documented; if review disagrees, demote to opt-in parameter — decision point recorded here |
Async max_errors abort machinery fights TaskGroup semantics |
Pattern already proven by except* CheckpointError; prototype in a throwaway script before committing to the design |
| Signature width (13 kwargs) draws the same review criticism again | Surface accounting section above is the pre-written answer; Phase 7 removes the duplication cost |