Skip to main content

Lab 1 โ€” Build a watermarked tumbling-window aggregator

Objectiveโ€‹

By the end of this lab you will have a runnable Python module, stream.py, that consumes a simulated event stream โ€” deliberately out of order, with one genuinely late record โ€” and produces correct per-window counts using a watermark to decide when each tumbling window is complete. The late record is detected and dropped on purpose, not silently miscounted. The module uses only the Python standard library, so it costs nothing and runs anywhere.

Skills exercised:

  • Assigning events to tumbling windows by event time
  • Advancing a monotonic watermark from the maximum event time seen
  • Closing windows and releasing their state when the watermark passes their end
  • Detecting and handling a late event per a stated policy

Prerequisites and setupโ€‹

Prerequisites: the three lessons in this module โ€” Event time vs. processing time, Windowing and watermarks, and Stream processing patterns. This lab turns the watermark bookkeeping from Lesson 2 and the late-event policy from Lesson 3 into working code.

Setup: you need Python 3.10 or newer. There are no packages to install; everything uses the standard library. Verify your version and create a working folder.

python3 --version
mkdir de205-stream-lab && cd de205-stream-lab

Expected output from the version command is a line like Python 3.11.6. If the command is not found, install Python from python.org before continuing. In the steps below, use python3 on macOS/Linux and python on Windows.

note

Every step edits the single file stream.py in this folder. If you close your terminal and come back, re-run cd de205-stream-lab to return to it; your file is still there. Each step adds to the file, so keep the code from earlier steps in place.

Step 1 โ€” Create the event sourceโ€‹

Goal: build the generator that emits the stream, with events deliberately out of order and one that will arrive late.

Create stream.py with the constants and the generator:

from collections import defaultdict

WINDOW_SIZE = 10 # seconds spanned by each tumbling window
ALLOWED_LATENESS = 5 # how far the watermark trails the newest event time


def generate_events():
"""Yield (event_time, sensor_id) pairs, deliberately out of order.

Event times are whole seconds. The event at event_time 7 arrives long
after its window has closed โ€” it is the late event you will handle later.
"""
script = [
(2, "sensor-a"),
(5, "sensor-b"),
(8, "sensor-a"),
(13, "sensor-c"),
(3, "sensor-a"), # out of order, but still within allowed lateness
(16, "sensor-b"),
(25, "sensor-a"), # jumps the watermark forward
(7, "sensor-b"), # late: window [0, 10) has already closed
(22, "sensor-c"),
(28, "sensor-a"),
]
for event in script:
yield event

Checkpoint: count the events the generator produces.

python3 -c "import stream; print(sum(1 for _ in stream.generate_events()))"

You should see 10. If you see a different number, compare your script list against the ten rows above โ€” a missing comma silently merges two tuples.

Step 2 โ€” Assign events to tumbling windowsโ€‹

Goal: map any event time to the start of the tumbling window it belongs to.

Add this function to stream.py:

def window_start(event_time):
"""Return the start second of the tumbling window an event falls in."""
return (event_time // WINDOW_SIZE) * WINDOW_SIZE

Checkpoint: check three event times land in the right windows.

python3 -c "import stream; print(stream.window_start(7), stream.window_start(13), stream.window_start(28))"

You should see 0 10 20: event time 7 is in window [0, 10), 13 is in [10, 20), and 28 is in [20, 30). If every value is 0, WINDOW_SIZE is not set to 10; check the constant from Step 1.

Step 3 โ€” Aggregate with a watermark that closes windowsโ€‹

Goal: build the processor that counts events per window, advances a watermark, and closes any window the watermark has passed. This version does not yet handle late events โ€” you add that in Step 4.

Add this class to stream.py:

class TumblingWindowProcessor:
"""Aggregate a stream into tumbling windows, gated by a watermark."""

def __init__(self, window_size=WINDOW_SIZE, allowed_lateness=ALLOWED_LATENESS):
self.window_size = window_size
self.allowed_lateness = allowed_lateness
self.watermark = None # None until the first event arrives
self.open_counts = defaultdict(int) # window_start -> running count
self.closed_counts = {} # window_start -> final count

def _window_end(self, start):
return start + self.window_size

def process(self, event_time, key):
"""Route one event into its window, then advance the watermark."""
start = window_start(event_time)
self.open_counts[start] += 1
candidate = event_time - self.allowed_lateness
if self.watermark is None or candidate > self.watermark:
self.watermark = candidate
self._close_ready_windows()

def _close_ready_windows(self):
for start in sorted(self.open_counts):
if self._window_end(start) <= self.watermark:
self.closed_counts[start] = self.open_counts.pop(start)

Checkpoint: feed a short in-order sequence and inspect the state.

python3 -c "import stream; p = stream.TumblingWindowProcessor(); [p.process(t, 'k') for t in [2, 5, 8, 13, 16]]; print(dict(p.closed_counts), dict(p.open_counts), p.watermark)"

You should see {0: 3} {10: 2} 11: three events closed in window [0, 10), two still open in [10, 20), and the watermark at 11 (the max event time 16 minus the 5-second lateness). If closed_counts is empty, the watermark never reached a window end โ€” confirm _close_ready_windows compares _window_end(start) <= self.watermark.

Step 4 โ€” Detect and drop late eventsโ€‹

Goal: give the processor a deliberate late-event policy. A record whose window has already closed must not reopen it or corrupt a neighbor; here the policy is to route it to a late_events list and drop it from the counts.

First, add one line to __init__ to hold late events. Place it after the closed_counts line:

self.late_events = [] # events that arrived after their window closed

Then replace your process method with this version, which checks for lateness before counting:

def process(self, event_time, key):
"""Route one event into its window, or set it aside if it is late."""
start = window_start(event_time)
end = self._window_end(start)
if self.watermark is not None and end <= self.watermark:
self.late_events.append((event_time, key))
return
self.open_counts[start] += 1
candidate = event_time - self.allowed_lateness
if self.watermark is None or candidate > self.watermark:
self.watermark = candidate
self._close_ready_windows()

Checkpoint: feed the whole stream through process and inspect the result.

python3 -c "import stream; p = stream.TumblingWindowProcessor(); [p.process(t, k) for t, k in stream.generate_events()]; print(dict(p.closed_counts), 'late=', p.late_events)"

You should see {0: 4, 10: 2} late= [(7, 'sensor-b')]: window [0, 10) closed with four events (including the out-of-order 3), window [10, 20) with two, and the single late record (7, 'sensor-b') set aside instead of miscounted. If late is empty, the lateness guard is not firing โ€” confirm it runs before open_counts[start] += 1.

Step 5 โ€” Flush open windows and run the streamโ€‹

Goal: close any windows still open when the stream ends, then wire the whole processor into a runnable program.

Add the flush method to the class (same indentation as process):

def flush(self):
"""End of stream: close every window still open, then report."""
for start in sorted(self.open_counts):
self.closed_counts[start] = self.open_counts.pop(start)
return self.closed_counts

Then add the driver code at the end of the file, outside the class:

def run():
processor = TumblingWindowProcessor()
for event_time, key in generate_events():
processor.process(event_time, key)
windows = processor.flush()
return windows, processor.late_events


if __name__ == "__main__":
windows, late = run()
for start in sorted(windows):
end = start + WINDOW_SIZE
print(f"window [{start}, {end}): {windows[start]} events")
print(f"late events dropped: {len(late)}")

Checkpoint: run the whole module.

python3 stream.py

You should see exactly four lines:

window [0, 10): 4 events
window [10, 20): 2 events
window [20, 30): 3 events
late events dropped: 1

Window [20, 30) now shows its three events because flush closed it after the stream ended. If it is missing, run is not calling processor.flush() before returning.

Validationโ€‹

Verify the whole build against the objective: correct per-window counts and the late event handled as specified. Run this validation one-liner, which reruns the stream and checks both the window counts and the exact late record:

python3 -c "import stream; w, late = stream.run(); ok = (w == {0: 4, 10: 2, 20: 3}) and late == [(7, 'sensor-b')]; print('PASSED โ€” windows correct and late event handled' if ok else f'FAILED โ€” got {w} late={late}')"

Checkpoint: the command prints PASSED โ€” windows correct and late event handled. If it prints FAILED, compare the reported counts against {0: 4, 10: 2, 20: 3}: a wrong window [0, 10) count usually means the out-of-order event 3 was dropped or the late event 7 was counted; a missing window [20, 30) means flush did not run.

Teardownโ€‹

Nothing is billable โ€” this lab runs entirely on your machine. Keep the de205-stream-lab folder if you want to reuse stream.py as a reference for the Structured Streaming module, which runs this same watermark logic on real tooling; otherwise delete it with rm -rf de205-stream-lab (macOS/Linux) or Remove-Item -Recurse de205-stream-lab (Windows).

Troubleshootingโ€‹

SymptomLikely causeFix
ModuleNotFoundError: No module named 'stream'Running from the wrong foldercd into de205-stream-lab, where stream.py lives
Step 1 checkpoint prints a number other than 10A tuple in script is malformedCheck each row has both values and a trailing comma
window_start returns 0 for every inputWINDOW_SIZE not set to 10Confirm the WINDOW_SIZE = 10 constant from Step 1
late list is empty after Step 4Lateness guard runs after countingThe if ... end <= self.watermark check must come before open_counts[start] += 1
Window [0, 10) shows 3 instead of 4Out-of-order event 3 treated as lateWith ALLOWED_LATENESS = 5, event 3 is still valid; check the guard uses end <= watermark, not event_time
Window [20, 30) missing from the outputflush not called at end of streamrun must call processor.flush() before returning
Validation prints FAILEDAny of the aboveRe-run the Step 4 and Step 5 checkpoints to isolate which count is wrong