Deduplicator¶
The Deduplicator removes duplicate events using a deterministic SHA-256 key and an atomic Redis Lua script. Runs after normalization, before feature engineering.
Deduplication Key¶
Composition: SHA-256(message | type | template_id | host | source) → first 32 hex chars
def compute_dedup_key(event: CanonicalEvent, template_id: str) -> str:
parts = [
event.message or "",
event.type.value,
template_id or "",
event.host or "",
event.source or "",
]
key = "|".join(parts)
return hashlib.sha256(key.encode()).hexdigest()[:32]
Why these fields? - Same message + same template + same host + same source = duplicate - Different host/source = different event (even if message identical)
Redis Lua Script (Atomic)¶
-- dedup.lua
-- KEYS[1] = dedup:{dedup_key}
-- ARGV[1] = window_seconds (300)
local key = KEYS[1]
local window = tonumber(ARGV[1])
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, window)
return {0, 1} -- not duplicate, count=1
else
local ttl = redis.call('TTL', key)
if ttl < 0 then
redis.call('EXPIRE', key, window)
end
return {1, count} -- duplicate, current count
end
Execution:
# Python wrapper
async def check_and_increment(dedup_key: str, window: int = 300) -> tuple[bool, int]:
result = await redis.eval(
LUA_SCRIPT,
1,
f"dedup:{dedup_key}",
str(window)
)
is_duplicate = bool(result[0])
count = int(result[1])
return is_duplicate, count
Properties:
- Atomic: INCR + EXPIRE in single Redis call
- Race-free: Multiple pipeline replicas safe
- Low latency: < 1 ms per call
- Sliding window: TTL resets on each hit
Pipeline Integration¶
flowchart LR
NORM[Normalized Event] --> KEY[Compute dedup_key]
KEY --> LUA[Redis EVAL<br/>INCR + EXPIRE]
LUA -->|is_duplicate=0| UNIQUE[Unique Event]
LUA -->|is_duplicate=1| DUP[Duplicate → Skip]
UNIQUE --> FEAT[Feature Engineering]
DUP -.-> STATS[duplicate_total++]
Window Configuration¶
| Parameter | Default | Description |
|---|---|---|
DEDUP_WINDOW_SECONDS |
300 | Sliding window (5 minutes) |
| Redis Key Format | dedup:{sha256} |
Auto-expires after window |
Tuning: - Shorter window (60s) → More events stored, less dedup - Longer window (3600s) → Better dedup, more Redis memory
PostgreSQL Backstop¶
Even if Redis dedup misses (race, restart, clock skew), PostgreSQL enforces uniqueness:
-- Partial unique index (migration 0015)
CREATE UNIQUE INDEX ux_events_dedup_key
ON events (dedup_key)
WHERE dedup_key IS NOT NULL;
Storage layer (storage.py) also does intra-batch merge:
# Merge events with same dedup_key within batch
merged = {}
for event in batch:
key = event.dedup_key
if key in merged:
merged[key].occurrence_count += event.occurrence_count
else:
merged[key] = event
Performance¶
| Metric | Value |
|---|---|
| Latency (Redis) | < 1 ms |
| Throughput | ~2,100 checks/sec |
| Memory per key | ~72 bytes (key + count + TTL) |
| Keys at 2K eps × 300s | ~600K keys (~43 MB) |
Monitoring¶
# Redis dedup keys
docker compose exec redis redis-cli --scan --pattern "dedup:*" | wc -l
# Key TTL sample
docker compose exec redis redis-cli --scan --pattern "dedup:*" | head -5 | xargs -I{} redis-cli TTL {}
# Pipeline stats
docker compose logs consumer-pipeline | grep duplicate_total
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
| Low dedup rate | Window too short | Increase DEDUP_WINDOW_SECONDS |
| High memory | Window too long / high cardinality | Decrease window, check for unmasked PII in messages |
| Duplicates in DB | Redis restarted / clock skew | PG unique index catches; check clock sync |
| Redis OOM | Too many keys | Reduce window, enable maxmemory-policy allkeys-lru |