Polling and the completeness gap
What can polling never see, and why can no method report its own blind spot?
Query/diff-based CDC and the completeness gap
The cheapest rung
Poll for rows whose updated_at is greater than the last watermark (the highest timestamp you have already processed):
SELECT * FROM orders
WHERE updated_at > :last_watermark
ORDER BY updated_at;It needs no special privileges, no invasive installation, and works against anything with a SQL interface. This is Tariq's first attempt.
Polling is sampling
It inherits the arithmetic of sampling. Anything that changes more than once per poll interval is aliased: you observe the final state and lose the intermediate ones.
Example (Tariq's audit problem). An order moves confirmed → packed → shipped within 4 minutes. The poller runs every 15 minutes. It sees only shipped. The packed state is gone forever.
Whether this matters depends on the consumer:
- A state-convergent sink (an upsert into a table) only cares about the final state. Aliasing is harmless.
- A path-dependent sink (an audit trail, a history-keeping table, an action triggered by each event) needs every step. For it, aliasing is silent data loss.
Two failures worse than aliasing
Both are structural, not accidental.
- Hard deletes are invisible in principle. A row that no longer exists cannot appear in a query for rows that changed. This is Tariq's 212 cancelled orders. (A full comparison of key lists between source and target can find deleted rows. But that is a snapshot comparison, a different and costlier method, not watermark polling.)
- The watermark column is maintained by application code. Any writer that forgets to set it (a DBA correction, a migration script, an ORM that bypasses the field) produces a change that is unobservable forever, not merely late. This is Lena's price-correction script.
Add two more traps. Clock skew: the clocks of different application servers differ slightly, so updated_at is not perfectly ordered. Long transactions: a transaction that started before your watermark but commits after it inserts a row with a timestamp that is already in your past. In PostgreSQL, for example, now() returns the time the transaction started. A transaction that starts at 10:00:00 and commits at 10:07:00 writes updated_at = 10:00:00. If your poll ran at 10:05 and moved the watermark to 10:05, the row appears at 10:07 with a timestamp before the watermark, and the next poll will never select it. Overlapping windows (always re-read the last few minutes) mitigate this. Nothing eliminates it.
Generalizing: every method has a bypass set
Every detection mechanism has a set of writes that bypass it:
- bulk or direct-path loads that skip the log,
TRUNCATE, which is DDL rather than DML and may leave no per-row records,- restores from backup,
- replica rebuilds,
- DDL-driven rewrites.
Each mechanism has a different bypass set, and none of them can report the size of its own blind spot.
This is the general independence-assumption problem applied to completeness: your pipeline assumes it sees everything, and it cannot verify that from the inside.
In one line: polling detects transitions the application chose to advertise; everything else is invisible by construction, not by bug.