Deduplicate a Stream of Retried Events

Updated · By

Keep a hash map from event ID to the time its accepted copy arrived, plus a queue of IDs in arrival order. On each event, pop everything older than the window from the queue's front and delete it from the map; the event is a duplicate if its ID is still in the map. O(1) amortized per event.

mediumPattern: Sliding windowFrom Devana's Netflix question set

Problem

Playback clients retry uploads when they do not hear back, so the same event can arrive several times. Downstream counts must not double count, but the stream runs at a million events a second, so you cannot remember every ID forever.

Write a class EventDeduplicator(window_seconds) with is_duplicate(event_id, now). It returns True when the same ID was accepted within the last window_seconds, and otherwise accepts the event and returns False. State the guarantee the window gives.

class EventDeduplicator:
    def __init__(self, window_seconds: int = 900): ...
    def is_duplicate(self, event_id: str, now: float) -> bool: ...

This is one of Devana's own questions, written in the style of Netflix's interviews. It is not a question leaked from Netflix, and Devana is not affiliated with Netflix.

Constraints

  • now never decreases from one call to the next.
  • A copy arriving at most window_seconds after the accepted one is a duplicate; the boundary is inclusive.
  • A duplicate does not extend the window, which runs from the accepted copy.
  • After the window, the next copy is accepted again and starts a new window.
  • Memory is bounded by the events inside the window, and dropping a genuine event is worse than letting an occasional duplicate through.

Examples

Example 1

Input:  window_seconds = 900
        is_duplicate("e1", 0), is_duplicate("e1", 10)
Output: False, True

The retry 10 seconds later is inside the 15-minute window.

Example 2

Input:  window_seconds = 900
        is_duplicate("e1", 0), is_duplicate("e1", 7200)
Output: False, False

Two hours later the first copy has left the window, so the second is accepted. That is the guarantee to state: duplicates are caught within 15 minutes, not forever.

Hints

Open one at a time, and try the problem again after each.

Hint 1

Membership questions want a hash set or map. What goes wrong if that is all you keep?

Hint 2

Entries expire in time order, and events arrive in time order. Which structure gives you the oldest entry cheaply?

Hint 3

Pair the map with a queue of (time, id). Before answering, pop entries older than now - window_seconds from the front and delete them from the map.

Approach: from brute force to optimal

Brute force: remember everything, or scan

A set of every ID ever seen answers instantly but grows without bound; at a million events a second it fills memory within hours. Scanning a list of recent events instead bounds memory but costs time proportional to the window on every event.

Complexity: O(1) per event with unbounded memory, or O(w) per event for w events in the window

A map plus a queue: a time-based window

The map answers "seen recently?" in O(1). The queue holds accepted events oldest first, so expiry only ever touches its front. Every accepted event enters the queue once and leaves once, so the eviction work averages out to O(1) per event.

Complexity: O(1) amortized time per event; O(w) space for the w events accepted within the window

Fitting it in memory

Do the arithmetic: a million events a second for 900 seconds is 900 million IDs, and an 8 GB budget leaves about 9 bytes per entry, less than a typical hash map spends on overhead alone. So say how you would cut it: shard by event ID across machines, store fixed-size hashes instead of ID strings, or keep one set per minute and drop a whole set when it ages out.

A Bloom filter is the tempting answer, but its errors are false positives, and a false positive here drops a genuine event, the error the requirements rank as worse.

How to say it in the interview

Lead with the guarantee, because the question asks for it: "copies within 15 minutes of the first are dropped; later ones are let through." Then the structure, a map for membership and a queue for expiry, both O(1) amortized. Then do the memory arithmetic out loud, since it is what pushes the design toward sharding.

Solution

Python

from collections import deque

class EventDeduplicator:
    def __init__(self, window_seconds=900):
        self.window = window_seconds
        self.accepted = {}       # event id -> time its accepted copy arrived
        self.order = deque()     # (time, event id), oldest first

    def is_duplicate(self, event_id, now):
        # Forget accepted events that have left the window.
        while self.order and self.order[0][0] < now - self.window:
            _, old_id = self.order.popleft()
            del self.accepted[old_id]
        if event_id in self.accepted:
            return True
        self.accepted[event_id] = now
        self.order.append((now, event_id))
        return False

JavaScript

class EventDeduplicator {
  constructor(windowSeconds = 900) {
    this.window = windowSeconds;
    this.accepted = new Map(); // event id -> time its accepted copy arrived
    this.order = [];           // [time, event id], oldest first, from this.head
    this.head = 0;
  }

  isDuplicate(eventId, now) {
    // Forget accepted events that have left the window.
    while (this.head < this.order.length && this.order[this.head][0] < now - this.window) {
      this.accepted.delete(this.order[this.head][1]);
      this.head++;
    }
    // Drop the consumed prefix now and then, so the array stays bounded.
    if (this.head > 1024 && this.head * 2 > this.order.length) {
      this.order = this.order.slice(this.head);
      this.head = 0;
    }
    if (this.accepted.has(eventId)) return true;
    this.accepted.set(eventId, now);
    this.order.push([now, eventId]);
    return false;
  }
}

Both versions are tested against the same cases, including every example on this page.

  • The eviction test uses <, so an entry exactly window_seconds old is kept and the boundary is inclusive, as specified.
  • JavaScript has no built-in deque, and shift() on an array is O(n), so the code keeps a head index and compacts occasionally.
  • A duplicate never enters the queue, so it cannot extend the window.

Follow-up questions

Would you use a Bloom filter here? What is the risk given the cost asymmetry?

A Bloom filter never misses a real duplicate but sometimes reports a new event as seen, which drops a genuine event. At this volume even a 0.01% false positive rate loses about 8.6 million events a day, so if memory forces it, size the filter for a much lower rate and say what it costs.

How do you expire entries as the window slides?

From the front of the queue, in arrival order. A coarser alternative keeps one set per minute and drops whole sets as they age out, which is cheaper still.

How does this behave across many ingestion servers?

Retries can land on a different server, so route by a hash of the event ID: every copy of an event reaches the same deduplicator. Rebalancing then loses at most one window of history for the IDs that moved.

What if events can arrive with timestamps out of order?

Run the window on the server's arrival time, which never goes backwards, rather than on the client's timestamp, and keep the client's timestamp in the event itself.

Common mistakes

  • A set of every ID ever seen, which grows until the process runs out of memory.
  • Using list.pop(0) or Array.shift() for the queue, which makes every eviction O(n).
  • Refreshing the timestamp on every duplicate, so a client that keeps retrying holds the window open indefinitely.
  • Never stating the window. "Deduplicates the stream" without a time bound is a promise the design cannot keep.

Practice it in a mock interview

Reading a solution and explaining one under questioning are different skills. The button below starts this problem as a voice mock interview with Josh, Devana's AI interviewer, with an editor that runs your code.

Practice this problem in a mock interview

Practice this problem out loud

Josh, Devana's AI interviewer, runs this exact problem as a voice mock interview, asks follow-ups like the ones above, and scores you out of 100. The Free plan has 3 interviews a month, no card needed.

Practice this problem in a mock interview