How do you bound a Python dedupe set that tracks seen keys in a long-running service?
answer
- The batch idiom assumes a finite input
- A process that never exits changes the arithmetic
- Membership is cheap, knowing the oldest is not
- Pair the set with an ordered companion
- Evict from both, or only one shrinks
basics
~20 sGive it an eviction policy: pair the set with a collections.deque of the same keys and a maxlen, discarding the key about to fall off before each append. A bounded window means far-apart duplicates get through.
solid answer
~50 sA `seen` set inside a request loop is fine; a `seen` set that lives for the life of the process is an unbounded memory leak with extra steps. Bound it deliberately. The simplest form pairs the set with a `collections.deque(maxlen=N)` holding the same keys in arrival order: before appending to a full deque, `discard` the key at index 0 from the set, because the deque drops it silently and the set would otherwise keep it forever. A `dict` used as an insertion-ordered queue works too, evicting with `popitem` semantics on the oldest key. For time-based duplicates, bucket keys by minute and drop buckets older than the window. Every bounded scheme is a correctness tradeoff: a duplicate whose twin has already been evicted is processed twice, so size the window from the real arrival gap and store compact keys rather than payloads.
code
python · 18 linesfrom collections import deque
class BoundedSeen:
def __init__(self, maxlen):
self._order = deque(maxlen=maxlen)
self._seen = set()
def add_if_new(self, key):
if key in self._seen:
return False
if len(self._order) == self._order.maxlen:
self._seen.discard(self._order[0])
self._order.append(key)
self._seen.add(key)
return True
seen = BoundedSeen(2)
print([seen.add_if_new(k) for k in ("a", "b", "a", "c", "a")])go deeper
Recall that a set used to remember what has been processed only stays small if something removes entries. In a program that runs for weeks, that set is state with no upper bound.
Explain the mechanics of a bounded structure: a set for O(1) membership plus an ordered companion for eviction, and why both must be updated on every eviction or one of them grows forever.
Show the sizing reasoning from real numbers, peak rate times window, and state the correctness tradeoff plainly: a duplicate outside the window is processed twice. Instrument size, evictions and hit rate so the bound can be validated in production.
Decide where deduplication belongs at all. Per-process state gives a per-process guarantee, which may be too weak once there are replicas, restarts or replays; the alternative is idempotent processing or a shared store with expiry, and that is an architecture choice with its own cost.
## The failure mode The deduplication idioms that work in a script assume a finite input. A long-running service does not have one. A fraud-scoring service that keeps a module-level `seen = set()` so it never scores the same request twice has written a cache with no eviction policy, and the only thing limiting it is how long the process stays up. At a 1,200-request-per-minute peak that is 72,000 keys an hour and about 1.7 million a day; with 36-character request ids the set alone runs into hundreds of megabytes before anything else has grown, and resident memory climbs in a slow, boring line until the process is restarted or killed. Nothing in the code looks wrong, which is precisely why it survives review. ## Bounding by count The cheapest bound is a fixed number of remembered keys with first-in-first-out eviction. Membership needs O(1), and a `set` gives that; eviction needs to know which key is oldest, and a `collections.deque` with `maxlen` gives that. The two must be kept in step: ```python from collections import deque class BoundedSeen: def __init__(self, maxlen): self._order = deque(maxlen=maxlen) self._seen = set() def add_if_new(self, key): if key in self._seen: return False if len(self._order) == self._order.maxlen: self._seen.discard(self._order[0]) self._order.append(key) self._seen.add(key) return True ``` The `discard` before the append is the whole trick, and it is exactly where this gets written wrong. Appending to a full deque silently drops the leftmost element; if you do not remove that element from the set first, the set keeps growing while the deque stays flat, and you are back to an unbounded structure that merely looks bounded. Off-by-one at that boundary is the other classic: checking `len(self._order) > self._order.maxlen` never fires, because a deque never exceeds its maxlen. A plain `dict` can do the same job on its own, using keys as the queue and relying on guaranteed insertion order to find the oldest key. Promoting a key on a hit turns the structure into an LRU rather than a FIFO, which is better when the duplicate arrival pattern is bursty rather than uniform. ## Bounding by time Count-based bounds are awkward when the real requirement is temporal: "the same request id must not be scored twice within ten minutes". Expressing that as a count means guessing the rate, and the guess breaks during a traffic spike, which is exactly when duplicates cluster. Bucketing is more honest: keep a small mapping from a coarse timestamp, a minute, to the set of keys first seen in that minute; look a key up across the buckets in the window; drop buckets that fall out of it. Eviction is then dropping whole sets, which is one operation rather than many, and the memory ceiling is the arrival rate times the window rather than a number you invented. ## The tradeoff you must state out loud Every bounded deduplicator is approximate. A duplicate whose original has already been evicted is not recognized, so it is processed a second time. That is not a bug to be fixed, it is the price of the bound, and the design decision is where to set it: the window must comfortably exceed the realistic gap between a message and its retry. Sizing it from the actual retry policy and the actual peak rate is the senior part of the answer; sizing it from a round number is how a deduplicator quietly stops deduplicating during the one incident that matters. The second tradeoff is what you store. Keys, never payloads: a 36-character id costs a fraction of the record it identifies. Where even the keys are too many to hold, a fixed-size probabilistic membership structure trades a small false-positive rate for constant memory, which changes the failure mode from "processes a duplicate" to "occasionally skips a unique item", and that is a decision for the domain, not for the code. ## Operating it A bounded structure should be observable. Export the current size, the eviction count and the hit rate, because a hit rate that collapses is the signal that the window has become too small for the current traffic, and an eviction count of zero at peak means the bound was never reached and the sizing was pure guesswork. Where duplicates must be suppressed across processes or across restarts, in-process state is the wrong home entirely, and the answer is an external store with its own expiry rather than a bigger set. ## Interview framing The question is a differentiator, not a gate. Candidates who have only deduplicated batches reach for the module-level set and see nothing wrong. Candidates who have run a service ask, unprompted, how long the process lives and what bounds the state.
- Why must you discard the oldest key from the set before appending to a full collections.deque with a maxlen?Because the deque drops its leftmost element silently on that append. If the set is not updated first, it keeps every key ever added while the deque stays at its maxlen, so the structure looks bounded and grows without limit. Reading deque[0] before the append is the only chance to learn which key is about to disappear.
- When would you choose a time window over a fixed key count?When the requirement is temporal, such as suppressing a retry within ten minutes. A count-based bound encodes a guess about the arrival rate, and that guess fails during a spike, which is when retries cluster. A window sized from the retry policy holds regardless of rate, at the cost of memory that grows with traffic rather than staying flat.
- What changes when duplicates must be suppressed across process restarts or across replicas?In-process state stops being the right home. A set in one worker cannot see what another worker or a previous incarnation processed, so the deduplication guarantee is only per-process and per-lifetime. That case needs a shared store with its own expiry, and the local structure degrades to a cheap first-line filter in front of it.
saying these in an interview costs you the question
- Keeps a module-level seen set for the process lifetime
- Assumes a deque with maxlen also trims the paired set
- Sizes the window from a round number, not the retry policy
- Stores whole payloads instead of keys
- Claims a bounded window still catches every duplicate
- Relies on in-process state across replicas or restarts