Approach overview:- Identify duplicates by ordering events per user and marking any event within 1 second of a previous kept event as duplicate.- Delete duplicates in small batches using a CTE (or temp table) so each DELETE touches limited rows (reduces lock contention).- If feasible, do dedupe offline (CTAS + swap) or enforce uniqueness on insert (unique index + upsert) to avoid repeated cleanup.SQL pattern (Postgres-style) — mark duplicates using window functions, then delete in batches:sql
-- 1) First create a helper column rn_group that increments when gap > 1 second
WITH flagged AS (
SELECT
event_id,
user_id,
event_time,
SUM(is_new_group) OVER (PARTITION BY user_id ORDER BY event_time) AS grp
FROM (
SELECT
event_id,
user_id,
event_time,
CASE
WHEN LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) IS NULL THEN 1
WHEN EXTRACT(EPOCH FROM (event_time - LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time))) > 1 THEN 1
ELSE 0
END AS is_new_group
FROM staging_events
) s
),
-- 2) pick the "first" row in each group to keep
to_delete AS (
SELECT f.event_id
FROM (
SELECT event_id,
ROW_NUMBER() OVER (PARTITION BY user_id, grp ORDER BY event_time, event_id) AS rn
FROM flagged f
) f2
WHERE rn > 1
-- optional: limit batch size to avoid large deletes
LIMIT 10000
)
DELETE FROM staging_events se
USING to_delete td
WHERE se.event_id = td.event_id;
Key points:- The inner window marks contiguous events per user where gaps ≤ 1 second as one group; we keep the earliest event per group.- Delete in batches (LIMIT 10k) inside a loop/application until no rows deleted — prevents long table locks and large MVCC churn.Alternative (safe offline swap):- CTAS deduped copy: create a new table with DISTINCT ON (user_id, grp) or the window logic, build indexes, then atomically swap tables (rename) inside a short transaction. This isolates readers/writers and avoids long deletes.Concurrency considerations:- If staging accepts concurrent writes, running deletes concurrently can lead to race conditions (new duplicates inserted after you computed to_delete). Mitigations: - Use advisory locks (pg_try_advisory_lock) around dedupe runs to serialize cleanup jobs. - Run dedupe in short frequent batches so window of inconsistency is small. - Prefer dedupe-before-merge: ingest into staging_temp, dedupe, then insert into main staging with INSERT ... ON CONFLICT DO NOTHING using a unique index on (user_id, floor(extract(epoch from event_time))) or a computed column approximating the 1s bucket — but note bucketing trades correctness at boundaries. - Use snapshot isolation (SERIALIZABLE) if you need strict correctness, but it may cause retries. - Best: enforce uniqueness at ingest (de-dup in producer or use deduped intermediate table) or perform CTAS + atomic swap during low-traffic window.Edge cases:- Events arriving out-of-order (late data) require re-run of dedupe for affected user/time windows.- Timestamps with different precisions/timezones — normalize to UTC and consistent type first.