S3-to-S3 streaming converter that compresses JSONL files into Parquet with ZSTD
compression. Built and tuned against the viraj/ and code-gen/ prefixes on
bgen-data-team. Production-tested on 51.77 TiB → 8.54 TiB (83.5% smaller).
Two converters in this repo
convert_jsonl_parquet.py— streaming S3-to-S3 via DuckDBhttpfs(this README).pipeline_convert.py— 3-stage download→convert→upload via local NVMe. DuckDB is local-only; boto3 owns S3. Reach for it when token expiry, httpfs flakiness, or huge single-file converts bite. See PIPELINE.md.
Input (S3 JSONL) ──┐ ┌──> Output (S3 Parquet+ZSTD)
├── DuckDB stream + COPY ──> ──┤
Manifest (S3) ──────┘ └──> Progress (local JSON)
| Metric | Value |
|---|---|
| Source prefix | s3://bgen-data-team/viraj/ |
| Files converted | 1,570 |
| Input size | 51.77 TiB |
| Output size | 8.54 TiB |
| Compression ratio | 83.5% smaller |
| Bytes saved | 43.23 TiB |
| Failures | 0 |
| Format | Parquet + ZSTD-9 |
| Row group size | 1,000,000 rows |
| Hardware | 191 cores, ~1.2 TiB usable RAM |
# Single-node, default settings (good for code-gen-shaped data)
python3 convert_jsonl_parquet.py \
--src-prefix viraj/ \
--num-nodes 1 --node-id 0 \
--large-workers 1 \
--threads-per-large-worker 64 \
--memory-per-large-worker 200GB \
--small-workers 20 \
--memory-per-small-worker 32GB \
--size-threshold-gb 0.5 \
--compression-level 9 \
--allow-malformed
# Dry-run (no writes, just shows what would convert)
python3 convert_jsonl_parquet.py --src-prefix viraj/ --dry-run
# Resume an interrupted run — just re-run the same command.
# Already-completed files skip via progress.json + footer validation.
# Retry failed files (with patched script after fixes)
python3 convert_jsonl_parquet.py ... --reset-failed --allow-malformedRun under tmux or nohup for long jobs.
DuckDB reads JSONL directly from S3 via httpfs, parses, compresses to Parquet,
and writes back to S3 — all in one streaming COPY statement. No intermediate
files. Memory caps prevent unbounded growth.
Input: s3://bucket/<src>/path/to/file.jsonl
Output: s3://bucket/<src>-parquet/<2hex>/path/to/file.parquet
^^^^^^^
md5(input_key)[:2]
The 2-hex prefix spreads writes across 256 sub-prefixes — sidesteps S3's 3,500 PUT/sec-per-prefix throttle without affecting reads.
| Pool | When | Workers | Memory each | Threads each |
|---|---|---|---|---|
| Large (Phase 1) | file ≥ --size-threshold-gb |
few (1) | large (200 GB) | many (64) |
| Small (Phase 2) | file < --size-threshold-gb |
many (20) | small (32 GB) | few (auto) |
Huge files need lots of memory; tiny files parallelize wide.
Bin-packs by bytes (not file count) for balanced node load. Deterministic across nodes: same input → same partition assignment.
# Node 0 (also lists + writes the shared S3 manifest)
python3 convert_jsonl_parquet.py --num-nodes 3 --node-id 0 ...
# Node 1 / 2 (read manifest from S3, process their slice)
python3 convert_jsonl_parquet.py --num-nodes 3 --node-id 1 ...
python3 convert_jsonl_parquet.py --num-nodes 3 --node-id 2 ...Start node 0 first so the manifest is built before others try to read it.
Two layers guard against re-conversion:
progress_<shard>.jsonrecords completed files; listing-stage filter skips them instantly.- Footer validation — before any COPY, the script HEADs the output and reads the Parquet footer. Valid footer → skip. Invalid → delete and reconvert.
So even if progress.json is lost, no work is duplicated.
Real JSONL data has surprises. The script auto-handles three classes of issue:
If row 3.18M of a file has a key not seen in the first 20K-row sample (e.g.
extra "Subject" key), the typed read normally fails. union_by_name=true
(always on) extends the schema as new keys appear instead of locking to the
sample.
For files like nemo-pt-v1/output2/passed/* that contain stray bad lines
(e.g. concatenated JSON or trailing garbage). Sets ignore_errors=true —
those rows are silently dropped, the rest of the file converts. Trade-off:
row-count verification can't detect dropped rows.
When DuckDB's 20K-row sample mistypes a column (e.g. inferred INT but later
row has a string), the COPY blows up with a stoi/Conversion Error mid-file.
The retry wrapper transparently tries deeper modes:
Tier 1: typed read (default) — best case, full structure
│
└─ TYPE error ──> Tier 2: sample_size=-1 — scan whole file for types
│ mixed columns become VARCHAR
│
└─ TYPE error ──> Tier 3: read_json(records=false)
single JSON-typed column called `data`
downstream uses json_extract()
Success log surfaces the tier:
OK file.jsonl ...→ typed (tier 1)OK [FULL-SAMPLE] file.jsonl ...→ tier 2OK [RAW-TEXT] file.jsonl ...→ tier 3 (single-column)
To skip the retry-chain delay on known-problem files, force a tier directly
with --full-sample or --raw-text.
These are load-bearing — change at your peril.
DuckDB parses this parameter via std::stoi (32-bit). Setting it to 2^31
(2,147,483,648 = 2 GiB exactly) throws bare "stoi" from SQL parameter
parsing — looks identical to a data-side type error. Current value: 1 GiB
(1,073,741,824).
Parallel COPYs at this DuckDB version + host combo segfaulted. Single-worker is the only stable mode for the large-file phase.
Enabling it caused segfaults at 8-worker concurrency in earlier testing. Single-worker mode could safely turn it on but currently doesn't.
Level 9 hits the sweet spot of size-vs-time for warm-storage workloads (~2-3 reads per few months). Level 15 added ~3-5% better compression at ~3× the CPU time — not worth it for read-rare data.
DuckDB's memory_limit is a soft cap; real usage spikes past during
schema inference + ZSTD buffers. Always leave 300-400 GiB headroom.
| Flag | Default | Purpose |
|---|---|---|
--bucket |
bgen-data-team |
S3 bucket |
--src-prefix |
code-gen/ |
Source prefix to scan |
--dst-prefix |
derived (<src>-parquet/) |
Destination prefix |
--compression |
zstd |
zstd or snappy |
--compression-level |
15 | zstd 1-22 (use 9 for production) |
--row-group-size |
1,000,000 | Rows per Parquet row group |
--num-nodes |
1 | Total nodes in run |
--node-id |
0 | This node's index (0-based) |
--force-refresh-manifest |
off | Node 0 only: rebuild S3 manifest |
--max-files N |
unlimited | Process at most N files |
--min-size-gb / --max-size-gb |
none | Size filters |
--size-threshold-gb |
1.0 | Files ≥ → large pool |
--large-workers |
8 | Workers for large-file phase |
--small-workers |
100 | Workers for small-file phase |
--threads-per-large-worker |
96 | DuckDB threads per large-worker |
--memory-per-large-worker |
100GB | DuckDB memory_limit per large-worker |
--memory-per-small-worker |
4GB | DuckDB memory_limit per small-worker |
--allow-malformed |
off | ignore_errors=true; drops bad JSONL lines |
--full-sample |
off | Force sample_size=-1 from tier 1 |
--raw-text |
off | Force records=false (single-column) |
--reset-failed |
off | Clear failed list, retry those files |
--dry-run |
off | Show planned work, no writes |
--progress-file |
auto | Override progress JSON path |
# Live log tail
tail -f conversion.log
# Snapshot of progress.json
python3 -c "
import json
d = json.load(open('progress_viraj.json'))
s = d['stats']
print(f'OK: {len(d[\"completed\"])}')
print(f'FAIL: {len(d[\"failed\"])}')
print(f'Saved: {(s[\"total_input_bytes\"]-s[\"total_output_bytes\"])/1024**4:.2f} TiB')
"
# Process state (is it actually doing work?)
ps -p $(pgrep -f convert_jsonl_parquet | head -1) -o pid,pcpu,pmem,etime,stat,comm
# Memory / spill check
du -sh tmp_duckdb/ # spill directory size
free -h # total + available RAM
# Audit: which files used the type-fallback?
python3 -c "
import json
d = json.load(open('progress_viraj.json'))
for k, v in d['completed'].items():
if v.get('used_full_sample') or v.get('used_raw_text'):
tag = 'RAW-TEXT' if v.get('used_raw_text') else 'FULL-SAMPLE'
print(f'[{tag}] {k}')
"Cause: maximum_object_size ≥ INT32_MAX. The error is in SQL parameter
parsing, not data. Fix: cap at 1 GiB (1073741824). Current code already
has this.
Cause: OOM killer. Your large-workers × memory-per-worker exceeds
available RAM. Note memory_limit is a soft cap — real usage spikes past it
during schema inference + compression.
Fix: Check free -h, compute (available RAM - 400 GiB headroom) /
(workers × memory). Reduce one of those. On the verified production host
(2 TiB total, 750 GiB consumed by other workloads, 1.2 TiB usable): stay
under ~800 GB total cap across all workers.
Cause: A rare key appears later than DuckDB's inference sample. Fix:
Current code treats this like schema drift and retries that file with
sample_size=-1. If the schema still cannot stabilize, it falls back to raw
JSON-line parquet. For a shard where most files have high-cardinality dynamic
keys, start the run with --full-sample or --raw-text.
Cause: A single JSON record is larger than the cap. Fix: Edit the constant in the script. Current value 1 GiB; do not go ≥ INT32_MAX.
Cause: union_by_name=true schema state grew very large on a
high-cardinality-key file. Fix: Bump --memory-per-small-worker to
≥32 GB, lower --size-threshold-gb to 0.5 so larger files move to the
high-memory pool.
Cause: STS token expired (default 1 hour). The script has automatic secret refresh every 20 minutes, but a single file COPY taking >1 hour will fail.
Fix: Either split huge files, run with a 12-hour token, or accept the retry path (the script retries transient errors with fresh creds).
Cause: Parallel COPYs at high --large-workers combined with
http_keep_alive=true historically crashed DuckDB. Fix: Stick with
--large-workers 1 for the large phase.
It's almost certainly slow, not stuck. A 2 TiB file at 1 GB/s S3 read takes
~35 minutes just to download. The script doesn't log intra-file progress.
Diagnose with ps, /proc/PID/io, du -sh tmp_duckdb/.
s3://bgen-data-team/
├── viraj/ # input (untouched)
│ └── synthetic-code-pt/.../file.jsonl
│
├── viraj-parquet/ # output
│ ├── 00/
│ ├── 01/
│ ├── ...
│ ├── a3/synthetic-code-pt/.../file.parquet
│ └── ff/
│
└── _manifests/
└── viraj.json # shared file listing
-- Everything (all 256 shards):
SELECT * FROM read_parquet('s3://bgen-data-team/viraj-parquet/*/**/*.parquet');
-- A specific path (still globbed across shards):
SELECT * FROM read_parquet(
's3://bgen-data-team/viraj-parquet/*/synthetic-code-pt/.../file.parquet'
);
-- Files written via [RAW-TEXT] fallback have a single `data` column:
SELECT json_extract(data, '$.field_name') FROM read_parquet(...);v2/
├── convert_jsonl_parquet.py # the script (everything)
├── delete_completed_inputs.py # delete inputs after verifying outputs
├── conversion.log # append-only run log
├── progress_<shard>.json # resume state per source prefix
├── tmp_duckdb/ # DuckDB spill directory (auto-created)
└── README.md # this file
After conversions land cleanly, the JSONL inputs can be removed to reclaim S3
storage. delete_completed_inputs.py walks every progress_*.json next to
it and, for each completed entry, deletes the input only after re-verifying
the output on S3.
HEADthe recordedoutput_key— must exist withContentLength > 0.- Read the Parquet footer via DuckDB's
parquet_file_metadata()— same checkconvert_jsonl_parquet.pydoes before skipping a pre-existing output. Fails on truncated / corrupt files. - (Optional
--strict-row-count) the footer row count must equal thefooter_rowsvalue recorded at conversion time.
Only when all three pass does the script delete the input_key. The default
is a dry-run — --execute is required to actually delete anything.
# 1) Dry-run — verifies every completed parquet, deletes nothing.
python3 delete_completed_inputs.py
# 2) After the dry-run is clean, actually delete.
python3 delete_completed_inputs.py --execute
# Stricter: also require footer rows to match the value recorded in progress.
python3 delete_completed_inputs.py --strict-row-count --execute
# Quick smoke test on the first 50 entries (verbose prints each one).
python3 delete_completed_inputs.py --max-files 50 --verbose| Flag | Default | Purpose |
|---|---|---|
--bucket |
bgen-data-team |
S3 bucket for both verify and delete |
--progress-file |
every progress_*.json next to the script |
Repeatable; explicit progress files to read |
--execute |
off (dry-run) | Without this flag, nothing is deleted |
--strict-row-count |
off | Also require footer rows == recorded footer_rows |
--workers |
32 | Concurrent verifier/deleter threads |
--max-files N |
unlimited | Process at most N entries (for smoke tests) |
--verbose |
off | Log every entry considered, not just failures |
Per-file lines plus a summary at the end:
=== summary ===
Verified OK: 1,588
Verify failed: 0
Deleted: 1,588 # only in --execute mode
Delete failed: 0 # only in --execute mode
Reclaimed: 51.77 TiB
Elapsed: ...
Verify failures are listed (first 20) with their reasons — these inputs are kept. Re-run after addressing them.
- Dry-run is the default — there is no flag that bypasses verification.
- Deletes are driven by
progress_*.json, not by listing the destination prefix. A parquet that happens to exist on S3 but isn't recorded in progress will not cause its input to be deleted. - The footer check is the same one the converter uses to decide a pre-existing output is trustworthy. If it passes here, the parquet would also be trusted on a future resume.
--strict-row-countis the strongest setting; use it unless you're re-converting files where--allow-malformedlegitimately dropped rows (row counts won't match in that case).
-
DuckDB's
read_json_autodoesn't acceptall_varchar(onlyread_csv_autodoes). Tier-3 fallback first used a broken parameter, then read_csv with control-char delimiters as a hack, then finally the correctread_json(records=false). -
maximum_object_size = 2^31is the worst possible value — exactly one too high forstoi. Always cap below INT32_MAX. -
Auto-fallback chains are good defaults when data has long-tailed quality issues. Manual flags exist for when you've already learned a file needs tier N.
-
S3 prefix throttling is real but
--large-workers 1makes it moot at this scale. The 2-hex sharding was still worth keeping for safety margin and zero read penalty. -
DuckDB
memory_limitis soft. Plan for 1.5-2× the cap during peak moments (schema inference + ZSTD buffer + row-group flush). -
STS auto-refresh in DuckDB doesn't work via
CREDENTIAL_CHAIN. Resolve via boto3 in Python, push frozen creds into DuckDB secrets, refresh every 20 min explicitly. -
Verify hardware claims with
free -handhtop. Stated specs and reality often diverge by 2-4× on shared hosts.