A canary for your CDC pipeline โ catches silent replication drift before your analysts do.
Project description
๐ค CDCanary
A canary for your CDC pipeline โ catches silent replication drift before your analysts do.
CDC pipelines fail loudly when they crash โ and silently when they don't.
A column added mid-stream lands as NULL in your warehouse. A backfill quietly
skips rows. Replication lag creeps from minutes to days. No error, no alert.
Someone finds out three months later, in a business meeting, with a wrong number
on the slide.
CDCanary is a lightweight monitor that runs aggregate consistency checks between your source and replica on a schedule, and pings Slack when they disagree.
โโโโโโโโโโโโ CDC (Datastream / Debezium / Fivetran / ...) โโโโโโโโโโโโโ
โ MySQL โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโถ โ BigQuery โ
โโโโโโฌโโโโโโ โโโโโโโฌโโโโโโ
โ โโโโโโโโโโโโโ โ
โโโโโโ aggregate โโโโถโ ๐ค CDCanaryโโโโโโ aggregate queries โโโโโ
queries โโโโโโโฌโโโโโโ
โ row delta ยท freshness ยท null-rate ยท schema drift
โผ
Slack alert
Approach
CDCanary deliberately does not do row-level diffing. Aggregate signals (counts, timestamps, null fractions, schemas) catch the failure modes that actually happen in CDC pipelines, run in seconds on billion-row tables, and keep the adapter surface small enough to maintain. It monitors continuously โ this is a canary you run from cron, not a one-off migration validator.
The known blind spot: rows whose values are wrong while counts and null rates still match (e.g. lost UPDATE events). A sampled-checksum check is on the roadmap to cover that probabilistically without paying full-diff costs.
Checks
| Check | Signal | Catches |
|---|---|---|
row_delta |
source vs target row count (windowed) | dropped rows, stalled backfill |
freshness |
source vs target max(updated_at) lag |
replication lag, dead connector |
null_rate |
source vs target null fraction per column | schema-drift NULL corruption |
schema_drift |
column set + coarse types | added/missing columns, type changes |
All checks are symmetric aggregate queries โ any supported connector can be a source or a target. 3 connectors = 9 directions, including reverse ETL (BigQuery โ MySQL) and same-engine replicas (PostgreSQL โ PostgreSQL).
Connectors (v0.1)
MySQL ยท PostgreSQL ยท BigQuery
Quickstart
Zero config โ point it at both ends and see what disagrees:
pip install cdcanary[mysql,bigquery]
cdcanary scan \
--source mysql://readonly:$PW@prod-db/shop \
--target bigquery://my-project/raw_shop
# โ
ok shop.orders row_delta rows match: 1,204,331 vs 1,204,318 (ฮ0.00%)
# โ
ok shop.orders freshness replication lag 4m (within limit)
# ๐ด FAIL shop.products null_rate null-rate drift: sale_status (src 0.0% vs tgt 5.9%)
# ๐ด FAIL shop.coupons table_presence table exists in source but not in target
Then make it permanent โ generate a config and put check on a schedule:
cdcanary init --discover --source mysql://... --target bigquery://...
cdcanary check -c cdcanary.yml # cron / GitHub Actions / Airflow
Try the demo locally
The GIF above is a real run. examples/demo spins up a MySQL
"source" and a PostgreSQL "replica" with four kinds of drift deliberately baked
in โ a missing table, stalled replication, schema drift, and the NULL-corruption
case this tool exists for:
cd examples/demo && docker compose up -d --wait
cdcanary scan \
--source mysql://root:demo@127.0.0.1:13306/shop \
--target postgres://demo:demo@127.0.0.1:15432/shop/public
docker compose down -v # cleanup
Configuration
Config describes rules, not snapshots. A schema pair re-discovers tables
on every run, so tables added or dropped at the source are picked up without
config edits โ and a new source table that hasn't reached the target yet is
itself a finding (table_presence).
connections:
mysql_prod:
type: mysql
host: prod-db.internal
database: shop
user: readonly
password: env:MYSQL_PASSWORD # secrets always come from the environment
bq_raw:
type: bigquery
project: my-project
credentials: env:GOOGLE_APPLICATION_CREDENTIALS
pairs:
- name: shop
source: { connection: mysql_prod, schema: shop }
target: { connection: bq_raw, schema: raw_shop }
tables: ["*", "!tmp_*"]
# target_table: "shop_{table}" # uncomment if the CDC tool renames tables # globs; "!" excludes
defaults: # applied to every discovered table
row_delta: { tolerance_pct: 0.5 }
freshness: { column: auto, max_lag_minutes: 60 }
null_rate: { columns: auto, max_diff_pp: 1.0 }
schema_drift: { ignore_columns: [datastream_metadata] }
overrides: # applied top-to-bottom; later rules win
- match: "log_*" # glob, exact name, or a list of either
checks:
null_rate: false # too big โ skip the expensive check
- match: orders
checks:
freshness: { column: paid_at, max_lag_minutes: 30 }
- match: users
target_table: customers # irregular rename, right next to its exceptions
# Single-table pair โ for tables that deserve hand-tuned checks.
# Can point across schemas/names and coexist with schema pairs above.
- name: payments
source: { connection: mysql_prod, table: shop.payments }
target: { connection: bq_raw, table: raw_shop.payments_v2 }
checks:
row_delta: { tolerance_pct: 0.1, where: "created_at < CURRENT_DATE" }
freshness: { column: paid_at, max_lag_minutes: 15 }
null_rate: { columns: [amount, status], max_diff_pp: 0.5 }
alerts:
slack_webhook: env:CDCANARY_SLACK_WEBHOOK
column: auto picks the first timestamp column by convention
(updated_at, modified_at, created_at, ...); columns: auto compares
every column present on both sides. Schema pairs and table pairs can be mixed
freely โ broad coverage from discovery, precise thresholds where it counts.
Table names that differ between source and target are handled in two layers:
a pair-level target_table: "shop_{table}" template for systematic renames
(CDC tools usually rename uniformly), and per-table target_table in
overrides for the irregular few. If the mapping has no pattern at all,
single-table pairs are the honest answer.
Exit codes are cron-friendly: 0 all green ยท 1 warnings ยท 2 failures.
Non-goals
- Row-level diffing โ full row comparison fits one-off migration validation better than scheduled monitoring. CDCanary sticks to aggregate queries so checks stay fast and cheap enough to run every hour, and connectors stay small. A sampled-checksum check is planned for value-level verification (see roadmap).
- Auto-remediation โ CDCanary detects and alerts; deciding how to fix a pipeline is left to a human.
- Web dashboard โ results are available as terminal output,
--json, and Slack alerts, which integrate with whatever dashboarding you already run.
Roadmap
- v0.1 โ 4 checks, 3 connectors, Slack alerts, CLI
- v0.2 โ
sampled_checksumcheck: hash-compare a sampled PK range on both sides โ catches value-level corruption (e.g. lost UPDATE events) that aggregate signals can't see, without the cost of a full row diff - v0.2 โ baseline state (trend-based anomaly instead of fixed thresholds)
- v0.3 โ Snowflake connector, Prometheus exporter
Releasing
See RELEASING.md โ tags publish to PyPI via trusted publishing.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cdcanary-0.1.0.tar.gz.
File metadata
- Download URL: cdcanary-0.1.0.tar.gz
- Upload date:
- Size: 213.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ac0062cdd4e40b45b52a136ece80ccdf206cfce018ae9e16354a4e2c9c501909
|
|
| MD5 |
b2b14951e1c95de5ab535b0733d91457
|
|
| BLAKE2b-256 |
413d3bd4df2ff8d47dede4ad3d473fe15408e26e3cf87b003f7eacf36080969c
|
Provenance
The following attestation bundles were made for cdcanary-0.1.0.tar.gz:
Publisher:
release.yml on thomas783/cdcanary
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cdcanary-0.1.0.tar.gz -
Subject digest:
ac0062cdd4e40b45b52a136ece80ccdf206cfce018ae9e16354a4e2c9c501909 - Sigstore transparency entry: 2165817149
- Sigstore integration time:
-
Permalink:
thomas783/cdcanary@2aef66d915b5a065f1e4826b261aaf4c607e1781 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/thomas783
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2aef66d915b5a065f1e4826b261aaf4c607e1781 -
Trigger Event:
push
-
Statement type:
File details
Details for the file cdcanary-0.1.0-py3-none-any.whl.
File metadata
- Download URL: cdcanary-0.1.0-py3-none-any.whl
- Upload date:
- Size: 27.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6ff84cefc9698b88076f4c72b9074513515b3f6b2c240fd2971276e4c29a359f
|
|
| MD5 |
6bb14124c0679883862891b16d6292e8
|
|
| BLAKE2b-256 |
f8bfb683b3c0b6a49e294b2e158ff648ac1525f974ffa68c2a17c86fea672559
|
Provenance
The following attestation bundles were made for cdcanary-0.1.0-py3-none-any.whl:
Publisher:
release.yml on thomas783/cdcanary
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cdcanary-0.1.0-py3-none-any.whl -
Subject digest:
6ff84cefc9698b88076f4c72b9074513515b3f6b2c240fd2971276e4c29a359f - Sigstore transparency entry: 2165817397
- Sigstore integration time:
-
Permalink:
thomas783/cdcanary@2aef66d915b5a065f1e4826b261aaf4c607e1781 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/thomas783
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2aef66d915b5a065f1e4826b261aaf4c607e1781 -
Trigger Event:
push
-
Statement type: