Skip to content

fix(db): prevent duplicate event inserts under lock expiry and partial-chunk failure - #2

Closed
raonitimo wants to merge 1 commit into
mainfrom
raoni/ch-f7-f8-ingest-idempotency
Closed

fix(db): prevent duplicate event inserts under lock expiry and partial-chunk failure#2
raonitimo wants to merge 1 commit into
mainfrom
raoni/ch-f7-f8-ingest-idempotency

Conversation

@raonitimo

Copy link
Copy Markdown
Owner

Problem

Two ways the event-ingestion path can insert duplicate rows into ClickHouse. events is a plain (Shared)MergeTree with no dedup key, and pageviews are the billing metric, so duplicates inflate billing.

F7 — flush lock can expire mid-insert

base-buffer.ts tryFlush() acquires the Redis flush lock with lockTimeout = 60 (seconds). But an insert can run far longer: client.ts sets request_timeout = 300_000 and the insert proxy sets max_execution_time: 300. A slow insert outlives the 60s lock, so a second worker replica can acquire the lock and start a concurrent flush of the same queued events → duplicate inserts.

F8 — post-loop ltrim replays committed chunks

event-buffer.ts processBuffer() inserts events in chunks in a loop, but the ltrim that removes them from the Redis queue runs only after the whole loop. If chunk 3 of 4 throws, the catch logs and returns without trimming → the entire batch is retried next cycle → chunks 1 & 2 are re-inserted → duplicates.

Fixes

  • F7: raise lockTimeout to 360s so the lock cannot expire while an insert (max 300s) is in flight. Added a deterministic per-chunk insert_deduplication_token (sha256 of the chunk payload) as defense-in-depth so ClickHouse rejects a duplicate insert even if the lock assumption is ever violated.
  • F8: trim each chunk from the front of the queue (and decrement the counter) only after it is safely inserted. On failure we stop — already-committed chunks stay trimmed, and only the untrimmed remainder is retried, so committed chunks are never replayed. Ordering is preserved (chunks processed in queue order, sorted by created_at); realtime publishEvent moved per-committed-chunk so a later failure no longer drops earlier chunks' notifications.

Tests

Extended packages/db/src/buffers/event-buffer.test.ts:

  • a mid-batch chunk failure does not re-insert already-committed chunks (retry inserts only the remainder);
  • the dedup token is a non-empty, deterministic value that matches across retries of identical content.

Ran pnpm vitest run packages/db/src/buffers/event-buffer.test.ts12 passed. No formatter run (per repo policy).

…l-chunk failure

F7 — lock can expire mid-insert: base-buffer tryFlush() held the flush lock
with a 60s TTL, but a ClickHouse insert can run up to 300s (client
request_timeout = 300s, insert max_execution_time = 300s). A slow insert
outlives the lock, letting a second worker replica acquire it and concurrently
flush the same Redis-queued events -> duplicate rows. Raise lockTimeout to 360s
so the lock cannot expire while an insert is in flight, and add a deterministic
per-chunk insert_deduplication_token as defense-in-depth so ClickHouse rejects
a duplicate insert even if the lock assumption is ever violated.

F8 — post-loop ltrim replays committed chunks: processBuffer() inserted events
in chunks but only ltrimmed the queue after the whole loop. If a later chunk
threw, the catch returned without trimming, so the entire batch (including
already-inserted chunks) was retried next cycle -> duplicates. Trim each chunk
from the front of the queue only after it is safely inserted; on failure we
stop, leaving just the untrimmed remainder to be retried. Per-chunk realtime
publish keeps committed chunks' notifications intact.

Tests: extend event-buffer.test.ts to prove a mid-batch chunk failure does not
re-insert committed chunks, and that the dedup token is deterministic across
retries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@raonitimo

Copy link
Copy Markdown
Owner Author

Superseded by the upstream contribution Openpanel-dev#417 (prod runs the stock upstream image, so the fix must land upstream, not on this fork). Closing here.

@raonitimo raonitimo closed this Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant