Key-bounded versus time-bounded state
Simulate a modelNeeds Python
Problem
Streaming state comes in two kinds with different growth laws. Key-bounded state (a counter per user) grows with the number of distinct keys and stops growing once every key has been seen; doubling the event rate barely changes it. Time-bounded state (a buffer that keeps records for an interval, as an interval join does) holds rate × retention records, so it doubles when the rate doubles, and grows without limit if nothing evicts it.
Simulate two operators over the same synthetic stream: a per-user counter (key-bounded) and an interval-join buffer (time-bounded, keep each record for 600 simulated seconds). Measure the memory at two event rates, 100 and 200 events per second. Then remove the eviction line.
The question. What happens to each kind of state when the rate doubles, and when eviction is removed?
Predict
The number of keys (and the counter memory) stays about the same when the rate doubles, while the buffer length (and its memory) doubles. Without the eviction line, the buffer grows without limit.
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
key-bounded-versus-time-bounded-state.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Key-bounded against time-bounded state. Two operators over the same stream: a per-user counter (state bounded by the number of keys) and an interval-join buffer that keeps each record for 600 simulated seconds (state bounded by rate × retention). Memory and entries at two event rates, then the buffer with its eviction removed. """ import argparse, json, random, tracemalloc from collections import deque p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rates", default="100,200", help="Events per simulated second.") p.add_argument("--seconds", type=int, default=1200, help="Simulated seconds.") p.add_argument("--users", type=int, default=100_000, help="Distinct users.") p.add_argument("--interval", type=int, default=600, help="Join retention (s).") 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(rate, evict=True): random.seed(args.seed) tracemalloc.start(); counts, buf = {}, deque() for t in range(args.seconds): for _ in range(rate): u = random.randrange(args.users); counts[u] = counts.get(u, 0) + 1; buf.append((t, u)) if evict: while buf and buf[0][0] <= t - args.interval: buf.popleft() # eviction = the retention rule cur, _ = tracemalloc.get_traced_memory(); tracemalloc.stop() return {"rate": rate, "eviction": "yes" if evict else "no", "keys": len(counts), "buffer": len(buf), "memory_mb": cur / 1e6} rates = [int(x) for x in args.rates.split(",")] rows = [run(r) for r in rates] + [run(rates[-1], evict=False)] for r in rows: print(f"rate {r['rate']:>4}/s, eviction {r['eviction']:<3}: keys {r['keys']:>7,} buffered records {r['buffer']:>8,} memory {r['memory_mb']:6.1f} MB") 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 key-bounded-versus-time-bounded-state.py uv run key-bounded-versus-time-bounded-state.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rates100, 200 | Events per simulated second. |
| --seconds1200 | Simulated seconds. |
| --users100,000 | Distinct users. |
| --interval600 | Join retention (s). |
| --seed0 | Random seed. |
Our run
1,200 simulated seconds
| Rate (/s) | Eviction | Keys | Buffered records | Memory (MB) |
|---|---|---|---|---|
| 100 | yes | 69.8K | 60K | 9.44 |
| 200 | yes | 91K | 120K | 18.2 |
| 200 | no | 91K | 240K | 27.3 |
rate 100/s, eviction yes: keys 69,788 buffered records 60,000 memory 9.4 MB
rate 200/s, eviction yes: keys 90,952 buffered records 120,000 memory 18.2 MB
rate 200/s, eviction no : keys 90,952 buffered records 240,000 memory 27.3 MBWhat we found
Doubling the rate doubled the buffer exactly, from 60K to 120K records (600 s × rate), while the keys grew less, from 69.8K to 91K: at the lower rate not every user had appeared yet in 20 minutes. Without eviction the buffer held all 240K records seen and would keep growing for as long as the job ran.
Read it
Size state by its law: key-bounded state by cardinality, time-bounded state by peak rate × retention. A rate spike (a sale, a replay after an outage) does nothing to the first and multiplies the second, which is why the time-bounded operators are the ones that run out of memory on the busiest day, and why every one of them needs an eviction rule tied to event time.