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 |
sampled_checksum |
row contents on a sampled key set (newest half + a rotating spread across the whole range) | value-level corruption โ lossy casts, tz shifts, lost updates |
Aggregate checks are symmetric 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).
Performance
Measured on a PostgreSQL 16 โ PostgreSQL 16 pair in local Docker
(Apple Silicon), single orders-shaped table, default options:
| check | 10M rows (0.9 GiB) | 100M rows (8.6 GiB) |
|---|---|---|
row_delta |
1.6s | 22s |
freshness |
0.7s | 15s |
null_rate (6 columns) |
3.2s | 101s |
schema_drift |
<0.1s | 0.1s |
sampled_checksum (strategy: recent) |
<0.1s | <0.1s |
sampled_checksum (strategy: mixed, n=100) |
1.4s | 38s |
Client peak memory was 57 MB at both scales โ every check either aggregates inside the database or fetches a fixed-size sample, so table growth costs the client nothing.
Two honest observations from the 100M run:
- Full-scan checks (
null_rate, thespreadhalf ofsampled_checksum) go I/O-bound once the table outgrows the database's cache โ the jump from 3s to 101s is disk, not the tool. On tables that size, scope them down viaoverrides(null_rate: false,strategy: recent, or awherewindow) if the full default set is slower than your check interval. - Index-backed checks (
row_delta,freshness,recentsampling) stay cheap enough to run every few minutes at any of these scales.
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.products sampled_checksum row content drift: 6/90 sampled rows differ (cols: price; e.g. id=75)
# ๐ด 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 five kinds of drift deliberately baked
in โ a missing table, stalled replication, schema drift, a lossy price cast
only row-content sampling can see, 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
The same seeds double as the integration suite โ pytest -m integration boots
them in throwaway testcontainers and asserts
every seeded drift is caught (runs in CI on each push).
Run it on a schedule (GitHub Actions)
cdcanary check is designed to be a cron job, and GitHub Actions is the
cheapest cron you already have. examples/github-actions
has a copy-paste workflow: scheduled runs, secrets-based credentials, warn/fail
mapped to run status, results kept as artifacts.
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
- Full row-level diffing โ comparing every row fits one-off migration
validation better than scheduled monitoring. CDCanary keeps checks fast and
cheap enough to run every hour: aggregates for the broad signals, plus
sampled_checksumon a small deterministic sample for value-level verification โ never a full-table diff. - 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: compare row contents on a sampled key set (newest half for fresh breakage + a rotating spread that walks the whole table across runs, so lost UPDATEs on old rows surface too) โ value-level corruption 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.2.0.tar.gz.
File metadata
- Download URL: cdcanary-0.2.0.tar.gz
- Upload date:
- Size: 254.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e782df8f9ebd3c929d680cf996e93fa67e3e1079260975eaa5f47e29f3ec86fb
|
|
| MD5 |
18adc66d3b65e904bc42cf2810983427
|
|
| BLAKE2b-256 |
3d76a848360d4890142349aa9b4f5d3a4c3523cdf1b217df549a7e975b7a7298
|
Provenance
The following attestation bundles were made for cdcanary-0.2.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.2.0.tar.gz -
Subject digest:
e782df8f9ebd3c929d680cf996e93fa67e3e1079260975eaa5f47e29f3ec86fb - Sigstore transparency entry: 2187072335
- Sigstore integration time:
-
Permalink:
thomas783/cdcanary@0533b8c703377dd2ddf2e023014e09efc432cf2f -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/thomas783
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@0533b8c703377dd2ddf2e023014e09efc432cf2f -
Trigger Event:
push
-
Statement type:
File details
Details for the file cdcanary-0.2.0-py3-none-any.whl.
File metadata
- Download URL: cdcanary-0.2.0-py3-none-any.whl
- Upload date:
- Size: 33.1 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 |
eea633bff08bfd3778fc668c696672efb2b9540340956d19fa42ad60af518e57
|
|
| MD5 |
fa4bc3037eca775bb1925eb51ab83c18
|
|
| BLAKE2b-256 |
d151e7e3fb4fa3c3b8b511d0c4e6a973b526b2ef89151b93a2c0a78fb7e398e4
|
Provenance
The following attestation bundles were made for cdcanary-0.2.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.2.0-py3-none-any.whl -
Subject digest:
eea633bff08bfd3778fc668c696672efb2b9540340956d19fa42ad60af518e57 - Sigstore transparency entry: 2187072353
- Sigstore integration time:
-
Permalink:
thomas783/cdcanary@0533b8c703377dd2ddf2e023014e09efc432cf2f -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/thomas783
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@0533b8c703377dd2ddf2e023014e09efc432cf2f -
Trigger Event:
push
-
Statement type: