Varve · swath · field guide to the engine

How do you divide 39.7 million S3 objects among 128 listing workers when you cannot see how their keys are distributed?

The 128 belongs to the recorded run on this page, which set that as its ceiling. --concurrency is an adaptive ceiling rather than a required setting, and its current default is 64.

swath is an open-source command-line tool that lists very large S3 buckets in parallel. It can stream a table, TSV, or JSONL — compressed, if you want — or write a managed Parquet dataset 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.

S3 returns object keys in order but never reveals how they are distributed. swath assigns non-overlapping key ranges to listing workers, watches where the objects actually turn out to be, and splits the unscanned remainder of busy ranges so idle workers can help. In the recorded run below it opened with 513 adjacent ranges; one of them ultimately held 68% of the bucket, mass the seed could not know exactly in advance. This guide explains that range model, the durability boundary, and where parallelism stops helping. The name is the method: adjacent swaths, no gaps, no overlap.

Objects listed
39,651,850
Initial ranges
513
In one range
68.0%
Listing workers
128
Trace event span
1m 09s

† One recorded runnoaa-gestofs-pds-field-guide-trace, at a published ceiling of --concurrency 128. The 1m 09s is the span between the first and last retained trace events, not a listing wall clock reported by swath; the skew below is a property of this capture of the bucket.

On this page

Five things to remember

  1. S3 returns object keys in one global order, one page of up to 1,000 keys at a time.
  2. swath lets different listing workers scan non-overlapping key ranges in parallel.
  3. It does not need to know the distribution in advance; idle workers split the unscanned remainder of busy ranges.
  4. Managed Parquet output can resume from durable progress after an interruption.
  5. 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.

Want to run it before reading the internals?

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.

Plate 1 · measured

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.

Uniform baseline 513 ranges · 0.195% each if mass were even 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 ranges, same order 26,946,179 objects — 68.0% ↑ the other 512 ranges, together 32% 1,281× the median range · 510 of the 513 came in under the baseline · none came back empty
Plate 1 The uniform baseline 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 ranges came in under the baseline; 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 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.

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 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.

Seeding This model uses one delimiter=/ probe to find structural boundaries.
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.

§1

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.

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 for CommonPrefix boundaries 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.

Source: docs/internals/algorithms.md §8

§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: 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
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.

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:

DestinationWhat 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.

What the durability guarantee covers

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.

§7

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

§8

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.

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. 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.

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 behavior. The control loop, and the reason retries are counted separately from throttles, are in the concurrency deep dive.

the run

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.

Objects listed
39,651,850
Initial ranges
513
Behind one range
26,946,179
Splits in that lineage
2,155
Ranges completed
2,912
Ranges failed
0
Trace event span
1m 09s
Worker utilization
57.3%
† What these measurements mean

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.

Do you have a bucket that breaks this?

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.

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.

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.

Amazon S3 · ListObjectsV2 up to 1,000 keys, in byte order listing workers — bounded by --concurrency range scanner one worker, one range re-reads hi before every key thief / owner self-split narrows hi, inserts a child ① commit durable checkpoint one writer thread · the worklist · cursors ② then emit bounded page channel filters regex · size · mtime · storage class text stream table · TSV · JSONL, optionally compressed → stdout or one file one-shot: nothing to publish, and nothing to resume from dataset writers direct Parquet, or a TSV/JSONL directory dataset a small bounded pool — three writers by default, not one per worker page-run staging durable segments on local disk, sealed as the listing runs --sort only · after listing completes optional bounded cascade only above the admitted fan-in header scan → router one global order, calibrated parts parallel encoders dense ordinals fix names and order publication final parts, then the manifest — _SUCCESS is written last
Plate 7 Listing workers are not output writers. --concurrency bounds the first pool only; direct Parquet and text directory datasets are written by a separate, much smaller bounded writer pool, and sorted output adds a third bounded pool of encoders that runs after listing has finished. Text directory datasets can use several writers but are still not resumable. Sorted output finalizes from durable page-run staging, so an interrupted sorted run reuses its checkpoint-tracked sealed segments; any unfinished listing tail is still re-listed from the durable cursor. Every dataset ends the same way: parts, then the manifest, then _SUCCESS last.

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.

Tmax 1 time → 503 503 503 grows while the endpoint stays clean ×0.7 on stress Workers above the new T finish their current page, then park. The floor is 1 — one listing slot stays admitted. Simplified: the freeze and shed rungs that also act on T are not drawn. See algorithms.md §5.
Plate 9 AIMD on the concurrency target, simplified to its two load-bearing moves. It exists to degrade gracefully against a hostile or throttling endpoint and recover afterwards — not as a tuning knob for throughput.

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.

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 the real engine vs a fake S3 impl api impl swath-cli the supported product binary swath-sim real policies vs a modeled store, in virtual time green = the main product artifacts · ochre = separately released contributor toolkit · dashed = non-release dev tool
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.
ModuleHoldsDistribution
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
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.