State TTL and expiry
How does state expire, and why is a TTL a silent correctness change?
Unbounded state is always a missing or unenforceable retention rule, so every backend grows expiry machinery. TTL (time to live) means "delete this entry when it has not been touched, or has existed, for a set time." The mechanisms trade thoroughness against cost:
| Mechanism | How it works | Cost | Weakness |
|---|---|---|---|
| Lazy cleanup on access | check expiry when an entry is read | free | never touches keys that are never read again, which are exactly the leaking ones |
| Background incremental scan | scan a slice of state in each cycle | bounded per cycle | a continuous tax on CPU and I/O |
| Cleanup during compaction | drop expired entries while compaction rewrites files | cheapest thorough option, as it rides on work already scheduled | only as often as compaction runs |
| Filter during snapshot | skip expired entries when writing a checkpoint | small | state in memory is not reduced |
Cleanup during compaction is the same tombstone-collection trick that storage engines use, relocated into the runtime.
The semantics matter more than the mechanism
- TTL on the last write expires keys that are updated rarely but still read often.
- TTL on the last access keeps "zombie" keys alive forever, because something touches them periodically.
- Event-time TTL is the correct one and depends on watermark progress. So an idle or lagging source stops cleanup exactly when you need it.
TTL is a silent correctness change
This is the important property. A join whose state expires below the real time skew between the two streams produces missing rows, not errors. Nothing in the system distinguishes "no match existed" from "the match was evicted." The job assumes that real-world event skew stays inside the configured retention. It cannot verify that assumption. When it breaks, the cost lands on a downstream consumer who sees a slightly smaller number and has no signal at all.