Varve · swath · field guide to the engine

How do you divide 39.7 million files among 128 workers when S3 will not tell you where they are?

swath is an open-source command-line tool that lists very large S3 buckets in parallel and writes the result to Parquet you can resume after a crash. It is for anyone who has waited on a bucket too big to enumerate one page at a time, and has no usable Inventory report to fall back on. This page is how it works.

swath guessed 513 adjacent ranges and started listing. One of those guesses secretly held 68% of the entire bucket — 1,281× the median guess, and nothing could have revealed that in advance. The engine discovered the imbalance while running, split that one range apart until every worker could help, and finished the bucket. The name is the method: adjacent swaths, no gaps, no overlap.

Objects listed
39,651,850
Initial guesses
513
In one guess
68.0%
Workers
128
Wall clock
1m 09s

† One recorded run, from a dedicated cloud host at --concurrency 128. A wall clock is a property of the machine and the network; the distribution figures below are properties of the bucket.

On this page
Plate 1 · measured

513 guesses, and the one that mattered

Before it lists anything, swath has to cut the keyspace into pieces. It has no distribution to cut against, so it does what any blind partitioner does: one delimiter=/ probe for the top-level directories, then ranges cut along the boundaries it returns — capped at four per worker and evenly subsampled — each implicitly assumed to hold about as much as the next. For this bucket that came to 513 ranges and an expectation of 0.195% of the files in each.

Below is that assumption on top, and what the run actually measured underneath. Same 513 seeds, same order, same colour. Only the widths differ.

What the engine guessed 513 ranges · 0.195% expected in each one of them held two thirds of the bucket the engine could not know that until it listed What was actually there measured, same 513 seeds, same order 26,946,179 files — 68.0% ↑ the other 512 guesses, together 32% 1,281× the median guess · 510 of the 513 came in under the prior · none came back empty
Plate 1 The uniform prior against the measured key mass of s3://noaa-gestofs-pds. Widths in the lower strip are object counts, not byte distances. 510 of the 513 guesses came in under the prior; one came in at 349× it. This is the shape every design decision in the rest of this page is answering.

Drawn from the run's own event trace via tools/explainer — not hand-placed. See the measured run below ↓

Why this is the whole problem

A partitioner that trusts the prior parks two thirds of the bucket behind a single worker's ranges while the rest of the fleet runs dry, and the run takes as long as that one worker takes. Nothing about the guess can be improved by guessing harder — the information does not exist until keys come back. So swath does not try to guess well. It guesses cheaply, then corrects continuously while listing, which is what the rest of this page is about.

Plate 2 · model

Watch a miniature model of the mechanism

This one is a drawing, not a measurement

Everything in this plate is a deliberately simplified model: an illustrative lumpy key distribution, 12 workers rather than 128, and a flat directory placed at the end so the serial floor is certain to appear. It runs the same kinds of decisions in the same order — seed, drain, estimate, split, claim, finish — at a speed and scale you can watch, not the engine's actual policies. Its counters are not measurements of anything. For measured behaviour, Plate 1 above and the recorded 39.7-million-file run below are the real thing.

The grey profile is mass — where the keys sit in the keyspace. Real buckets are wildly lumpy like this, as Plate 1 just showed, and the engine cannot see the profile; it only ever learns it by listing.

Each colored band is one range owned by one worker. The filled part is what that worker has already listed. Watch for the ochre marks: those are splits — the moment an idle worker carves a busy one in half and takes the upper piece.

Seeding One delimiter=/ probe asks S3 where the directories are.
Keys listed
0
LIST requests
0
Live ranges
0
Busy workers
0 / 12
Splits committed
0

Two things worth catching before you read on. Early, a worker in a sparse region rips across a huge slice of the keyspace while a worker in a dense one barely moves — progress in keyspace is not progress in keys. Late, one band goes red and everything else finishes around it: that is a single flat directory with no internal structure to split on, and it is the one shape swath genuinely cannot parallelize.

§1

The constraint everything follows from

S3's ListObjectsV2 gives you one page — up to 1000 keys, in sorted byte order — plus a way to ask for the next one. You can start anywhere by sending start-after=<token>, and the token need not be a key that exists. So splitting the keyspace is free.

What no request will tell you is how many keys sit between two tokens. There is no count(prefix) and no "give me keys 50,000–51,000". The only way to find out how much work a range holds is to list it — which is the work. That is the whole problem, and it is why parallel listing is a guessing problem rather than a partitioning one.

Sequential pagination — what S3 actually gives you 1000 keys 1000 keys 1000 keys …one round trip each… start-after = last key start-after = last key Arbitrary split points — free to name, impossible to price A B how many keys in here? nothing will tell you
Plate 3 Pagination is sequential and cheap to follow; boundaries are free to invent and impossible to price. Every mechanism in swath exists to price a range without paying for the listing first.
§2

The range, and the one-key mistake

Everything in the engine is expressed as a half-open range (A, B]. Send start-after = A (S3 returns keys strictly greater), emit each returned key while k ≤ B, and stop at the first key past B without emitting it. B = null means the open frontier — the rightmost range, with no upper bound.

Which side owns the boundary key is the most load-bearing convention in the system. swath's rule: the boundary belongs to the left. Get it wrong in either direction and every single split silently corrupts the output — one key lost, or one key duplicated, per split, across thousands of splits.

the same five keys, split at B k1 k2 B k4 k5 gap both exclusive B emitted by nobody overlap both inclusive B emitted twice swath (A, B] · left wins emits k ≤ B start-after = B, so emits k > B
Plate 4 Invariant I3. The left range emits the boundary key; the right range uses it as its exclusive start-after. Together with I2 — the live ranges always tile the whole keyspace — this is what makes concurrent workers safe to run without a dedup pass.
Order by the bytes, not by the string

S3 keys are Unicode text, but S3 orders them by their unsigned UTF-8 byte representation. Java's String.compareTo orders by UTF-16, and the two disagree for supplementary code points (≥ U+10000), where surrogate pairs reorder. So keys travel through the whole engine as KeyBytes — the UTF-8 bytes plus an unsigned comparator — and are decoded to text only at the very edge, for text output and filters. Comparing boundaries with String.compareTo would be a silent correctness bug, not a style issue: two ranges that look adjacent in UTF-16 can overlap or leave a gap in the order S3 actually paginates.

§3

Two ways to split, neither enough alone

Given a range, where do you cut it? swath has two independent answers, and the interesting part is that each one fails exactly where the other works.

Byte midpoint — dynamic, but blind split at the arithmetic middle of the byte range all keys empty byte-space midpoint lands here retry toward the cursor → thin slivers, not halves Works beautifully on hashes and uuids, where mass is spread across the byte space. Collapses to near- serial on a deep tree that shares one long prefix. delimiter=/ probe — structural, one call ask S3 where the directories are real, populated boundaries CommonPrefixes: logs/2024/ logs/2025/ logs/2026/ One request returns the bucket's own layout, so the cuts sit where the keys actually are — but it is a one-shot probe, and cannot rebalance as the run goes. swath does both: seed structurally up front, steal dynamically for the rest of the run.
Plate 5 Seeding alone cannot rebalance; stealing alone is blind to where the mass is. The hybrid is the engine: one bounded delimiter=/ descent before any worker starts, then demand-driven stealing forever after.

The seed, in one paragraph

Before a single worker claims work, SeedStep issues a shallow delimiter=/ pass at the listing prefix. S3 returns the top-level common prefixes p1 < p2 < … < pk, which become cut-points: (⊥, p1], (p1, p2], …, (pk, null]. The cut count is capped at min(1000, 4×workers) and evenly subsampled, so seeding is proportional to the parallelism you actually have. The descent adaptively goes one level deeper only where a sub-level is narrow and complete, and the whole thing is bounded by a probe wallet — a directory explosion can spend the wallet, never blow it. All the seed nodes are inserted in one atomic transaction, so the very first durable state is already a valid tiling of the keyspace.

§4

The steal — and its ladder of evidence

A worker with no pending range to claim, while others are still busy, becomes a thief. It picks the victim with the largest estimated remaining work — an estimate anchored to what that victim has already listed and how fast it is draining, with the open-frontier worker scoring infinite until it gets a finite bound — and tries to cut it in half.

The pivot is never a bare midpoint. It is placed from what the run has already observed: for a bounded range, an interpolation inside (cursor, hi] using the alphabet actually seen in returned keys; for the open frontier, a forward reflection of the density already drained. Then exactly one probe — a max_keys=1 request — tests whether the upper half has any keys at all. Only when that probe comes back empty does the thief spend more.

Estimate where half the remaining work is from mass already seen · 0 requests Ask S3 for one key past that boundary max_keys=1 · 1 request If a key comes back, split there the common case · done If nothing does, climb to harder evidence directory structure, observed density Re-validate against the moving owner, then commit one guarded transaction The cheapest question first, and every rung after it fires only on evidence that the last one failed.
Plate 6 A steal, at the level you need to follow the argument. The thief picks the victim with the largest estimated remaining work, places a pivot from observed mass, and spends exactly one request to find out whether it guessed somewhere real.

Source: docs/internals/algorithms.md · invariants I2–I4 in contracts.md

There is a fourth trigger worth knowing about, because it inverts the usual direction: owner-side split. On a dense, fast-draining range, even a valid pivot can be overrun before the split commits — the drainer advances a full page per round trip and passes the pivot. So the range's own owner places a pivot far ahead of its cursor and commits the split at page-commit time, gated to fire only while live work has fallen below the worker count. The mechanism is global; the trigger is local and demand-driven. That distinction is one of the project's two stated design laws.

Show the complete pivot decision ladder — every rung, and why the split cannot race
place pivot m from observed mass interpolate(cursor, hi, f), synthesized against the alphabet seen in real keys 0 requests probe (m, hi] — one key does the upper half hold any key at all? most steals end right here 1 request hit empty the two ways a pivot goes wrong — each spends its own ladder, one request per rung EMPTY UPPER the pivot aimed above all the keys 1 · step back to the plain midpoint 2 · delimiter=/ structure probe, take the median boundary, when the page came back complete 3 · reflect the drained density forward 4 · bisect back toward the cursor CURSOR-ADJACENT SLIVER the child would inherit the whole tail 1 · back out coarse→fine through the directory levels, at most 4 probes 2 · flat-leaf density pivot 3 · else commit the byte-exact sliver — thin, but it still tiles under victim.lock — re-validate, then the split transaction victim.hi := m · insert the child (m, oldHi] guarded on (cursor IS NULL OR cursor < m) AND range_end IS oldHi AND status ≠ COMPLETED
Plate 6a Every rung below the first probe fires only on real evidence, and each costs at most one more request. A pivot that is genuinely unsplittable — no safe key strictly between the bounds — is cached as such so later steals don't re-probe a dead range.

Why the split can't race

The victim is still listing while the thief is probing, so the thief's snapshot of (cursor, hi) is speculative. Three mechanisms make the hand-off safe. The victim re-reads its volatile hi before every key, so a page fetched under the old, wider bound stops at the new one. At commit time, under the same per-worker lock a thief must take to narrow, the batch is re-trimmed against the current bound — catching the one key that could have slipped through already-batched. And the split itself is a standalone SQL transaction guarded on (cursor IS NULL OR cursor < pivot) AND range_end IS oldHi AND status ≠ COMPLETED, so a stale second thief simply loses.

§5

What the engine learned from real buckets

Two rules govern every mechanism above, and both were arrived at by being wrong first. They are not coding conventions or aesthetics — they are findings about where estimation breaks on real data, and any new mechanism has to be argued against them. Plate 1 is the evidence for the first one.

L1 · Placement
Condition estimates on observed mass; a uniform prior is bootstrap-only. Real buckets cluster in a thin sliver of the byte range, so "assume keys are evenly spread, cut in the arithmetic middle" systematically aims pivots into empty space. The evidence ladder, best first: real observed keys → CommonPrefix boundaries → drain-density reflection → uniform interpolation.
L2 · Triggers
Mechanisms may be global and shape-blind; their triggers must be local and demand-driven. One shape-agnostic split primitive is fine. A trigger keyed on a run-wide failure or pacing signal punishes the whole run for one bad region and starves the parts that were doing fine — so gate on a per-victim fact or on genuine unmet demand instead.
Every new algorithm path must be instrumented

swath deliberately does not bake bucket-shape detection into the engine. It emits rich per-path signals instead, so buckets get classified and tuned after the fact. Any new pivot strategy, split trigger, seed mode, backoff, or router branch must emit an engagement counter plus whatever cheap keyspace signal it observed. The test: can post-hoc analysis tell from the metrics alone whether this path fired, and whether it helped?

§6

Commit before emit, and the two cursors

The ordering above is not incidental. A page's checkpoint commits before its rows go downstream. That means the checkpoint can never claim a page that wasn't durably recorded — and it means the reverse window is real: stop the process between the commit and the emit, and you have a page that was committed and never written out.

swath does not paper over that. It tracks two cursors per range. cursor is the last committed key. durable_cursor is the highest key whose pages all live in finalized Parquet parts — parts whose footer has been fsynced. Resume rewinds to durable_cursor, discards the unfinalized tail, and re-lists only that bounded stretch.

one range, over time → pages cursor advances every page durable_cursor part-00 fsynced part-01 fsynced jumps only on finalize crash / Ctrl+C re-listed on resume finalized parts — retained, never re-listed, never duplicated
Plate 8 Invariants I1, I5 and I6. A part's rows are durable if and only if the part is finalized, so the recovery rule is mechanical: keep every finalized part, throw away the tail, re-list the gap.
DestinationWhat you actually get
managed Parquet directory Exactly-once durable dataset. Resumable with swath resume out/. Only the bounded non-durable tail is re-listed.
stdout / text file One-shot, non-resumable. Commit-before-emit means an interrupted run can be missing an in-flight page — at-most-once, stated plainly rather than hidden.
single-file Parquet Non-resumable; requires --checkpoint none.

Resuming is gated on args_hash — a hash of the run's scope (scheme, endpoint, bucket, prefix, recursion, versioning). Output format and filters are deliberately excluded, because they don't change what gets listed. Resume against a mismatched scope is refused; --restart discards the old run.

What "exactly once" does and does not claim

Exactly-once here is a statement about swath's own parallelization and recovery, and nothing else. No row is lost or duplicated because of the range protocol, a split, a crash, or a resume — that is what the invariants above buy you.

It is not a point-in-time snapshot of the bucket. A listing takes minutes, S3 offers no consistent snapshot across a paginated scan, and a bucket being written during the run will produce a mixed-time view: an object created after the sweep passed its key may be absent, and one deleted behind the sweep may still appear. If you need a coherent instant, you need a source that provides one — S3 Inventory, or a quiet bucket.

§7

Where it honestly cannot win

Inside one flat directory — a single prefix with no sub-structure — the only tool S3 offers is start-after pagination: one round trip per 1000 keys, strictly sequential. A directory with 110,000 keys is ~110 sequential round trips, and adding workers does not help, because handing a worker a valid starting point deep inside that directory requires already knowing a key that far in — which costs exactly the listing you were trying to parallelize.

You can sometimes split such a directory by guessing name-prefix bands (hex 0f, say) when the names are uniform. When they aren't, the sequential floor is real. It bounds only that one directory's tail — a bucket with many dense directories still parallelizes across them; only the last, densest one serializes at the very end. That is the red band you saw in Plate 2.

And when not to use swath at all

If you already have a fresh S3 Inventory report or an S3 Metadata table for the bucket, query that. Reading a precomputed listing is strictly cheaper than any live ListObjectsV2 lister, swath included. swath exists for the buckets where that isn't an option — not enabled, too stale, or not yours — and it is LIST-only by design: it never reads object contents, and it never routes to a precomputed listing to make its own numbers look better.

The cost is legible and worth knowing before you start: one LIST request per 1000 keys. A billion-key bucket is roughly a million requests — on the order of $5 at a $0.005-per-1000 reference rate, before probe and retry overhead, and before egress if you run it outside the bucket's region. Every run reports its actual cost.api_calls.

And it will not floor an endpoint to hit a number

--concurrency is a ceiling, not a target. swath backs off when an endpoint shows genuine stress and recovers gradually afterwards, so the run can finish slower than you asked for and that is the intended behaviour. The control loop, and the reason retries are counted separately from throttles, are in the concurrency deep dive.

the run

One bad guess became work for all 128 workers

This is the run Plate 1 was drawn from: s3://noaa-gestofs-pds, a public NOAA forecast archive, listed cold with no inventory and no prior knowledge of its shape. The single heaviest seed was split 2,155 times over the course of the run — mostly by its own worker shedding work ahead of its cursor, since only 195 of the run's 2,399 splits were taken by thieves. Owner-side splitting is what refilled the worklist; idle workers claiming those shed ranges is what emptied it again.

Objects listed
39,651,850
Initial guesses
513
Behind one guess
26,946,179
Splits in that lineage
2,155
Ranges completed
2,912
Ranges failed
0
Wall clock
1m 09s
Of perfect speedup
57.3%
† The timings still need re-measuring

This run was executed from a dedicated US-East cloud host — a different provider than the bucket's, so the path to S3 crossed networks — at --concurrency 128. 1m 09s is still one recorded run, not a benchmark — no competing listers were measured alongside it, and instance type, network and the day's S3 behaviour all move it. The speedup percentage is a ratio of busy to idle worker-time within this run: useful for diagnosing the steal path, not comparable across machines or concurrency settings.

What does not depend on the machine is the shape this page argues from: 39,651,850 objects across 513 seeds, with 68.0% of them behind one of them. That is a property of the bucket, and any run over the same contents will find it. The split and range counts are not in that category — 2,155 splits and 2,912 completed ranges are facts about this trace. Scheduling, timing, endpoint behaviour and any writes to the bucket will move them.

The last number is the honest one, and it went the wrong way. Perfect speedup here would have been 39 seconds — 5,030 busy worker-seconds spread evenly across 128 workers. It took 69. The missing 30 seconds are 3,750 worker-seconds spent idle — worker time not spent listing. Some of it is the partition still being corrected; startup, request latency, more workers than there is splittable work, and the unsplittable tail all contribute too, and this figure does not separate them. What it does say plainly is that at 128 workers a large share of the fleet was waiting rather than listing. That gap is a known lead, not a mystery, and it is being tracked.

Do you have a bucket that breaks this?

The interesting failure mode is a key distribution the pivot cascade cannot cut — deep uniform prefixes, one enormous flat directory, names that defeat interpolation. If you have one, we would genuinely like to see it. Bucket names and object contents are not needed; a redacted ordered key distribution, or just the shape of it, is enough. Open an issue, or mail oss@varve.io.

part two

Under the hood

Everything above is the argument. Everything below is the implementation behind it — collapsed, because you do not need any of it to understand what swath does or to decide whether it is useful to you. Open what you want to audit.

Implementation: the runtime pipeline — channels, filters, sort, and how output is written

The worklist is the checkpoint table. Each row in SQLite's listing_node is one unit of work moving PENDING → IN_PROGRESS → COMPLETED, which is what makes resume nearly free and eliminates a whole class of permit-across-join deadlock. A fixed pool of virtual threads drains it; termination is by quiescence (outstanding == 0), not by an empty queue — a worker may be mid-page and about to spawn a child.

Amazon S3 · ListObjectsV2 up to 1000 keys, in byte order S3PageFetcher the one shipped backend · swath-s3 WorkStealingScan · N virtual threads RangeScanner.runRange one worker, one range re-reads hi before every key Thief / OwnerSelfSplit narrows hi, inserts a child ① commit SQLite checkpoint one writer thread · WAL · the worklist ② then emit bounded Channel of PageBatch optional --sort lane segments staged on disk, k-way merged at the end FilterChain regex · size · mtime · storage class JSONL · TSV · table → stdout ParquetWriterPool → parts + manifest
Plate 7 The run has two deliberately different concurrency shapes: a small static pipeline of siblings under a structured-concurrency scope, and the listing engine, which is a worklist — that is what caps threads at N regardless of how wide the range tree grows.
Deep dive: concurrency control — AIMD, retry interaction, and why --concurrency is a ceiling

--concurrency (default 64) sets a ceiling. The live target T starts at a slow-start floor and moves by AIMD: multiply down by 0.7 on a real 503 or Retry-After, add one after roughly ten clean seconds, never below one.

The subtlety is what feeds it. The AWS SDK's own retry is turned off (maxAttempts = 1) so the gauge sees every genuine 503 immediately, rather than after the SDK quietly absorbed three behind its own backoff. Two adaptive loops reacting to the same signal over-correct; swath keeps exactly one.

On a healthy endpoint the decrease path never fires — T ramps to the ceiling and stays. A clean run's speed comes from the engine, not from adaptivity.

Tmax 1 time → 503 503 503 +1 per clean ~10s window ×0.7 on stress Workers above the new T finish their current page, then park. The floor is 1 — the run always makes forward progress.
Plate 9 AIMD on the concurrency target. It exists to degrade gracefully against a hostile or throttling endpoint and recover afterwards — not as a tuning knob for throughput.
Repository architecture: the modules — dependency rules, and what reaches the uber-jar

Six Gradle modules, all edges pointing one way. Only swath-cli ships: the uber-jar is built over its runtime classpath, so a module reaches a released artifact if and only if the CLI depends on it.

swath-model KeyBytes · ListEntry · PageBatch a true leaf — it imports no other internal package api swath-core engine · engine.policy · checkpoint · sort · output filter · pipeline · runtime · observability no AWS SDK, no picocli, no terminal. api impl swath-s3 S3PageFetcher · the AWS SDK lives here swath-replay-server the real engine vs a fake S3 impl api impl swath-cli the only shipped artifact swath-sim real policies vs a modelled store, in virtual time solid outline = on the shipped path · dashed = development and analysis tools, never released
Plate 10 The dependency rules do real work: core stays AWS-free so a future GCS backend doesn't drag the S3 SDK, and terminal-free so an embedding application gets progress events without inheriting swath's opinions about stderr.
ModuleHoldsShips
swath-model KeyBytes and its unsigned comparator, the sealed ListEntry hierarchy, PageBatch, and ByteMidpoint — the UTF-8-safe pivot math. via CLI
swath-core Everything that isn't a backend or a front end: the WorkStealingScan engine and its policy seam, the SQLite checkpoint, the Parquet writer pool and external merge sort, filters, the pipeline, metrics. via CLI
swath-s3 The one shipped backend. S3PageFetcher, client construction, fault classification, bearer-token auth. Siblings like swath-gcs would sit beside it. via CLI
swath-cli The swath binary: Picocli commands, option groups, exit codes, the stderr progress display and terminal detection. yes
swath-replay-server Serves swath's own Parquet fixtures back as a fake S3 ListObjectsV2 endpoint, so the real engine can be exercised end-to-end over HTTP with injected latency shapes. no
swath-sim A discrete-event simulator that runs the real split/steal policies against a ground-truth store in virtual time. Answers "what would a different policy have done here?" thousands of times without paying a socket. no
The policy seam — why the simulator is possible at all

Decision logic is separated from execution. ThiefPolicy, OwnerSplitGovernor, and HybridSeedPlanner are pure functions of an immutable view: no lock, no clock, no randomness, no RPC of their own. The executors — Thief, OwnerSelfSplit, SeedStep — snapshot state, drive the policy through a request/response loop issuing every probe it asks for, and apply the result. A test walks the policy package's field-type closure and fails the build if any policy reaches for ambient time, randomness, or metrics. That purity is what lets the simulator replay a recorded (view, decision) pair and get the same answer.

Glossary — the nine terms this page uses precisely
range / node
A half-open (lo, hi] slice of the keyspace, and simultaneously one row in the SQLite worklist. The unit of work, of checkpointing, and of resume.
cursor
The last key committed for a range. Doubles as the next request's start-after — there is no opaque continuation token in the model, which is why resume survives token expiry and re-splitting.
hi
A range's current upper bound. Volatile, because a thief lowers it while the owner is mid-page; the owner re-reads it before every single key.
pivot
A synthesized boundary key at which a range splits. Need not exist in the bucket, but must be valid UTF-8 and avoid code points that would make S3 reject the request.
probe
A max_keys=1 request that answers one question: does the upper half of a proposed split contain any key at all? The cheapest evidence in the system.
mass
Where a bucket's keys actually sit in the keyspace. Always non-uniform, never visible in advance, and the thing every estimator is trying to approximate.
the open frontier
The single rightmost range with hi = null. It gets special treatment everywhere: density extrapolation instead of interpolation, and no owner self-split.
durable_cursor
The second cursor: the highest key whose pages all sit in finalized Parquet parts. It lags cursor, and the distance between them is exactly what a resume has to re-list.
quiescence
How the run ends. Not "the queue is empty" — a worker may be mid-page and about to spawn a child — but an outstanding count reaching zero after every completion and child is durably committed.