Approach: use an in-memory hash table for O(1) presence checks and a time-ordered structure to expire entries when TTL passes. Provide thread-safety and periodic cleanup to remove stale IDs. For resilience and scale, move the store to a shared external system (Redis) using atomic operations (SETNX/EXPIRE or Lua), add persistence, and partition keys across nodes.Python implementation (thread-safe, in-memory with TTL and O(1) checks):python
import time
import threading
from collections import deque
class InMemoryDedupe:
def __init__(self, ttl_seconds=300, cleanup_interval=1.0):
self.ttl = ttl_seconds
self.store = {} # id -> expiry_time
self.expiry_queue = deque() # (expiry_time, id) ordered by insertion
self.lock = threading.Lock()
self._stop = False
self._thread = threading.Thread(target=self._cleanup_loop, daemon=True)
self._thread.start()
self.cleanup_interval = cleanup_interval
def is_duplicate(self, msg_id):
"""Return True if msg_id seen (and not expired). Otherwise record it and return False."""
now = time.time()
with self.lock:
expiry = self.store.get(msg_id)
if expiry and expiry > now:
return True
# record new id
expiry_time = now + self.ttl
self.store[msg_id] = expiry_time
self.expiry_queue.append((expiry_time, msg_id))
return False
def _cleanup_loop(self):
while not self._stop:
now = time.time()
with self.lock:
while self.expiry_queue and self.expiry_queue[0][0] <= now:
_, mid = self.expiry_queue.popleft()
# only remove if expiry matches (avoid race where same id reinserted)
if self.store.get(mid, 0) <= now:
self.store.pop(mid, None)
time.sleep(self.cleanup_interval)
def stop(self):
self._stop = True
self._thread.join()
Key points:- is_duplicate is O(1) average (hash lookup + append).- expiry_queue ensures cleanup is efficient; cleanup pops only expired entries.- Thread-safe via lock; cleanup_interval trades CPU vs latency of freeing memory.Complexity:- Time: is_duplicate O(1) average. Cleanup amortized O(1) per expired entry.- Space: O(n) where n = number of distinct IDs within TTL window.Edge cases:- Clock skew across nodes — use monotonic timers or synchronize clocks.- Very high arrival rates: memory growth until cleanup runs; tune cleanup_interval and TTL.- Reinserted same ID before old expiry — code checks expiry match to avoid premature deletion.Resilience & scaling (production recommendations):- Use Redis as centralized dedupe store: - Use SET key value NX EX ttl to atomically set if absent with TTL. If SET returns OK -> not duplicate; else duplicate. - For multi-step processing where dedupe must persist beyond operation, use a separate "processing" key and multi-phase semantics (claim/process/complete) with EXPIRE and Lua scripts to avoid races.- For high throughput and horizontal scale: - Shard keys across Redis cluster nodes using consistent hashing (or let Redis Cluster). - Keep dedupe lifetime bounded (TTL) to limit storage. - For strict exactly-once across restarts: persist processed IDs to durable store (e.g., RocksDB, or Redis with AOF/RDB), or attach dedupe confirmation to message broker offsets (commit offsets after dedupe + processing).- For multi-region: prefer per-region local cache + central Redis for cross-region; use local cache as fast-path and Redis as source of truth (cache-aside), handling eventual consistency.- Monitoring: metrics for hits/misses, memory usage, expiration rate, latency.- Security: TTLs, key namespaces per tenant, encryption in transit and at rest.Trade-offs:- In-memory is lowest latency but not durable. Redis gives durability and cross-node consistency but adds network latency and single-system complexity (mitigate via clustering and local caches).