Five things to remember
- S3 returns object keys in one global order, one page of up to 1,000 keys at a time.
- swath lets different listing workers scan non-overlapping key ranges in parallel.
- It does not need to know the distribution in advance; idle workers split the unscanned remainder of busy ranges.
- Managed Parquet output can resume from durable progress after an interruption.
- The result is the complete result of a live listing — not a point-in-time snapshot of a bucket that changes during the run.
Two words first, because the rest of the page leans on them. Every S3 object has a key — the byte sequence used as its name — and S3 lists those keys in one global order. This guide calls that ordered set the keyspace. A range is a non-overlapping slice of that order, assigned to one listing worker. If it helps to think of objects as the files stored in the bucket, the keyspace is the single sorted list of all their names.
Run the small anonymous NOAA example in Getting started. It needs Docker but no AWS account, and it finishes in seconds — the full-bucket run further down this page is a different proposition, worth tens of thousands of LIST requests.
513 ranges, and the one that mattered
Before it emits any objects, swath cuts the keyspace into pieces with a bounded delimiter=/ descent. The boundaries reveal structure, not exact object counts: even mass-aware seeding has only bounded samples and keyspace span to work from. The worker-proportional cap is min(1000, 4 × workers) cut points — 512 at 128 workers. This run opened with 513 ranges, which is consistent with that cap, but the retained trace records the range count rather than the seed path that produced it. The upper strip therefore uses a uniform baseline: 0.195% of the objects per range.
Below is that baseline on top, and what the run actually measured underneath. Same 513 initial ranges, same order, same color. 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 a uniform 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. A bounded seed can gather useful structural and sampled-mass evidence, but it still cannot know exact range sizes without doing the listing. swath therefore does not need the seed to be right: it corrects continuously while listing, which is what the rest of this page is about.
Watch a miniature model of the mechanism
Everything in this plate is a deliberately simplified model: an illustrative lumpy key distribution and 12 workers rather than 128. 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 behavior, Plate 1 above and the recorded 39.7-million-object 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 active range, owned by the worker named inside it. The filled part is what that worker has already listed. A worker moves left to right until it finishes; it never jumps from one live range to another.
When an idle worker steals, it cuts the unscanned remainder of a busy worker's range in two. The busy worker keeps the lower child; the thief immediately takes the upper child. The animation pauses on that exact moment and labels both children. Hollow triangles show the other handoff: an owner shedding an upper child into the dashed pending row for an idle worker to claim. In the recorded run below, owner-side shedding made 92% of all splits. A red tick is a probe that found no useful upper child.
- Keys listed
- 0
- LIST requests
- 0
- Live ranges
- 0
- Busy workers
- 0 / 12
- Splits committed
- 0
The important moment is one band becoming two adjacent bands: the original worker stays on the lower child and the formerly idle thief appears on the upper child. Nobody joins a range that already has an owner. When a worker finishes, it becomes idle and can later claim or steal another range. Also notice that a worker in sparse keyspace moves much farther than one in dense keyspace — progress in byte position is not progress in keys. The limits section covers the genuinely hard-to-split shapes separately.
The constraint everything follows from
For a general-purpose bucket, 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=<boundary>, and the boundary 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 boundaries. 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.
That ordered start-after contract is what the range model rests on. S3 directory buckets provide neither the same global order nor StartAfter, which is why swath does not support them.
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.
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.
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.
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.
Source: docs/internals/algorithms.md §8
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.
Source: algorithms.md §3 · 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 — design law L2 in docs/internals/overview.md.
This fourth trigger did most of the work in the recorded run below: 2,204 of 2,399 committed splits were owner-side, while thieves took 195. Both paths are paced to limit unproductive splitting: within a claimed range, its owner carves no more than once per 32 committed non-empty pages, and across the fleet only one thief may own the steal-attempt slot at a time, including any probes that attempt issues. These limits can slow worklist refill; issue #205 tracks their effect and possible demand-aware replacements.
Show the complete pivot decision ladder — every rung, and why the split cannot race
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.
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.
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?
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.
Which of those you get depends on where you send the output. swath can stream a table, TSV, or JSONL, and text output may be gzip- or Zstandard-compressed; it can also write TSV/JSONL directory datasets or a managed Parquet dataset. Durable resume is a managed-Parquet feature, and globally sorted Parquet is an opt-in finalization path on top of it. The full decision table lives in docs/usage.md; the short version:
| Destination | What you actually get |
|---|---|
| managed Parquet directory -o out/ |
The resumable output. Finalized parts are retained exactly once; an unfinished tail may be re-listed from the last durable cursor. Resume with swath resume out/. |
| stdout / text file | One-shot and non-resumable. Because a page commits before it is emitted, an interrupted stream can omit a page the checkpoint already recorded. |
| TSV / JSONL directory dataset | Several background writers, and _SUCCESS written last — but not resumable in 0.3.1, and it requires --checkpoint none. |
| a path ending .parquet | Not what it looks like. In the current pre-1.0 compatibility behavior this spelling selects a legacy one-writer, non-resumable directory layout under a file-looking path; it does not create one physical Parquet file. Use a directory path such as out/ instead. |
A resumable run stores several related identities.
args_hash identifies the listing scope. The filter
specification and the output/run identity are persisted separately.
swath resume refuses a change to any identity field, because combining
different targets, filters, or output contracts would produce an incoherent dataset;
operational settings the CLI documents as free or restorable may be restored or
re-supplied. --restart discards an unfinished checkpoint instead.
The full rules are in
docs/usage.md.
It covers swath's own publication protocol, and nothing else. For a managed Parquet dataset a finalized part is durable: swath retains it across an interruption and never republishes it as a duplicate part, removes the unfinished part, and may re-list the tail after the last durable cursor. _SUCCESS is written only after complete publication.
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.
How the guarantees are enforced
For a managed Parquet dataset: no row lost, none duplicated — across splits, steals, crashes, and resumes. An interrupted stdout or text-file run is at-most-once, as the table above describes. A guarantee like that is worth exactly as much as the machinery that would catch its violation. This is that machinery.
Each invariant carries a number (I1–I12) in the repository’s contracts.md, and the contribution rules require that a high-risk unit’s adversarial test — the one trying to break no-gap/no-overlap, or resume-after-crash — is authored or reviewed independently of the code’s author. The test that wants the code to fail is never written only by the person who wants it to pass.
Then the instruments, each answering a different kind of distrust:
- Adversarial keyspaces, in memory. Property suites drive the real engine over generated pathological keyspaces — 0xFF bytes, supplementary-plane characters, boundary shapes built to break pivot math — and assert the listing is complete and duplicate-free under every split/steal interleaving they can provoke.
- kill -9, for real. Integration suites SIGKILL a live listing process mid-run — no shutdown hooks, no goodbye — then resume and require the same complete listing a crash-free run would have produced: nothing missing, nothing doubled. The recovery rule above is exercised as a fact, not an argument.
- A replay of real buckets. A replay server serves captured listings of real buckets back over real HTTP, with measured per-request-shape latency profiles injected — millions-of-objects realism, deterministic and free, without touching S3. The server’s own fidelity is itself tested by diffing its responses against recorded real-S3 traffic, page for page.
- A deterministic simulator. The real split/steal decision code runs against a modeled store in virtual time — same seed, byte-identical run — so a policy pathology found once can be replayed exactly, thousands of times, while it is being fixed.
- An independent reader. Parquet output is verified with DuckDB — a reader that shares no code with the writer.
One structural choice makes all of this bite harder: --checkpoint none is not a separate fast path — it is the same durable machinery pointed at an in-memory database. There is one code path, so the invariants are exercised by every run and every test, not only the checkpointed ones.
Source: docs/ops/dev/TESTING.md · docs/swath-replay.md
Where parallelism stops helping
A flat prefix is harder to divide, but it is not inherently serial. Current swath recognizes a truncated, dense flat region during seeding and pre-cuts it into leading-byte radix bands. During the run, a thief can also synthesize a density-based pivot without that boundary being an existing key. Those guesses work especially well for hex, UUID, timestamp, and similar printable-ASCII names.
Parallelism stops helping when those bounded guesses cannot find a useful populated child, or when no safe boundary remains strictly between a range's cursor and end. The owner must then finish that residual range. Ordinary pagination within one range is sequential — one round trip per page of up to 1,000 keys — so a large residual tail can still set the run's finish time. The red band in Plate 2 illustrates that outcome; it does not claim that every flat prefix reaches it.
A second potential floor is inside the client: all listing workers feed one bounded channel drained by one output-stage consumer, so output backpressure can serialize a large fleet before batches reach the background writers. A swath 0.3.1 compressed-TSV run on a 1.07-billion-object bucket exposed this bottleneck; see the performance guide for diagnosis and issue #206 for the dated evidence.
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. A full page returns up to 1,000 keys, so the request floor is roughly objects ÷ 1,000: a billion-key bucket is on the order of a million LIST requests. Probes, retries, sparse pages, and any re-listed unfinished tail add to that, as does egress if you run outside the bucket's region. For a bill, take cost.api_calls from the run's JSON report and multiply by your provider's current LIST price — do not treat any price printed in a guide as evergreen. Request cost has the formula.
--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 behavior. The
control loop, and the reason retries are counted separately from throttles, are in
the concurrency deep dive.
One heavy initial range became work for all 128 workers
The measured example on this page is the capture identified as noaa-gestofs-pds-field-guide-trace — a label assigned to this artifact, not one recorded at capture time. It ran against the public NOAA archive s3://noaa-gestofs-pds/. The trace shows 128 distinct listing workers, consistent with the published concurrency ceiling of 128. The swath version, commit, capture date, exact command, output mode, target and client regions, machine type, and whether seeding used prior information are unknown in the retained evidence; they are marked unknown here rather than inferred.
This is not the recording embedded in the repository README. That is a separate capture of the same bucket, made with swath v0.2.1, which returned 39,585,029 objects — a live bucket changes between runs, so the two are identified separately. The interactive trace linked at the end of this section is this capture, not that one.
Plate 1 was drawn from this run. The single heaviest seed was split 2,155 times over the course of it — 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.
1m 09s — 68,592 ms — is the trace event span: the time between the first and last event in this run's --trace JSONL. It is not a listing wall clock reported by swath; no run summary was retained for this capture. No competing listers were measured alongside it, and machine, network, and the day's S3 behavior all move the span. The replay animation on the trace page runs for 36 seconds; that is a playback length chosen for viewing, not a measurement of the run. The 57.3% is listing-worker utilization over that trace span — useful for diagnosing this capture's work distribution, not comparable across machines or concurrency settings.
In this capture the bucket state was highly skewed: one of 513 initial ranges held 68.0% of the 39,651,850 objects returned. That skew is the durable observation, and it is an observation about this identified capture — not a promise that every run produces the same partition tree. A rerun against a bucket that has changed, a different swath version, or a different seed configuration can produce different range and split counts. The same goes for everything downstream of it: 2,155 splits and 2,912 completed ranges are facts about this trace, and scheduling, timing, endpoint behavior, and writes to the bucket all move them.
Across the 69-second trace event span, dividing the 5,030 recorded busy worker-seconds evenly across 128 workers gives a 39-second idealized lower bound for this run's own work accounting. The roughly 30-second gap is the run's 3,750 recorded idle worker-seconds divided across the fleet — worker time not spent listing. Startup, stretches with less splittable work than workers, and residual work that could not be usefully split all contribute; the figure does not separate them. At 128 workers, a large share of the fleet was waiting rather than listing. That is a diagnostic of this run — a lead being tracked — not a portable efficiency score or a benchmark result.
The interesting failure mode is a key distribution the pivot cascade cannot cut well — deep uniform prefixes, a flat region whose names defeat radix bands and interpolation, or a residual range with no safe boundary left. 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.
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.
One clarification before you do. In this guide, worker means a listing worker issuing ordered S3 page requests. Output writers and sorted-finalization encoders are separate bounded pools and do not scale one-for-one with --concurrency: a run at --concurrency 128 does not have 128 Parquet writers or 128 sort encoders.
Implementation: the runtime pipeline — channels, filters, and how each kind of output is written
The worklist is the checkpoint table. Each row of the checkpoint's listing-node table 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.
Downstream of the filters, the three output shapes have genuinely different machinery. A text stream is one-shot. A direct Parquet or text directory dataset is written during the listing by a small bounded pool of writers. Sorted output is different again: workers stage durable page-run segments while they list, and the whole ordering job runs after listing finishes, reading those segments back.
Detail and current constants: docs/usage.md#sorted-output · docs/performance.md · docs/internals/architecture.md
Deep dive: concurrency control — AIMD, retry interaction, and why --concurrency is a ceiling
--concurrency (default 64) sets a ceiling, and the live target
T moves underneath it. It starts at
min(4, --concurrency) and doubles through a handful of paced
steps while the endpoint stays clean; the run's first congestion signal latches that
growth down to a cautious additive step for the rest of the run. A real 503 or
Retry-After multiplies T down by 0.7
and pauses new steals. The floor is one, so at least one listing slot stays admitted.
Two further rungs exist and neither of them lowers T: a growth freeze that suppresses the increase step entirely while worker timeouts are high, and a latency valve that admits only a paced increase while successful-attempt latency is inflated against its own rolling minimum. A separate, starvation-gated shed does cut T harder, but only when timeouts are high and the run has stopped making progress. The current multipliers, windows, and gate thresholds are in algorithms.md §5.
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. One caveat: the latency valve compares a short EWMA with a rolling-minimum baseline seeded by the run's first successful pages, so a client very close to the bucket, whose first pages return in a few milliseconds, can be held well below the ceiling for minutes by the ordinary latency rise that comes with load — tracked in issue #202. Adaptive concurrency is a safety control, not the source of parallelism: seeding and splitting decide whether useful work exists at all, and AIMD only limits how much of it may call the store at once. It is a reactive backpressure controller, not an online throughput optimizer — it will not search back down to a lower T just because a lower one would deliver the same keys per second more cheaply.
Source: docs/internals/algorithms.md §5
Repository architecture: the modules — dependency rules, and what reaches the uber-jar
Six Gradle modules, all edges pointing one way. Only swath-cli is part of the main swath product artifacts: the uber-jar is built over its runtime classpath and the Docker image copies exactly that jar, so a module reaches those artifacts if and only if the CLI depends on it. That is not the same as "never released" — swath-replay is packaged and released separately, as its own distribution and container image, on the repository's shared version cadence. swath-sim is on neither runtime path.
| Module | Holds | Distribution |
|---|---|---|
| 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 work-stealing engine and its policy seam, the SQLite checkpoint, the output layer including the bounded Parquet writer pool and the sorted-finalization pipeline, filters, the pipeline, metrics. | via CLI |
| swath-s3 | The supported 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. | the product |
| swath-replay | The replay toolkit: 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. A contributor tool, not part of the swath CLI and not needed for a normal listing. Its wire behavior is conformance-tested; its diagnostics and launcher settings are a deliberately unstable development surface. docs/swath-replay.md |
separate release + container image |
| 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. | non-release dev tool |
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.