Watermark with an idle partition
Simulate a modelNeeds Python
Problem
A global watermark is the minimum of the partitions' watermarks, so one partition that stops sending holds every window open: the system cannot know whether the quiet partition has old events still to come. An idleness timeout excludes a quiet partition from the minimum so that windows close, which is correct only if the partition really has nothing old to send; if it comes back with a backlog, those events arrive after their windows closed.
Write a small simulation of 3 partitions where one stops sending. Compute the global watermark as the minimum, and record when windows close. Add an idleness timeout (2 minutes) and record what changes. Then make the idle partition send its old records after the timeout and count how many become late.
The question. How long do windows wait while a partition is idle, and how many of its events become late once a timeout lets windows close?
Predict
You should see the stall without the timeout, and a measurable count of late records with it.
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
watermark-with-an-idle-partition.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """A watermark with an idle partition. Three partitions send one event per second each, with up to 0.2 minutes of skew. Partition 3 goes quiet at minute 10; at minute 20 it sends the events it held back (event times from minute 10 to 20), then carries on. The global watermark is the minimum of the partition watermarks (max event time seen minus a delay). Records how late each 1-minute window closes, without an idleness timeout and with one, and counts events that arrive after their window closed. """ import argparse, json, random p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--minutes", type=int, default=30, help="Simulated minutes.") p.add_argument("--idle-at", type=int, default=10, help="Minute at which partition 3 goes quiet.") p.add_argument("--resume-at", type=int, default=20, help="Minute at which partition 3 sends its backlog.") p.add_argument("--timeout", type=float, default=2.0, help="Idleness timeout (minutes).") p.add_argument("--delay", type=float, default=0.5, help="Watermark delay (minutes).") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() def run(timeout): rng = random.Random(args.seed) seen = {0: 0.0, 1: 0.0, 2: 0.0}; last = {0: 0.0, 1: 0.0, 2: 0.0} closed = 0; late = 0; delays = []; backlog_sent = False for s in range(args.minutes * 60): now = s / 60; arrivals = [] for part in (0, 1, 2): if part == 2 and args.idle_at <= now < args.resume_at: continue # quiet arrivals.append((part, max(0.0, now - rng.uniform(0, 0.2)))) if now >= args.resume_at and not backlog_sent: # the held-back events, all at once arrivals += [(2, args.idle_at + i / 60) for i in range((args.resume_at - args.idle_at) * 60)]; backlog_sent = True for part, ev in arrivals: if ev < closed: late += 1 # its window has already closed seen[part] = max(seen[part], ev); last[part] = now active = [seen[p_] for p_ in seen if timeout is None or now - last[p_] <= timeout] wm = min(active) - args.delay while closed + 1 <= wm: closed += 1; delays.append(now - closed) # window [closed-1, closed) closes return {"idleness_timeout": f"{timeout:g} min" if timeout is not None else "none", "windows_closed": closed, "longest_close_delay_min": max(delays), "late_events": late} rows = [run(None), run(args.timeout)] for r in rows: print(f"timeout {r['idleness_timeout']:<6}: {r['windows_closed']} windows closed, longest wait past a window's end {r['longest_close_delay_min']:.1f} min, late events {r['late_events']}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"runs": rows}}, 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 watermark-with-an-idle-partition.py uv run watermark-with-an-idle-partition.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --minutes30 | Simulated minutes. |
| --idle-at10 | Minute at which partition 3 goes quiet. |
| --resume-at20 | Minute at which partition 3 sends its backlog. |
| --timeout2 | Idleness timeout (minutes). |
| --delay0.5 | Watermark delay (minutes). |
| --seed0 | Random seed. |
Our run
Partition 3 quiet from minute 10 to 20
| Idleness timeout | Longest wait past a window end (min) | Late events |
|---|---|---|
| none | 10 | 0 |
| 2 min | 2 | 540 |
timeout none : 29 windows closed, longest wait past a window's end 10.0 min, late events 0
timeout 2 min : 29 windows closed, longest wait past a window's end 2.0 min, late events 540What we found
Without a timeout, windows waited up to 10 minutes past their end, the whole silence, and no event was late (0). With a 2-minute timeout, the wait was at most 2 minutes, and when the partition sent its backlog, 540 of its events arrived after their windows had closed.
Read it
An idleness timeout trades a stall for late data, and which is right depends on the source: a partition that is quiet because nothing happens can be skipped; one that is quiet because it is down and buffering cannot. Know which kind of silence each source has, and give the late path somewhere to go.