Architecture¶
airflow-dq is deliberately not a platform: it's a Python package + an Airflow plugin. Contracts are YAML files, checks are ordinary Airflow tasks, results live in one Postgres schema, and the dashboard is a React app served by the plugin. This page is the readable overview; the full decision log (D1–D16, with tradeoffs) lives in Design decisions.
Data flow¶
flowchart TD
A[Contract Editor<br/>React view in Airflow UI] -->|409-safe save + diff| B[Contract Store<br/>immutable ODCS-YAML versions + registry DB]
B -->|version diff| C[Breaking-Change Detector]
B -->|propose=true| P[Git branch<br/>PR approval flow]
D[Your DAG] -->|build_contract_checks| E[Contract Compiler<br/>rules + SLA properties]
B --> E
E -->|Dynamic Task Mapping| F[One check task per rule]
F --> G[CheckExecutor<br/>Airflow Connection or DSN]
G -->|Postgres / DuckDB / Snowflake* / BigQuery* / Databricks* / Trino*| H[(Warehouse)]
G -->|idempotent write| I[(Results Store<br/>dq schema + migrations)]
G -->|best-effort| J[OpenLineage facets]
G -->|grouped, deduped| K[Alerts<br/>webhook / email / PagerDuty / Opsgenie]
I --> L[FastAPI plugin /dq<br/>auth: Airflow JWT or legacy token]
L --> M[Overview · Dashboard · Quarantine Triage · Editor<br/>React views in Airflow UI]
* experimental backends
In one sentence: author a contract → it is stored as an immutable version → a DAG calls
build_contract_checks() → the compiler turns rules + SLAs into mapped check-tasks → the executor
runs push-down SQL against the backend → results (plus samples/quarantine/anomalies) land
idempotently in the results store and, on failure, one grouped alert goes to the owner's channel →
the embedded dashboard reads the store.
The pieces¶
| Piece | Where | What it does |
|---|---|---|
| Contract store | contracts/ |
One YAML file per immutable version (<id>/<id>.v<N>.yaml), refs like payments@v1; server-side numbering, 409 on conflict, atomic writes; mirrored into a registry DB for the version timeline; optional git "propose" flow. |
| Compiler | compiler/ |
Contract → flat list of CheckSpecs: one schema-drift check per dataset, schema-derived notNull/unique, explicit rules, sla.* checks. Pure Python, no Airflow dependency. |
| Factory & operator | operators/ |
build_contract_checks() expands one DataContractCheckOperator per spec via Dynamic Task Mapping; owns on_fail policy, where-templating, asOfDate injection, results write, quarantine, alerting, lineage. |
| Executors | executors/ |
Pluggable CheckExecutor protocol; shared SQL codegen with a per-engine dialect layer; Airflow-Connection resolution with env-DSN fallbacks; pooled engines + statement timeouts. |
| Results store | results/ |
The dq Postgres schema (results, quarantine, metric anomalies, alert groups, contract registry), auto-migrated; idempotent upserts keyed by (dag_id, run_id, task_id, check_name). |
| Alerting | alerting/ |
Group-claim dispatch: one alert per (run, contract), severity-aware owner routing, four channel types, all best-effort. |
| Plugin | plugin/ |
FastAPI app at /dq (JSON API + served React bundle) registered via the Airflow plugin entry point; auth against the Airflow auth manager. |
| UI | ui/ |
React + TypeScript views (Overview, Dashboard, Quarantine, Editor) on the Airflow-native design system, embedded via external_views; ships pre-built in the wheel. |
The design decisions, condensed¶
The full rationale per decision is in Design decisions (D1–D16); the short version:
| # | Decision |
|---|---|
| D1 | One mapped task per check (Dynamic Task Mapping) — per-check visibility, logs, retries in the Airflow grid, instead of one opaque "validate" task. |
| D2 | Pluggable CheckExecutor protocol, not a SQL engine — adapters generate SQL per backend; dialect differences are contained in a dialect layer. |
| D3 | ODCS rather than a home-grown contract format. |
| D4 | Own dq schema in its own Postgres — never Airflow's metadata DB. |
| D5 | The wedge vs Soda/GE: visual authoring inside the orchestrator + codegen to real tasks + integrated dashboard, self-hosted OSS. |
| D6 | Failure policies fail / warn / quarantine per deployment of a contract. |
| D7 | Auth is explicit — Airflow 3 doesn't protect plugin routes, so airflow-dq does (auth-manager JWT + legacy token). |
| D8 | Tolerances over binary pass/fail — graded warn/fail thresholds against alert fatigue. |
| D9 | Schema drift as one metadata check with coarse type families (no false positives on decimal vs numeric). |
| D10 | Failures are actionable — every failing check carries a bad-row sample from the same predicate as the count. |
| D11 | Low-code authoring + one expression escape hatch + live preview — the Test button runs the real check before saving. |
| D12 | Gate + monitor duality from one contract — inline blocking gate and Asset-triggered record-only monitor. |
| D13 | Profile-to-draft + breaking-change detection — start from a suggested contract; saves are diffed and classified. |
| D14 | Anomaly detection: statistical, push-down, two levels — row-level z-score and server-side per-run metric anomalies. |
| D15 | Incremental by where scope, backfill-safe by logical date — templated partition scoping; freshness measured as-of the run. |
| D16 | OpenLineage, never in the critical path — DQ assertion facets, best-effort, spec-compliant dataset naming. |
Runtime behavior worth knowing¶
- The results write is the only near-critical-path sink, and even it is best-effort-with-retry (backoff, never raises). Quarantine, alerting, lineage and metric-anomaly detection are strictly best-effort — an outage there never fails your pipeline.
- Infra failures are
error, not mystery-red: executor exceptions becomestatus='error'results (recorded + alertable); onlyon_fail="fail"tasks re-raise them. - Everything is idempotent per run: results upsert on the natural key, quarantine replaces-on-retry, alerts claim a group key — Airflow retries and clears don't double-count.
- Two connections per check at most: the warehouse (read-only work) and the results store (writes). Quarantine reads bad rows from the former and writes them to the latter, so the warehouse needs no DQ tables.