Run Rivet on Apache Airflow
Rivet extracts your tables; Airflow schedules and watches them. This recipe makes
Airflow’s graph be Rivet’s extraction plan — small tables parallelised, heavy
tables run one at a time, a barrier between waves — with per-table retries, logs,
and alerting for free, and a real rivet binary doing the work. It builds one
DAG per source database (PostgreSQL, MySQL, SQL Server) from a single factory.
MongoDB fits the same pattern. MongoDB is a first-class Rivet source (full + CDC), so a
mongo.yamlconfig yields arivet_waves_mongoDAG analogous to the relational ones — same factory, same plan → waves → graph. The checked-in demo ships the three relational engines; add a Mongo config to extend it.
docs/recipes/airflow/
├── Dockerfile # apache/airflow + the rivet release binary baked in
├── docker-compose.2.10.yaml # official Airflow 2.10 stack, adapted
└── dags/
├── rivet_waves_dag.py # factory → rivet_waves_{postgres,mysql,mssql}
├── postgres.yaml / mysql.yaml / mssql.yaml # the configs you edit ← yours
└── postgres.plan.json / mysql.plan.json / … # rivet plan output (auto-refreshed)
Try it locally
This is the official Apache Airflow docker-compose stack (Postgres metadata +
Redis + CeleryExecutor — not SQLite/Sequential), adapted three ways: example DAGs
are off, the worker image has rivet baked in, and a dedicated TLS-enabled
Postgres holds Rivet’s durable run state.
cd docs/recipes/airflow
docker compose -f docker-compose.2.10.yaml up --build # Airflow 2.10.5
First boot builds the image and migrates the metadata DB (~2-3 min). Then open
http://localhost:8080 (login airflow / airflow). There are seven DAGs
— rivet_waves_postgres, rivet_waves_mysql, rivet_waves_mssql
(local Parquet), the same three with an _s3 suffix (shared MinIO bucket), and
rivet_waves_postgres_gcs — all from the same factory (a MongoDB config would
add an eighth, rivet_waves_mongo). Un-pause and trigger one, and open Graph:
plan ──> wave_2 ──────────> wave_3 ───────────────> wave_4
[small tables [bench_decimal → [bench_narrow →
in parallel] bench_hc → content_items]
orders → bench_wide] (one at a time)
- The demo runs against the project’s fixtures on the host (Postgres :5432, MySQL
- 3306, SQL Server :1433), so
docker compose upin the rivet repo first.docker compose -f … down -vtears everything down (-vdrops the state too).
(The DAG keeps a try/except around the BashOperator import so it still parses
on Airflow 3.x — where it moved to the standard provider — if you point it at a
3.x stack yourself.)
What the graph does
plan → waves. rivet plan scores every export (size, cursor quality, chunk
geometry, risk) and groups them into waves. The DAG runs the waves lowest-first
with a barrier between them — wave N+1 starts only after every task in wave N
succeeds.
Cheap parallel, heavy serial — within a wave. This is the part that matters:
only the cheap exports (planner cost_class: low) run in parallel. The heavier
ones run one at a time (a sequential chain). The whole reason the planner
defers big tables into a late wave is to not pile several large scans onto the
source at once — so running them in parallel would defeat the point. (Same split
as the in-engine rivet apply --parallel-export-processes.)
Each task is a real rivet run. A wave task is rivet run --config <source>.yaml --export <table> — a single table, with --reconcile (source
COUNT(*) vs exported rows) on a fresh full/chunked export. The task log is the
actual rivet output:
✓ orders incremental 250,000 rows 1 files 3.4 MB 2.7s RSS 64 MB
A failing reconcile (or any error) exits non-zero with a stable [RIVET_*] code,
so the task fails loudly and the wave barrier stops everything downstream — before
a half-extracted table reaches a warehouse load.
Config → plan → graph (no manual steps)
The graph is generated from Rivet’s planner, and each DAG’s plan task keeps it
fresh:
dags/<source>.yamlis the config you edit (postgres.yamletc).rivet init --source <url>scaffolds one from your live schema (use--exclude '<glob>'to drop test / junk tables); the checked-in samples are the fixtures trimmed to a clean set.- The DAG’s first task,
plan, runsrivet plan --format jsonand atomically rewrites<source>.plan.json(temp file, swapped in only if rivet succeeded and the output is valid JSON — a failed plan can’t truncate the graph source and break the DAG). - The DAG reads
<source>.plan.jsonat parse time to lay out the waves. So editing the config and re-running the DAG re-shapes the graph on the next parse — no hand-run CLI, no committing the plan by yourself. The checked-in sample makes the DAG work on the very first boot.
Same-named tables across engines never collide: each source writes to its own
./output/<engine>/<table>/ and keeps its own state database, so a bench_hc in
Postgres and one in MySQL don’t share cursor / shape / file-log state.
Skip tables — you don’t have to extract everything
Set an env var on the workers (or in the compose environment:):
| Variable | Effect |
|---|---|
RIVET_EXCLUDE | Comma-separated tables to drop. A wave left empty disappears. |
RIVET_ONLY | Comma-separated allow-list — run only these. |
Many sources → one bucket
For a shared S3 / GCS data lake, point each export’s destination: block —
nested inside every exports[] entry, as in the checked-in
dags/postgres.s3.yaml; there is no top-level destination in rivet configs —
at the same bucket with a per-source prefix so same-named tables across
engines never collide:
# postgres.s3.yaml
exports:
- name: bench_hc
# ...
destination:
type: s3
bucket: my-data-lake # ← one bucket for every source
prefix: rivet/postgres/{export}/ # ← namespaced by source; {export} = table
# mysql.s3.yaml → prefix: rivet/mysql/{export}/
# mssql.s3.yaml → prefix: rivet/mssql/{export}/
A bench_hc then lands at s3://my-data-lake/rivet/postgres/bench_hc/, the MySQL
one at …/rivet/mysql/bench_hc/ — separate objects. The namespace is required,
not cosmetic: different databases are different data, and the same logical table
yields different Arrow types per engine (uuid_col is FixedSizeBinary(16) on
PG/MSSQL but Utf8 on MySQL; MySQL drops the timestamp’s UTC tz) — merging them
under one prefix would write a dataset with incompatible schemas.
The checked-in *.s3.yaml / postgres.gcs.yaml configs build the cloud DAGs
(rivet_waves_<source>_s3, rivet_waves_postgres_gcs); the demo writes to the
project’s MinIO / fake-gcs emulators (worker env supplies AWS_ACCESS_KEY_ID /
AWS_SECRET_ACCESS_KEY). A real bucket drops the endpoint + allow_anonymous
and uses normal cloud credentials.
Recovery — all from Airflow, no shell
A chunked export checkpoints per chunk, so a kill mid-chunk is recoverable. The whole recovery story is operable from the Airflow UI:
| Situation | What to do |
|---|---|
| Transient crash mid-chunk (worker killed, timeout, retry) | Nothing — automatic. The retry’s plain run resumes the crashed checkpoint from the last good chunk. A checkpoint still held by a LIVE rivet process is refused, and the task fails rather than touch it. |
| Unresumable checkpoint (chunk params changed, or you want a clean re-extract) | Admin → Variables → rivet_reset = comma-list of tables → trigger the DAG. Each listed table’s checkpoint is wiped (rivet state reset-chunks) before its run. Clear the Variable afterwards. |
| Anything else | the normal task Clear / re-trigger in the UI. |
A resumed run writes only the chunks left after the crash, so the reconcile gate
is dropped on the resume path (a full-table COUNT(*) would false-mismatch the
remainder) — the same reasoning as skipping reconcile on incremental exports.
Architecture
rivetbinary — baked into the worker image from the published release (Dockerfile, arch-aware: pulls thex86_64oraarch64linux build). To pin a version, setRIVET_VERSIONin the composebuild.args.- Durable state —
rivet-state-db, a dedicated TLS Postgres service, with a separate database per source (rivet_state_postgres/_mysql/_mssql); the factory passes each DAG its ownRIVET_STATE_URLwithsslmode=require. SQLite state in a bind-mount corrupts under parallel writers; Rivet refuses to send state credentials in cleartext to a non-loopback host (CWE-319), so the service speaks TLS (a self-signed cert generated at startup — fine in-cluster). - Source credentials — Airflow Connections. Each source is an Airflow
Connection (
rivet_postgres/rivet_mysql/rivet_mssql), editable in the UI (Admin → Connections); the demo seeds them viaAIRFLOW_CONN_*in the compose so it works out of the box. The DAG builds the URL from the connection’s fields in rivet’s scheme — notget_uri(), whose scheme differs per engine (Airflow emitsmssql://, rivet wantssqlserver://) — and exports it as the env var the config’surl_envreads. Nothing about a database is hard-coded in the DAG. The demo opts into plaintext to the fixtures withsource.tls: { mode: disable }; a real remote DB usesmode: verify-full. - State schema is created up front.
initdbcreates the state databases, but rivet’s tables are created on first connect — under parallelism that races (“create version table: db error”). A one-shotrivet-state-schema-initservice runsrivet state showagainst every state DB at boot, so the schema exists before any wave task runs. The Airflow services wait for it.
Why this shape
Rivet’s planner is deliberately advisory (ADR-0006):
it scores and groups, it does not schedule. That’s what lets a real scheduler own
execution — retries, backfills, SLAs, alerting — while Rivet owns source safety
(which tables to defer, which to run serially, how hard to push the database).
This recipe is the seam between the two: the planner’s waves and cost classes
become Airflow’s graph, and nothing about your database or its credentials leaves
your environment.