Skip to content
About
Main content

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 does) holds rate × records, so it doubles when the rate doubles, and grows without limit if nothing evicts it.

Simulate two 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

Doubling the event rate doubles the time-bounded buffer. What happens to the number of keys in state?

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.

    key-bounded-versus-time-bounded-state.py42 linesDownload
    View 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)
    
  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 key-bounded-versus-time-bounded-state.py
    uv run key-bounded-versus-time-bounded-state.py --help     # every knob
Knobs
Knob · defaultWhat 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

1,200 simulated seconds
Rate (/s)EvictionKeysBuffered recordsMemory (MB)
100yes69.8K60K9.44
200yes91K120K18.2
200no91K240K27.3
Reference runApple M2 · 8 cores · 8 GB
Output · uv run key-bounded-versus-time-bounded-state.py
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 MB
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

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