Parallel range partitioning
A source that accepts a range filter — ?id_from=&id_to=, a SQL WHERE, an
offset/limit pair, a dated object prefix — is trivially parallelizable. Serial
pagination leaves most of the available bandwidth unused.
partition: splits the range into chunks and runs each as an independent
invocation:
execution: max_concurrent: 8 # chunks share this budget with every other row
partition: kind: integer from: 0 to: 1000000 chunk_size: 10000 bounds: inclusive # required — see below
pipeline: source: type: rest config: path: "/records?id_from=${partition.start}&id_to=${partition.end}" sink: type: file config: path: "./out/records-${partition.id}.jsonl"It works with any source
Section titled “It works with any source”Substitution walks the string leaves of a connector config, so no connector knows this feature exists:
# RESTpath: "/records?id_from=${partition.start}&id_to=${partition.end}"# Postgres / MySQL / MSSQL / SQLite / BigQuery / Snowflake / Spannerquery: "SELECT * FROM orders WHERE id >= ${partition.start} AND id <= ${partition.end}"# S3 / GCSprefix: "dt=${partition.start_date}/"# MongoDBfilter: '{"seq": {"$gte": ${partition.start}, "$lte": ${partition.end}}}'bounds — the one field with no default
Section titled “bounds — the one field with no default”With chunk_size: 10000 from 0:
bounds |
chunk 1 | chunk 2 | emitted end |
|---|---|---|---|
inclusive |
[0, 9999] |
[10000, 19999] |
9999 |
half_open |
[0, 10000) |
[10000, 20000) |
10000 |
It has to match how your source reads the upper bound:
- half-open chunks against an inclusive source → record 10000 is fetched twice, once per adjacent chunk.
- inclusive chunks against an exclusive source → record 9999 is never fetched.
Neither raises an error, and both scale with the chunk count. A guessed default would be a coin flip on silent data loss, so you state which your source is.
integer — id ranges
Section titled “integer — id ranges”partition: { kind: integer, from: 0, to: 1000000, chunk_size: 10000, bounds: inclusive }Tokens: ${partition.start}, ${partition.end}, ${partition.index},
${partition.id}.
timestamp — time windows
Section titled “timestamp — time windows”partition: { kind: timestamp, from: 2026-01-01, to: 2026-08-01, chunk_size: 1d, timezone: UTC }The same DST-correct windowing faucet backfill uses — 1d is a
calendar day, so it differs from 24h across a transition, deliberately.
Windows are always half-open.
Tokens: start, end, start_date, end_date, start_unix, end_unix,
index, id.
offset — countable result sets
Section titled “offset — countable result sets”partition: { kind: offset, total: 1234567, chunk_size: 10000 }The parallel form of what serial offset pagination already does. Tokens:
${partition.offset}, ${partition.limit}, index, id.
Discovering the bound
Section titled “Discovering the bound”Often you know where a range starts but not where it ends. to (and total) can
be discovered by running any source once:
partition: kind: integer from: 0 chunk_size: 10000 bounds: inclusive to: from_source: type: postgres config: connection_url: "${env:PG_URL}" query: "SELECT MAX(id) AS max_id FROM orders" value_path: "$.max_id"The probe is an ordinary source config, so a REST “last record” request works the same way:
to: from_source: type: rest config: path: "/records?sort=-id&limit=1" records_path: "$.data" value_path: "$.id"to_unbounded defaults on when the bound is discovered. A probed maximum is
stale the instant it returns, so the final chunk drops its upper bound and reads
whatever arrived since: its ${partition.end} renders as the largest integer
(9223372036854775807), so id <= ${partition.end} and id < ${partition.end} both admit every later row. Set it explicitly to false for a
range you know is closed.
With bounds: half_open a probed maximum is planned as to = MAX + 1, so the
row holding the maximum is read (and a range whose from equals the maximum is
one row, not empty).
A probe that returns no rows, null, or a non-numeric value is an error, not
a zero — MAX(id) over an empty table returns NULL, and treating that as 0
would plan one degenerate chunk and read nothing.
faucet validate is offline, so it reports that a row discovers its bound at run
time rather than pretending to have planned the chunks (the rest of the config is
checked against a one-chunk placeholder; explain, policy, test and template
registration plan the same way). Every command that runs or inspects the real
rows — run, schedule (each tick), serve, preview, doctor, plan,
backfill, verify, rollback, dlq replay, profiling — runs the probe
first, so they all plan the chunks faucet run would.
What partitioning does not change
Section titled “What partitioning does not change”- Concurrency. Chunks are ordinary sibling rows and share the single
execution.max_concurrentsemaphore. They do not get a private pool. - State. Each chunk gets its own state key (
{name}::{row}::partition::{id}), so bookmarks never collide and a resumed run picks up per chunk. - Failure handling.
execution.on_errorapplies as usual: a failed chunk is reported and, under the defaultcontinue, its siblings still finish.
Limits
Section titled “Limits”- A partitioned row cannot be referenced by another row’s
parent:ordepends_on:. It expands into one node per chunk, so there is no single node to attach to — this is rejected at load time rather than silently dropping the edge. - A
partition:block whose source references no${partition.*}token is rejected: every chunk would run the identical query. - The chunk count is capped (10,000). A tiny
chunk_sizeover a huge range is a config error, not a workload.
Relationship to shard: and backfill
Section titled “Relationship to shard: and backfill”shard: { count } |
partition: |
faucet backfill |
|
|---|---|---|---|
| Splits by | target count | chunk size | window size |
| Bounds from | the connector (MIN/MAX, hash) |
config, or a probe | the command line |
| Sources | the 7 implementing Shardable |
any | any |
| Runs | across cluster workers | within one run | as a bounded replay |
They compose conceptually but are separate tools: shard: spreads one huge table
across workers, partition: widens a single run, and backfill replays history.
partition: and backfill share the same windowing code, so their time
boundaries are identical by construction.