Aliasing in a polling pipeline
Break it on purposeNeeds Python
Problem
The simplest way to capture changes is to poll: every few seconds, select the rows whose updated_at is newer than the last value seen (the watermark). Polling samples the table, so it sees the state at each poll and nothing in between. A row that changes several times between two polls shows up once, with its latest value; the intermediate states are aliased away. And a deleted row no longer exists, so no query for changed rows can ever return it.
In PostgreSQL (or SQLite), create an orders table with updated_at. Write a script that changes one order's status three times per second for a minute. Run a poller every 5 seconds that selects updated_at > watermark, and count how many distinct statuses it observed. Then delete one row and see whether the poller ever notices.
The script uses SQLite and a simulated clock, so the run is instant and every timestamp is exact.
The question. Of 180 status changes in a minute, how many does a 5-second poller see, and does it ever see the deleted row?
Predict
Expect: about one observed state per poll, so roughly 12 of 180 changes seen (60 s ÷ 5 s = 12 polls), and the delete is never seen.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
aliasing-in-a-polling-pipeline.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Aliasing in a polling pipeline. An orders table with updated_at in SQLite; one order's status changes several times a second while a poller selects updated_at > watermark every few seconds. Then one row is deleted. Time is simulated, so the run is instant and exact: each change and each poll carries its own timestamp. """ import argparse, json, sqlite3 p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--changes-per-second", type=float, default=3.0, help="Status changes per second on the busy order.") p.add_argument("--seconds", type=int, default=60, help="Length of the run.") p.add_argument("--poll-every", type=float, default=5.0, help="Seconds between polls.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() STATUSES = ["placed", "paid", "packed", "shipped", "out for delivery", "delivered"] db = sqlite3.connect(":memory:") db.execute("CREATE TABLE orders (order_id INTEGER PRIMARY KEY, status TEXT, updated_at REAL)") db.executemany("INSERT INTO orders VALUES (?, 'placed', 0)", [(1,), (2,)]) # The timeline: status changes on order 1, polls, and one delete of order 2 half-way events = [(i / args.changes_per_second, "change") for i in range(1, int(args.seconds * args.changes_per_second) + 1)] events += [(k * args.poll_every, "poll") for k in range(1, int(args.seconds / args.poll_every) + 1)] events += [(args.seconds / 2 + 0.01, "delete")] events.sort(key=lambda e: (e[0], e[1] != "change")) # a change at the same instant lands before the poll watermark, n_changes, seen, polls = 0.0, 0, [], [] for t, kind in events: if kind == "change": n_changes += 1 db.execute("UPDATE orders SET status = ?, updated_at = ? WHERE order_id = 1", (STATUSES[n_changes % len(STATUSES)], t)) elif kind == "delete": db.execute("DELETE FROM orders WHERE order_id = 2") else: rows = db.execute("SELECT order_id, status, updated_at FROM orders WHERE updated_at > ?", (watermark,)).fetchall() for oid, status, ts in rows: seen.append({"t": t, "order_id": oid, "status": status}) watermark = max(watermark, ts) polls.append({"poll_at": t, "rows_returned": len(rows)}) observed = sum(1 for s in seen if s["order_id"] == 1) delete_seen = any(s["order_id"] == 2 for s in seen) print(f"{n_changes} status changes on order 1 in {args.seconds} s; {len(polls)} polls every {args.poll_every:g} s") print(f"changes observed by the poller: {observed} of {n_changes} ({observed / n_changes:.0%}); " f"distinct statuses seen: {len({s['status'] for s in seen if s['order_id'] == 1})} of {len(STATUSES)}") print(f"order 2 deleted at {args.seconds / 2:g} s: {'seen' if delete_seen else 'never seen'} by the poller") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"polls": polls, "summary": [{ "changes": n_changes, "observed": observed, "observed_pct": observed / n_changes * 100, "polls": len(polls), "delete_seen": "yes" if delete_seen else "no"}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run aliasing-in-a-polling-pipeline.py uv run aliasing-in-a-polling-pipeline.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --changes-per-second3 | Status changes per second on the busy order. |
| --seconds60 | Length of the run. |
| --poll-every5 | Seconds between polls. |
Our run
What the poller saw
| Status changes | Polls | Changes seen | Share | Delete seen |
|---|---|---|---|---|
| 180 | 12 | 12 | 6.67% | no |
180 status changes on order 1 in 60 s; 12 polls every 5 s
changes observed by the poller: 12 of 180 (7%); distinct statuses seen: 2 of 6
order 2 deleted at 30 s: never seen by the pollerWhat we found
The poller saw 12 of 180 changes (6.67%), one per poll, as expected, and the deleted row: no. The run also shows aliasing in its literal, signal-processing sense. The status cycles through six values at three changes per second, and the 5-second poll lands on the same two positions of that cycle every time, so the poller saw only two of the six statuses at all. A sampler whose period lines up with the signal does not just miss steps; it reports a pattern that is not there.
Read it
Polling is fine for a sink that wants only the latest state of each row. For a path-dependent sink (an audit trail, a history table, an action triggered by each event) every skipped step is silent data loss, and polling faster only shrinks the gap.
Hard deletes are invisible to polling in principle: a row that no longer exists cannot appear in a query for rows that changed. Finding them takes a full comparison of key lists between source and target, which is a snapshot comparison, a different and costlier method. Capturing every step and every delete needs log-based CDC, which reads the database's own change log.