Skip to content
About
Main content

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 ). 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

The status cycles through six values. How many of them does the 5-second poller ever see?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    aliasing-in-a-polling-pipeline.py59 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

What the poller saw
Status changesPollsChanges seenShareDelete seen
18012126.67%no
Reference runApple M2 · 8 cores · 8 GB
Output · uv run aliasing-in-a-polling-pipeline.py
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 poller
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What 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 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.

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 comparison, a different and costlier method. Capturing every step and every delete needs log-based , which reads the database's own change log.