This document describes a batch-only, ML-focused data processing system inspired by Dataflow and MapReduce, adapted to Slurm/HPC realities, large-scale preprocessing, and model-based inference with high startup cost.
Batch-only here means:
- no real-time / online streaming
- no event-time, watermarks, triggers, timers
- all inputs are bounded
- Pure batch preprocessing
- Shard-based progress, not task-based
- Long-lived workers (avoid Slurm queue latency)
- Implicit fusion by default
- Explicit materialization only at well-defined boundaries
- Bounded memory with controlled disk spill
- Efficient GPU inference via async continuous batching
- Global ordering of records
- Exactly-once execution of user code
- Zero recomputation (retries are allowed)
A shard is the smallest unit of scheduling and retry.
- Deterministically defined from input data
- Examples:
- Parquet row-group ranges
- JSONL byte ranges
(file, offset_start, offset_end)
- Many shards ≫ number of workers
A stage is a sequence of operations.
- Inside a stage:
- operations are fully fused
- execution uses a tight imperative loop over shard rows
- row/batch operators run in fused micro-batches (no per-step iterator chaining)
- no disk writes unless memory limits force a spill
- Between stages:
- data is materialized
- progress is checkpointed
- next stage consumes manifests
The ledger is the durable system of record.
- Not a process; a shared store
- Coordinates claims, retries, and commits
Minimal schema:
work_id
type: SHARD | MERGE | PARTITION
status: UNCLAIMED | CLAIMED | DONE | FAILED
lease_owner
lease_expiry
attempt
resource_class
temp_output_uri
error_code
- User submits N long-lived Slurm jobs
- Each job runs a worker loop
- Workers pull work from the ledger
Worker loop:
- Try to claim a READY merge / partition job
- Else claim an UNCLAIMED shard
- Execute work
- Publish output
- Update ledger
- Repeat
No central scheduler process is required.
Within a claimed shard, workers execute a fused plan with two logical step types:
- Single-row step:
row -> row | None - Batchable step:
list[row] -> list[row]
Arbitrary mixes are supported by a per-step fused scheduler with step-local queues. Batchable operators trigger only when their own queue reaches full batch size (tails flush at end-of-stream).
Workers batch across multiple claimed shards before completion:
- claim enough consecutive shards to satisfy current fused batch needs
- run fused operators on the concatenated row stream
- complete all claimed shards together on success (fail all on exception)
- this enables larger batchable windows without introducing a materialization boundary
Pseudo-flow:
- Read rows from
reader.read_shard(shard) - Apply fused row segments inline
- Accumulate into bounded buffers for batchable segments
- Emit outputs
- Heartbeat periodically and complete/fail in ledger
For fused in-worker execution with row/batch operators:
- Spark: fuses narrow operators inside a task (whole-stage style); breaks at shuffle/exchange.
- Beam/Dataflow: fuses adjacent element-wise transforms into runner stages; breaks at shuffle/stateful boundaries.
- Daft: optimizes lazy plans and executes fused partition-local operators; breaks at repartition/sort/join boundaries.
Refiner follows the same shape:
- fuse narrow local operators in one worker loop
- keep async inference as in-process fusion boundary
- materialize only at true stage boundaries (shuffle/sort/join/dedup)
- Atomic transition
UNCLAIMED → CLAIMED - Lease with expiry
- Periodic heartbeats extend lease
- Worker runs fused pipeline on shard
- Reads only the shard’s input range
- Produces temporary output
- Output written to unique temp path:
tmp/run_id/work_id/attempt_k/...
- Atomic publish:
- mark DONE
- record output URI
- Retries are safe (first publish wins)
Execution:
- Steps are inlined into one execution plan
- No intermediate files
- Bounded memory via micro-batch buffers
- Re-execution is cheap
A fusion boundary stops inlining but stays in the same process.
Use cases:
- Async inference islands
- Local execution-mode transitions (row-mode to async-mode and back)
Properties:
- No stage handoff
- No worker-count change
- No durable boundary output required
Materialization happens only when required:
- Shuffle / GroupByKey
- Sort
- Dedup
- Join
- Explicit stage boundary
- Memory pressure spill
At a boundary:
- Data is written to disk/object store
- Next stage operates on these outputs
- Next stage may run with a different worker count/resource class
Almost all “hard” batch operators are built from one base pattern:
While scanning input records:
-
Compute a bucket id:
hash(key) % Pfor shuffle / dedup / join- range bucket for global sort
-
Append record to an in-memory buffer for that bucket
-
Each bucket buffer has a memory limit
-
When exceeded:
- buffer is flushed to disk as a spill file (run)
- spill files are append-only chunks
-
This guarantees bounded memory
Spill = intentionally writing buffered data to disk to avoid OOM.
-
Each bucket becomes an independent work item
-
A worker claims bucket
b -
Reads all spill files for
b -
Performs local heavy computation:
- group by key
- external sort
- join
- dedup
-
Writes final output
Logical:
GroupByKey(key_fn, combine_fn)
Physical:
- Hash-partition records
- Spill partitioned runs
- One reducer per partition
- Sort or hash-aggregate locally
Logical:
Sort(key_fn, total_order=True/False)
Physical:
- Local in-memory sort + spill runs
- K-way merge
- Optional range partition for global order
Logical:
Dedup(key_fn, keep=first|min|max)
Physical:
- Shuffle by key
- Within partition, emit one record per key
Logical:
Join(left, right, key_fn, how=inner|left|...)
Physical:
- Shuffle both sides with same partitioner
- Local sort-merge or hash join per partition
- Avoid race conditions
- Avoid “last shard does extra work”
- Make retries idempotent
-
Deterministic grouping (e.g. 5 shard outputs)
-
When all shards in group DONE:
- ledger creates a MERGE job
-
Any worker can claim it
Merge job:
- Read shard outputs
- Write final merged file
- Publish
- Cleanup temp files
- Outputs are defined as an unordered set
- Determinism = same set, not same order
- Ordering requires explicit
Sortboundary
- Model init expensive
- Want continuous batching
- Requires async runtime (e.g. vLLM)
-
Worker hosts:
- one async event loop
- long-lived inference server
-
Sync pipeline feeds requests via queues
-
Async loop submits requests continuously
-
Results returned via queues
Only inference is async; rest remains synchronous.
Important:
- This is a fusion boundary, not a materialization boundary.
- Workers fuse all pre-inference operators, enter async island, then continue fused post-inference operators in the same process.
- Ordering can be relaxed for throughput unless explicit ordering is required.
RefinerPipeline exposes local execution helpers:
iter_rows()for lazy fused execution over all shards (single-process, single-worker semantics)materialize()to eagerly collect all rows into memorytake(n)for bounded sampling without full materialization
This mirrors the common pattern in Spark/Daft-style local usage:
- lazy transformation graph
- explicit terminal action for full materialization
- Shards are at-least-once
- Outputs are exactly-once
- Failures handled at shard or partition level
- OOM → escalate resource class
- Transient → retry
- Fatal → mark FAILED
- Submit workers once
- Over-shard inputs
- Dynamic behavior happens inside allocations
- No per-task Slurm submissions
| Aspect | DataTrove | New System |
|---|---|---|
| Progress tracking | task-based | shard-based |
| Disk usage | explicit stages | implicit fusion |
| Shuffle/dedup | manual | built-in operators |
| Failure recovery | coarse | per-shard |
| GPU inference | per-task | continuous batching |
| Slurm latency | frequent | amortized |
MapReduce extracted into reusable physical primitives.
Shards are data, not tasks.
Fusion by default, materialize only when forced.
Correctness lives in the ledger; workers are replaceable.