Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Learn the architecture

Two ways to understand how faucet-stream works. Pick the one that fits you — the switch remembers your choice as you browse.

  • 🎓 Beginner’s guide builds the whole system up as a story, one idea at a time.
  • 🏛 Architect reference is the condensed, subsystem-by-subsystem view for people who already have the mental model.

The buttons above switch this page in place on the published documentation site. If you’re reading the raw Markdown on GitHub (which doesn’t run the site’s scripts), both sections simply appear one after the other below.

The one-sentence idea

faucet-stream moves data from one place to another.

Picture a kitchen faucet: water comes from a pipe (the source), flows through the tap, and out into the sink. faucet-stream is the tap — you say where the data comes from and where it goes, and it moves the data reliably, without losing or scrambling it.

flowchart LR
    S["Source"] -->|records| P["faucet pipeline"] -->|records| K["Sink"]

Everything else — pages, bookmarks, retries, exactly-once — exists to keep that one sentence true even when things go wrong. We’ll add those ideas one at a time.

Chapter 1 — The two characters: Source and Sink

The whole system is built from two roles:

  • A Source knows how to read records from somewhere (a database, an API, a file, a queue).
  • A Sink knows how to write them somewhere else.

A connector is just a Source or Sink for one system (faucet-source-postgres, faucet-sink-bigquery, …). They all speak the same two-role language, which is why any source can feed any sink.

Records are just JSON. A database row, an API response, a file line — they all become plain JSON objects flowing through the pipe. At its simplest, a Source is one function (“give me your records”) and a Sink is one function (“here are records, write them”). That’s a working connector; everything else is optional.

Chapter 2 — Moving data once

Connect a Source to a Sink and you have a pipeline: read everything, write everything.

flowchart LR
    A["source.fetch<br/>read all"] --> B["sink.write"] --> C["done — wrote N records"]

For a one-time copy, this is all you need. Two real-world problems push us further: you don’t want to re-copy everything every run (Chapter 3), and your data might be too big for memory (Chapter 4).

Chapter 3 — Only the new stuff (incremental)

To avoid re-reading everything each run, the Source leaves itself a note — a bookmark — saying “I got up to here” (a timestamp, a log position, an offset). Next run it resumes from that note instead of the beginning.

Here’s the single most important rule in the whole project, and it’s just common sense:

The bookmark is saved only after the data is safely written.

If we saved “got to row 1000” first and then crashed before writing those rows, they’d be lost forever. So the order is always write → make sure it’s really saved → then save the bookmark. Crash in between, and the worst case is redoing a little work (safe) — never skipping data (catastrophic). Keep this rule in your pocket; every advanced feature respects it.

Chapter 4 — Bigger than memory (streaming)

Reading a billion rows into memory won’t work. So instead of “all the data,” the Source produces a stream of pages — chunks of, say, 1,000 records at a time — and the pipeline handles one page at a time:

flowchart LR
    P1["page 1"] --> W1["write"] --> P2["page 2"] --> W2["write"] --> P3["page 3<br/>+ bookmark"] --> W3["write"] --> F["flush"] --> CK["save bookmark"]

Only one page is ever in memory, so a thousand rows or a billion, memory stays flat. The bookmark rides along on the pages, and it’s still saved after the page is safely written — Chapter 3’s rule, now per-page.

Chapter 5 — The production toolbox (reach for these when you need them)

You now understand the spine: a source streams pages, the pipeline writes each page and checkpoints safely, so you can resume after a crash. Everything below is optional — a toolbox you pull from the day you hit the problem a tool solves. Find your situation, then follow the tool to its how-to. The family almost every real pipeline reaches for — shaping the data — comes first.

Shaping the data

The situation you’re inThe tool you reach for
The data isn’t in the shape the destination wantsTransforms
You need joins, aggregates, or real query powerSQL transform

Guarding the data

The situation you’re inThe tool you reach for
Some incoming rows are garbage (nulls, out-of-range)Quality checks
Downstream must never get a surprise shapeContracts
The data has PII you must never leakMasking
The incoming shape drifts from the destination’sSchema drift

Moving it reliably

The situation you’re inThe tool you reach for
A few bad rows keep killing the whole runDead-letter queue
The network or endpoint is flakyRetries & resilience
You must never write a row twice, even after a crashExactly-once
You need a destination table kept mirrored (upserts, deletes)Upsert / write modes

Getting data in and out at scale

The situation you’re inThe tool you reach for
One source is too big for a single workerSharding
Bootstrap a table, then follow its changes with no gapReplication
Replay a bounded slice of historyBackfill
Auto-generate configs from a live catalogDiscovery
Read or write compressed filesCompression

Running & operating it

The situation you’re inThe tool you reach for
Run on a cron scheduleScheduling
Run as a long-lived HTTP serviceServe
Spread runs across many machinesCluster
Start runs on events (a file lands, a webhook, a queue fills)Triggers
Turn one config into many pipelines (a DAG)Matrix & composition
Pull credentials from a secrets managerSecrets

Seeing what happened

The situation you’re inThe tool you reach for
Get metrics and tracesObservability
See where data came from and wentLineage
Alert when data goes stale or volume looks wrongSLA monitoring
Browse every dataset your pipelines have touchedData Movement Catalog
Get paged (Slack / PagerDuty) when something breaksNotifications

When several of the data-guarding tools are on, each page runs them in a fixed, safe order — mask first (so PII can’t leak), then validate (so bad data never lands), then write, then save the bookmark last:

flowchart LR
    PAGE["page"] --> M["mask"] --> Q["quality"] --> C["contract"] --> D["drift"] --> W["write"] --> FL["flush"] --> CK["save bookmark"]

The golden rule never bends, no matter how many tools you add.

The one rule that ties it all together

A bookmark is saved only after the sink has durably written and flushed the page. Write → flush → checkpoint. Always.

Every failure mode, retry, and exactly-once guarantee is a consequence of that one ordering.

Where to go next

Architecture at a glance

faucet-core is a lean library: it knows how to move one source to one sink and checkpoint safely. All orchestration (matrix DAGs, scheduling, the HTTP control plane, clustering) is CLI-layer code built on top. The full reference lives in the repository under docs/architecture/; this is the condensed view.

How a run is assembled

flowchart LR
    cfg["config"] --> comp["compose"] --> interp["interpolate"] --> sec["secrets"] --> parse["parse"] --> exp["expand"] --> exe["executor"] --> pipe["Pipeline"] --> rs["run_stream"]

expand is where a config becomes runnable and where the load-time gates run (exactly-once, write-mode × sink, quarantine-requires-DLQ) — an impossible topology fails faucet validate before any record moves. Deep dive: execution model.

The pipeline loop

run_stream consumes one StreamPage { records, bookmark } at a time and, per page, runs the fixed-order passes then one of three write paths:

flowchart LR
    PAGE["page"] --> M["mask"] --> Q["quality"] --> C["contract"] --> D["drift"] --> WR["write path"] --> FL["flush"] --> CK["checkpoint"]
  • Default (at-least-once): write_batch → flush → persist bookmark.
  • Exactly-once (atomic watermark): write_batch_idempotent(scope, token) → flush → persist (bookmark, seq); a replayed token-stamped write is a no-op.
  • DLQ: write_batch_partial routes per-row failures aside → flush → persist.

Deep dive: pipeline engine, stream pages.

The load-bearing invariant

A page’s bookmark is persisted only after the sink has durably written and flushed that page. Write → flush → checkpoint, in all three paths.

The state store is therefore never ahead of the sink, so recovery can only ever replay attempted work — never skip it. Deep dive: design invariants, recovery.

Delivery guarantees

GuaranteeRequiresOn the crash window
At-least-once (default)nothingreplays the page — may duplicate
Effectively-once / atomic-watermarkidempotent sink + deterministic-replay source + durable state + no DLQskips or re-anchors — no duplication
Effectively-once / keyed-upsertupsert-capable sink + write_mode: upsert|delete + keyre-upsert is a no-op — no duplication

Retry safety

A non-idempotent write_batch is retried only when the sink advertises idempotence — otherwise a lost response could silently duplicate every row. Deep dive: retries, resilience.

The subsystems

AreaReference
Connector SDK (Source/Sink traits)connector-sdk
State & bookmarksstate-management
Batching & adaptive controlbatching
Schema / quality / contracts / maskingschema
Observabilityobservability
Security modelsecurity
Performance & extensibilityperformance · extensibility

Decision history lives in the ADRs; proposals in the RFCs.

Flip this page to 🎓 Beginner’s guide if you’d like the same story from zero.