faucet-sink-jsonl
JSON Lines (.jsonl) file sink for the faucet-stream ecosystem. Writes each record as one JSON object per line in JSON Lines format.
It's the workhorse local-file destination: zero credentials, zero connection setup, and a streaming-friendly format that everything downstream understands (jq, pandas, DuckDB, BigQuery / Snowflake / Spark loaders). Records stream through a buffered async writer so even multi-million-row exports stay fast and bounded in memory, and dated output paths like ./data/dt=2026-03-08/part.jsonl work without pre-creating the directory tree.
Feature highlights
- Buffered async writes — records go through a
tokio::io::BufWriter, so high-volume runs aren't bottlenecked on per-record syscalls. - Append or truncate —
append: trueadds to an existing file; the default truncates on open for a clean export. - Auto-creates parent directories — missing parents of
pathare created (mkdir -psemantics) before the file opens, so dated/partitioned subdirectory paths just work. - Optional compression — behind the
compressionfeature, gzip / zstd are selected automatically from the.gz/.zstsuffix (or set explicitly). Multi-member output stays decoder-compatible across mid-stream flushes. - Optional encryption at rest — behind the
encryptionfeature, every record line is sealed with AES-256-GCM and written base64-encoded (#207). Append-safe; the natural fit for a file-backed DLQ. See Encryption at rest. - Pretty-print mode —
pretty: trueindents each record for human reading (no longer strict JSONL — records span multiple lines). - Flush-safe for CDC —
flush()finalises the writer and the next write reopens in append mode regardless ofappend, so a CDC source's per-transaction flush appends rather than truncates — no data loss mid-stream. faucet doctorpreflight —check()verifies the parent directory is writable (via a throwaway temp file) without ever touching your real output file.
Installation
# As a library:
# In the CLI (connector feature; in the CLI default build):
# With gzip / zstd compression:
Or via the umbrella crate:
sink-jsonl is one of the most common destinations and ships in the CLI's default build. The crate-local compression feature is opt-in.
Quick start
# pipeline.yaml — faucet run pipeline.yaml
version: 1
name: csv_to_jsonl
pipeline:
source:
type: csv
config:
path: ./data/input.csv
sink:
type: jsonl
config:
path: ./out/records.jsonl
Every row of input.csv is written to ./out/records.jsonl as one compact JSON object per line. The ./out/ directory is created automatically if it doesn't exist.
Configuration reference
| Field | Type | Default | Description |
|---|---|---|---|
path |
string (path) | (required) | Path to the output file. Missing parent directories are created automatically. |
append |
bool | false |
Append to an existing file. When false, the file is truncated on first open. |
pretty |
bool | false |
Pretty-print (indent) each record. This breaks strict JSONL — records span multiple lines. |
batch_size |
int | 1000 |
Records per upstream StreamPage. No behavioural effect at this per-record sink; present only for config parity. See Streaming & batching. |
compression |
none | gzip | zstd | auto |
auto |
(requires the compression feature) Output codec. auto picks gzip/zstd from the file suffix; anything else writes uncompressed. See Compression. |
encryption |
object | — | (requires the encryption feature) Seal every line with AES-256-GCM: { key, previous_keys?, algorithm? }. Mutually exclusive with compression. See Encryption at rest. |
Examples
Basic export (truncate mode)
version: 1
pipeline:
source:
type: rest
config:
base_url: https://api.example.com
path: /v1/events
sink:
type: jsonl
config:
path: ./out/events.jsonl
Append for incremental / repeated runs
version: 1
pipeline:
source:
type: postgres
config:
connection_url: ${env:DATABASE_URL}
query: SELECT id, name, signup_date FROM users
sink:
type: jsonl
config:
path: ./out/users.jsonl
append: true # each run adds to the file rather than overwriting it
Partitioned, dated output path
version: 1
pipeline:
source:
type: csv
config:
path: ./data/input.csv
sink:
type: jsonl
config:
# ${now.date} resolves to YYYY-MM-DD per run; the dt=... directory
# is created automatically.
path: ./data/dt=${now.date}/part.jsonl
Gzip-compressed export (requires the compression feature)
version: 1
pipeline:
source:
type: postgres
config:
connection_url: ${env:DATABASE_URL}
query: SELECT * FROM events
sink:
type: jsonl
config:
path: ./out/events.jsonl.gz # .gz suffix → gzip via compression: auto
Streaming & batching
Pipeline::run drives the source's stream_pages and hands each emitted StreamPage to Sink::write_batch. This sink iterates the page record by record, serializing and appending each one through the buffered writer.
Because the write path is inherently per-record, batch_size has no observable effect here — batch_size = 0 (the "no batching" sentinel) and any positive value produce byte-for-byte identical files. The field exists only so every sink shares one config shape; sinks like faucet-sink-postgres (multi-row INSERTs) or faucet-sink-bigquery (streaming-insert request sizing) genuinely use it. The per-run memory bound is set by the source's batch_size (the size of each StreamPage), not by this field.
When a bookmark-carrying page arrives (e.g. from a CDC source), the pipeline calls flush() after the page. This sink finalises the writer and the next write_batch reopens the file in append mode regardless of append, so per-transaction durability never truncates previously-written records.
Compression
Behind the crate-local compression Cargo feature. Adds a compression config field with values none, gzip, zstd, or auto (the default — detects .gz / .zst from the file path):
type: jsonl
config:
path: ./out/events.jsonl.zst
compression: auto # or 'gzip' | 'zstd' | 'none'
flush() finalises the encoder (writes the trailer); a subsequent write appends a fresh gzip/zstd member, producing a multi-member compressed file that standard decoders read back transparently. The sink does not set any HTTP Content-Encoding — the file is a plain compressed file on disk.
Config loading & schema
Configs load from YAML/JSON files, environment variables, or .env files via the CLI's normal loading path. From a library, use the faucet_core::config helpers:
use ;
use JsonlSinkConfig;
let config: JsonlSinkConfig = load_json?;
let config: JsonlSinkConfig = load_env_file?;
Inspect the full JSON Schema with:
Library usage
use ;
use ;
use json;
# async
Wire it to any source through a Pipeline:
use Pipeline;
use ;
use ;
# async
How it works
- The file is opened lazily on the first
write_batchcall and wrapped in atokio::io::BufWriter. An empty batch is a no-op (no file is created). - Missing parent directories of
pathare created (mkdir -p) before the file opens. - The first open obeys
append; re-opens after aflush()always append, so a flush-then-write sequence never truncates earlier data — important for CDC's per-transaction flush. - Each record is serialized to a single JSON line (or pretty-printed) followed by a newline; a
Mutexguards the writer for thread-safe writes. - With the
compressionfeature, the buffered file is wrapped in a gzip/zstd encoder chosen bycompression.resolve(path); a one-shot warning fires if an explicit codec disagrees with the file suffix. flush()finalises and shuts down the writer (flushing the buffer and writing any compression trailer). The defaultSinkimpl does not flush onDrop— callflush()explicitly before the program exits or the tail of the buffer is lost.
Lineage dataset URI
file://<path> — e.g. file:///tmp/output.jsonl.
Feature flags
| Feature | Default | Effect |
|---|---|---|
compression |
off | Adds the compression config field and gzip/zstd encoding via faucet-core/compression. |
This sink does not support effectively-once delivery or upsert/delete write modes — it is an append-only file writer. For effectively-once or upsert semantics, use a transactional sink (faucet-sink-postgres, faucet-sink-bigquery, etc.).
Troubleshooting / FAQ
| Symptom | Likely cause & fix |
|---|---|
failed to create parent directory '...' |
The parent path isn't creatable (permissions, or a path component is a file). Check write permissions on the closest existing ancestor; run faucet doctor to probe writability up front. |
failed to open <path> |
The file path is unwritable (read-only filesystem, no permission, or path is a directory). Verify the path and permissions. |
| File is empty / truncated after the run | You didn't flush(). The buffered/compressed writer only finalises on flush() — call it before dropping the sink (the CLI does this automatically). |
| Previous data disappeared on the next run | Default mode truncates on open. Set append: true to keep prior content across runs. |
| Output isn't valid line-delimited JSON | pretty: true indents records across multiple lines, which breaks strict JSONL. Use pretty: false (the default) for tooling that expects one object per line. |
.gz / .zst file isn't compressed |
The compression feature isn't enabled. Rebuild with --features "sink-jsonl,compression" (CLI) or --features compression (library); without it the suffix is treated as a plain filename. |
Setting batch_size changes nothing |
Expected — it's a no-op for this per-record sink. To bound per-page memory, set the source's batch_size. |
| Records lost mid-stream from a CDC source | Should not happen — re-opens after a per-page flush always append. If you see truncation, confirm nothing else is rewriting the file out-of-band. |
See also
- Sinks reference — the full connector capability matrix.
- Compression cookbook — gzip/zstd across file sinks.
- CLI reference —
faucet run,faucet validate,faucet schema,faucet doctor. faucet-sink-stdout— the same JSON Lines to a standard stream instead of a file.faucet-sink-csv— CSV/TSV file output with full quoting.faucet-sink-s3/faucet-sink-gcs— the same JSONL records to object storage.faucet-core— theSinktrait this connector implements.
Encryption at rest
Behind the crate-local encryption Cargo feature (#207). Each serialized JSON
line is sealed with AES-256-GCM (a fresh random nonce per record) and written
as its base64 encoding plus a newline — the file stays line-oriented and
append-safe across flush/reopen cycles.
sink:
type: jsonl
config:
path: ./dlq/failed.jsonl
encryption:
key: ${vault:secret/faucet#dlq-key} # high-entropy material, not a password
# previous_keys: ["${env:OLD_KEY}"] # rotation: read-only candidates
# algorithm: aes-256-gcm # default (and only) option
- The 32-byte AES key is derived as SHA-256 of the key string (a derivation, not a stretching KDF — source the key from a secrets manager).
- Mutually exclusive with
compression— per-line sealed records cannot form a valid gzip/zstd stream; the conflict is a typed config error before any file is created. faucet dlq inspect/replay/discardread sealed DLQ files transparently (--encryption-key, or automatically from the config'sdlq:block).- The same
encryptionblock sealsfilestate-store bookmarks — see the state cookbook.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.