Decisions¶
IDs O1 to O16. Each row states the recommendation, the alternative it was weighed against, and why the recommendation wins. Phases P1 to P5 have shipped (see roadmap.md), so every decision except O15 is built as recommended; O15 is built in part. The table keeps each decision as it was argued. Where the build departed from it, the section "As built" after the table says what was built and why, under the same ID.
| ID | Topic | Recommended | Alternative | Why |
|---|---|---|---|---|
| O1 | Scope of core | Recommended: pipekit-ops adds container plumbing, flow limits, execution, a store protocol and serialisable checks, and never inspects a carrier or adds a dependency. | Alternative: a separate pipekit-flow package for the new operators. |
The operators are small, stdlib-only and used by every sister package; a new package adds an install and a version to pin for about 1,400 lines. |
| O2 | Callables | Recommended: new operators take predicates, keys and folds as Operator instances or plain keys; a bare callable raises TypeError pointing to Lambda. |
Alternative: accept callables, as toolz does, and set forbid_in_yaml on every new class. |
With operators, a pipeline built from new ops round-trips and hashes; with callables, half the new catalogue would be flagged and check_pickleable would grow noisier. |
| O3 | Output type | Recommended: iterable ops return lists (dicts for the group-by family); Map.iter and Prefetch are the only lazy outputs. |
Alternative: lazy iterators throughout, like toolz. | A Graph node may feed several consumers, and a Cache must hash its input; both break on a one-shot iterator. |
| O4 | One engine | Recommended: a single Map(op, executor=…) implements ordering, errors, backpressure, chunking and cancellation once. |
Alternative: keep four classes and add the missing features to each. | Four copies of the same loop drift; today the classes already disagree on fail fast (0.20 s vs 1.01 s in the same test). |
| O5 | Where the executor lives | Recommended: executor is a name in the config; a live executor is bound with executor_scope and is not hashed. |
Alternative: executor objects as config-carrying operators (ThreadExecutor(8), DaskExecutor(address)). |
The same pipeline must have the same pipeline_hash on a laptop and on a cluster; any concurrent.futures.Executor (dask, Ray, loky) then works with no code in pipekit. |
| O6 | The four presets | Recommended: keep ThreadMap, ProcessMap, AsyncMap, BatchedMap as subclasses of Map with unchanged constructors, configs and hashes; document them as presets. |
Alternative: deprecate them in favour of Map and remove them in 0.3. |
They appear in docs, tutorials and stored configs; their hashes are registry keys, and a deprecation would buy only a shorter list of names. |
| O7 | Default error policy | Recommended: on_error="raise" fails fast and cancels pending work; "collect" raises a MapError (an ExceptionGroup) with partial results; "log_and_continue" keeps today's None slots. |
Alternative: make "collect" the default, as batch tools such as Prefect do. |
Fail fast matches ThreadMap and AsyncMap today and Python's own Executor.map; collect is one argument away. |
| O8 | Backpressure | Recommended: Map pulls input lazily and holds at most max_in_flight items, default \(2 n_w\) for owned pools and 32 for borrowed ones; in Map.iter(ordered=True) and Prefetch, finished results not yet yielded count as held. |
Alternative: Executor.map(…, buffersize=…), added in Python 3.14. |
pipekit supports 3.12 and 3.13; and a hand-written loop over submit and wait is also what gives per-item error records and cancellation. |
| O9 | Process start method | Recommended: start_method="spawn" by default for owned process pools, with "fork" and "forkserver" as opt-outs. |
Alternative: the platform default (fork on Linux up to 3.13). | fork deadlocks after JAX, BLAS or a thread pool has started threads, and makes pickling bugs platform-dependent. |
| O10 | Pickling check | Recommended: before the first remote submit, run check_pickleable(op) and then pickle.dumps(op) once; Cache and RateLimit define __getstate__. |
Alternative: trust the forbid_in_yaml flag, as today. |
The flag misses unflagged state: ProcessMap(Cache(op)) fails today with a lock pickling error that check_pickleable does not report. |
| O11 | Progress | Recommended: Map(…, progress=every) logs counts and throughput through logging; Log is the pass-through observer. |
Alternative: a Progress operator, or a tqdm extra. |
A pass-through step cannot know \(n\) or what is in flight; Map can. Core takes no dependency, and a tqdm logging handler is a user choice. |
| O12 | Disk cache | Recommended: a three-method Store protocol, MemoryStore and DirectoryStore in core, Cache(inner, store=name, key=op), stores bound by store_scope; no Checkpoint class. |
Alternative: Cache(inner, path="/scratch") with the path in the config, plus a separate Checkpoint(store) operator. |
A path in the config makes the hash depend on the machine; Checkpoint would duplicate Cache with a store and clash with pipekit_train.Checkpoint. |
| O13 | Names from toolz | Recommended: use toolz names where semantics match (PartitionAll, Concat, UpdateIn, Assoc, Dissoc, Get, GetIn, CountBy, Count). |
Alternative: the planned names (Chunk, Flatten, OnKey, GetItem). |
The background page calls pipekit "toolz with each function replaced by a typed class"; matching names make that literal and avoid reusing Count for two meanings. |
| O14 | Two BatchedMaps |
Recommended: the core class keeps BatchedMap; the planned pipekit-array class becomes BatchAlong(inner, batch_size, dim), as the sibling proposal says. |
Alternative: ADR A5 of pipekit-array: keep both names and let the import path disambiguate. | Hydra YAML and from pipekit_ops import * next to pipekit-array imports both see the bare class name; the core name has shipped since v0.1, the array one has not. |
| O15 | Async composition | Recommended: sync wrappers (Retry, Try, Cache, Timeout, RateLimit, Until, the iterable ops) refuse a child with an async def _apply at construction; async-aware versions come later through an optional _apply_async. |
Alternative: make every wrapper detect and await coroutines. | Today Retry(async_op) returns an un-awaited coroutine and retries nothing; an early TypeError fixes the silent bug now, and the full async path can be designed on its own. |
| O16 | JAX tracers | Recommended: Map(executor="serial") accepts tracers; every pool, Cache, Timeout, RateLimit and Prefetch raise TypeError on a tracer via is_traced. |
Alternative: refuse tracers everywhere in the execution group. | A serial loop under jit is just unrolled Python and is useful for small \(n\); sending a tracer to a thread or process is never correct. |
As built¶
These notes come from the pull requests that implemented P1 to P5 (#209, #210, #211, #215, #216, #217). Decisions without a note were built as written.
- O2 (callables).
As decided, a bare
lambdais refused with aTypeErrorthat namesLambda. Only the per-item key ofGroupBy,CountBy,UniqueandSortByaccepts an operator; the dict operators take plain keys, becauseGet(op)would just beop.Rename's old keys must bestr, because they are JSON keys in the config. The lambda check exists twice, asdicts.require_childand as a private helper inflow.py; merging them is a small follow-up. - O4 (one engine).
The async executor runs its event loop on a helper thread, so async goes through the same window loop as every other executor.
Map.rundrives that loop fromasyncio.to_thread, with the tasks on the caller's loop; cancellingrun()does not stop the driver thread. - O6 (the four presets).
The presets keep their constructors, configs and hashes (gate G2-6), with three differences from the design.
ProcessMapnow acceptson_error="collect", inherited fromMap.BatchedMapdoes not get theexecutorargument or thePartitionAllinternals yet, to keep its constructor unchanged: it runsMap(op)serially overitertools.batchedlists and flattens.AsyncMapkeeps its own running-loop error text, which points toawait async_map.run. - O8 (backpressure).
orderedis an argument ofMap.iter, not of the constructor, so it does not enter the config or the hash. - O9 (process start method).
Map(executor="process")uses spawn, as decided. TheProcessMappreset keeps the platform's start method, as the P2 fallback in roadmap.md allows, and raises aFutureWarningat construction when that default is not spawn; so on Linux (fork) everyProcessMapwarns. - O10 (pickling check).
Cachegained__getstate__and__setstate__: a pickled copy has a fresh lock, no entries and zero counters. A pickled or loadedRateLimitstarts with fresh state. Building P4 also fixed a P1 bug: the_MISSINGsentinel ofGetwas a bareobject(), so a pickledGet("id")returned a copy of the sentinel instead of raisingKeyError, and its hash held a memory address, which would have broken cache sharing across processes. The sentinel now survives pickling. - O11 (progress).
Log's default message is"call {n}: {type} {signature}", not the design's"{op}", because{op}is not one of the three fields the design defines. A message naming any other field raisesValueErrorat construction.Logcounts calls even when its logger is disabled for the level, but writes nothing; when a carrier arrives without its adapter,{signature}prints?. - O12 (disk cache).
The cache key is now
sha256(input key ‖ 0x00 ‖ pipeline_hash(inner)), plus‖ 0x00 ‖ pipeline_hash(key)whenkeyis set, so two classes with equal configs never share an entry, and neither dokey=Get("id")andkey=Get("name"). Only in-process keys changed;pipeline_hash(Cache(...))did not (gate G4-1), and the pinned test of the old framing becametest_hashing.py::test_cache_key_digest_framing.DirectoryStorewrites a format marker,pipekit-store.jsonwith version 1, refuses a directory of another version, and refuses keys that are not 64-character hex, which blocks path traversal.Cache.clear()clears only a private store, never a shared named one, andstoremust be astr. To carry bound stores to process workers (execution.md §2.7), P4 also editedexecution.py: owned pools get them through the initializer, borrowed non-thread executors with each task. - O15 (async composition).
Built in part.
Retry,Timeout,RateLimitandUntilrefuse a child whose_applyisasync defwith aTypeErrorat construction (gate G3-7;Untilchecks bothopandpred).Try,Cacheand the iterable operators do not refuse yet; forCachethis was left out of #216 as a follow-up.
Four departures from api/operators.md fall under no decision:
Timeoutstarts a daemon thread per call, not a shared pool. A pool's workers are joined at interpreter exit, so one call that hangs for good would stop the process from exiting, and a bounded pool full of hung workers would make later calls time out without ever running. Abandoned calls are tracked process-wide; when more thanmax_abandonedare still running, a timeout logs a warning onpipekit_ops.flow.RateLimitwindows are half-open, \([t, t + T)\), where \(t \in \mathbb{R}\) is the oldest recorded start in seconds and \(T \in \mathbb{R}_{>0}\) isperiod. A call that starts at \(t_\text{now} \in \mathbb{R}\) is admitted when \(t_\text{now} - t \ge T\); with a strict \(>\), a fake clock spins forever on ties.Untilreturns rather than raises whenpredholds after the last allowed call, andmax_iter=0only checks the input.AssertSignatureraises at build time, rather than only reporting. Itscompute_output_signaturemakesSequential.summary()raise the mismatch without running any data, which is howcompute_output_signatureerrors already surface (as in pipekit-array'sMeanScalar). An unknown or foreign signature passes through.
Things this design does not decide¶
- A cross-process rate limiter. It needs shared state; the docs show how to divide the limit instead.
- Async-aware wrappers.
Phase P6 designs
_apply_async, and has not started; until then O15 makes the failure loud, for the four operators that refuse today. - A
Graphthat runs independent nodes in parallel.Graphstays a serial topological walk; a parallelGraphevaluator would build onMapand the executor scope, and is a separate design.