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.
† 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.
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.
Drawn from the run's own event trace via tools/explainer — not hand-placed. See the measured run below ↓
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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?
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.
| Destination | What 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.
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.
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 0–f, 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.
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.
--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.
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.
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.
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.
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.
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.
--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.
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.
| Module | Holds | Ships |
|---|---|---|
| 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 |
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.