Skip to content

fix(db): prevent duplicate event inserts on lock expiry and partial-batch retry#417

Open
raonitimo wants to merge 1 commit into
Openpanel-dev:mainfrom
raonitimo:fix/ingest-duplicate-inserts
Open

fix(db): prevent duplicate event inserts on lock expiry and partial-batch retry#417
raonitimo wants to merge 1 commit into
Openpanel-dev:mainfrom
raonitimo:fix/ingest-duplicate-inserts

Conversation

@raonitimo

@raonitimo raonitimo commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

The event buffer (packages/db/src/buffers) can insert the same events into ClickHouse twice under two independent conditions. Since the events table has no dedup key, each one produces duplicate rows.

1. Flush-lock expiry. BaseBuffer.tryFlush() holds the Redis flush lock with a 60s TTL (lockTimeout = 60), but a ClickHouse insert can run up to max_execution_time = 300s server-side (INSERT_DEFAULT_SETTINGS in clickhouse/client.ts), and a flush inserts several chunks in sequence. A slow flush can outlive the 60s lock, letting a second worker replica acquire the lock and concurrently re-flush the same still-queued events.

2. Partial-batch retry. EventBuffer.processBuffer() inserted every chunk but only trimmed the whole slice from the Redis queue with a single ltrim at the very end. If a later chunk's insert threw, the error propagated before the trim, so the entire batch — including chunks that had already been inserted — was left on the queue and retried on the next cycle, duplicating the committed chunks.

Fix

  • Raise the flush-lock TTL to 360s so it can't expire while an insert is in flight (comfortably above the 300s server-side ceiling).
  • Trim each chunk from the front of the queue right after its insert succeeds, and move the per-project realtime publish to happen per committed chunk. A mid-batch failure now leaves only the untrimmed remainder to retry; already-committed chunks are never replayed.
  • Send a deterministic per-chunk insert_deduplication_token (sha256 of the chunk's rows) as defense-in-depth, so an identical insert is rejected by ClickHouse even if the lock assumption is ever violated (e.g. a replica race on a replicated table).

Because a safe front-of-queue trim requires committing chunks in order, this buffer's chunk inserts are now sequential rather than run through parallelLimit. The other buffers (profile/replay/group) keep their parallel inserts. The raw JSONEachRow passthrough (no JSON.parse on the hot path) is preserved.

Tests

Extends event-buffer.test.ts:

  • A mid-batch chunk failure commits (and trims) the earlier chunk, retains the failed one, and on retry re-sends only the remainder — never the committed events.
  • The dedup token is deterministic across two independent flushes of identical content.

All 20 tests in the file pass locally (vitest run src/buffers/event-buffer.test.ts, Redis running, ClickHouse mocked).

Notes

  • Per the repo's NEVER CALL FORMAT guidance, I did not run the formatter; the diff is limited to the logical change.
  • No changeset added (repo has no .changeset setup).

Summary by CodeRabbit

  • Bug Fixes
    • Improved event delivery reliability during large or multi-part uploads.
    • Prevented duplicate event records when a partial upload fails and is retried.
    • Ensured successfully processed events are not resent after later processing failures.
    • Improved consistency of realtime updates so notifications are sent only after events are successfully stored.

…atch retry

The event buffer can insert the same events into ClickHouse twice under two
conditions:

1. Flush-lock expiry. tryFlush() holds a Redis flush lock with a 60s TTL, but
   a ClickHouse insert may run up to max_execution_time = 300s server-side. A
   slow insert can outlive the lock, letting a second worker replica acquire
   it and re-flush the same still-queued events. The `events` table has no
   dedup key, so this produces duplicate rows.

2. Partial-batch retry. processBuffer() inserted every chunk but only trimmed
   the whole slice from the Redis queue in a single ltrim at the very end. If
   a later chunk's insert threw, the error propagated before the trim ran, so
   the entire batch — including chunks already inserted — was retried on the
   next cycle, duplicating the already-committed chunks.

Fixes:
- Raise the flush-lock TTL to 360s so it cannot expire while an insert is in
  flight (comfortably above the 300s server-side insert ceiling).
- Trim each chunk from the front of the queue immediately after its insert
  succeeds, and publish that chunk's realtime notification then. A mid-batch
  failure now leaves only the untrimmed remainder to retry; committed chunks
  are never replayed.
- Send a deterministic per-chunk insert_deduplication_token (sha256 of the
  chunk's rows) as defense-in-depth, so an identical insert is rejected by
  ClickHouse even if the lock assumption is ever violated (e.g. a replica
  race on a replicated table).

Because a safe front-of-queue trim requires committing chunks in order, this
buffer's chunk inserts are now sequential rather than run through
parallelLimit; the other buffers keep their parallel inserts.

Adds unit tests for the partial-batch-retry path and the deterministic token.

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

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Event buffer flush reliability

Layer / File(s) Summary
Chunked commit and retry flow
packages/db/src/buffers/event-buffer.ts, packages/db/src/buffers/event-buffer.test.ts
EventBuffer inserts and trims chunks sequentially, publishes notifications after each committed chunk, accumulates processed rows, and supplies deterministic ClickHouse deduplication tokens. Tests cover failed-chunk retries and token stability.
Flush lock duration
packages/db/src/buffers/base-buffer.ts
The default Redis flush lock timeout increases from 60 to 360 seconds, with documentation for multi-chunk flush timing.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EventBuffer
  participant Redis
  participant ClickHouse
  participant RealtimeNotifications
  EventBuffer->>Redis: Read queued JSONEachRow lines
  EventBuffer->>ClickHouse: Insert one chunk with deduplication token
  ClickHouse-->>EventBuffer: Confirm chunk insert
  EventBuffer->>Redis: Trim committed chunk
  EventBuffer->>RealtimeNotifications: Publish per-project counts
Loading

Suggested reviewers: lindesvard

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: preventing duplicate event inserts from lock expiry and partial-batch retries.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/src/buffers/event-buffer.ts`:
- Around line 256-268: Handle the promise returned by publishEvent in the batch
publishing loop so Redis publish failures are caught and logged rather than
becoming unhandled rejections. Update the loop around countByProject and
publishEvent, preserving the existing per-project event payload and allowing the
buffer flush to continue for other projects.
- Around line 177-194: Update the event flush path using deduplicationToken so
ClickHouse insertion is synchronous and completes before the Redis ltrim commit
point. Override the settings from getClickhouseSettings() for this insert to
disable async_insert and wait for the insert to finish; do not rely on
async-insert deduplication for correctness.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6e6a31a9-4c37-42f9-9f08-32db61b26412

📥 Commits

Reviewing files that changed from the base of the PR and between f72310c and 18774de.

📒 Files selected for processing (3)
  • packages/db/src/buffers/base-buffer.ts
  • packages/db/src/buffers/event-buffer.test.ts
  • packages/db/src/buffers/event-buffer.ts

Comment on lines +177 to +194
/**
* Deterministic idempotency token for a chunk of raw JSONEachRow lines.
* The same chunk content always hashes to the same token, so if the exact
* chunk is inserted twice — e.g. a lock-expiry double-flush, or a retry
* after a partial batch failure — ClickHouse can reject the duplicate
* block via `insert_deduplication_token`. This is defense-in-depth on top
* of the per-chunk commit below; on replicated tables it also covers a
* race where two replicas flush the same queue slice.
*/
private deduplicationToken(lines: string[]): string {
const hash = createHash('sha256');
for (const line of lines) {
hash.update(line);
hash.update('\n');
}
return hash.digest('hex');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether BUFFER_ASYNC_INSERTS is used in deployment/env config, and whether the
# `events` table DDL sets non_replicated_deduplication_window (if not Replicated).
rg -n 'BUFFER_ASYNC_INSERTS' --type=ts -C2
rg -n 'CREATE TABLE.*events|non_replicated_deduplication_window|ReplicatedMergeTree' -g '*.sql' -g '*.ts'

Repository: Openpanel-dev/openpanel

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- files of interest ---'
git ls-files | rg '^(packages/db/src/(buffers|clickhouse)|.*(events|schema|migration|ddl).*)'

echo
echo '--- locate clickhouse/event references ---'
rg -n --hidden --no-ignore-vcs \
  'insert_deduplication_token|async_insert|BUFFER_ASYNC_INSERTS|non_replicated_deduplication_window|ReplicatedMergeTree|MergeTree|events\b' \
  packages db . 2>/dev/null | head -n 300

echo
echo '--- event-buffer file outline ---'
ast-grep outline packages/db/src/buffers/event-buffer.ts --view expanded || true

echo
echo '--- base-buffer file outline ---'
ast-grep outline packages/db/src/buffers/base-buffer.ts --view expanded || true

Repository: Openpanel-dev/openpanel

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- base-buffer outline ---'
ast-grep outline packages/db/src/buffers/base-buffer.ts --view expanded || true

echo
echo '--- event-buffer outline ---'
ast-grep outline packages/db/src/buffers/event-buffer.ts --view expanded || true

echo
echo '--- init clickhouse migration outline ---'
ast-grep outline packages/db/code-migrations/3-init-ch.ts --view expanded || true

echo
echo '--- event-buffer relevant lines ---'
cat -n packages/db/src/buffers/event-buffer.ts | sed -n '150,260p'

echo
echo '--- base-buffer relevant lines ---'
cat -n packages/db/src/buffers/base-buffer.ts | sed -n '1,220p'

echo
echo '--- init clickhouse migration relevant lines ---'
cat -n packages/db/code-migrations/3-init-ch.ts | sed -n '1,260p'

Repository: Openpanel-dev/openpanel

Length of output: 28575


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- createTable helper outline ---'
ast-grep outline packages/db/src/clickhouse/migration.ts --view expanded || true

echo
echo '--- createTable helper relevant lines ---'
cat -n packages/db/src/clickhouse/migration.ts | sed -n '1,260p'

echo
echo '--- search for events_replicated / replicated engine usage ---'
rg -n 'events_replicated|ReplicatedMergeTree|ReplacingMergeTree|MergeTree\(' packages/db/src packages/db/code-migrations packages/db/prisma/migrations -g '!**/node_modules/**'

Repository: Openpanel-dev/openpanel

Length of output: 13346


🌐 Web query:

ClickHouse insert_deduplication_token async_insert ignored deduplication replicated MergeTree non_replicated_deduplication_window

💡 Result:

In ClickHouse, the interaction between insert deduplication settings and asynchronous inserts works as follows: 1. insert_deduplication_token and async_insert Previously, insert_deduplication_token was not supported with async_insert, but this was addressed in version 23.7, where support for insert_deduplication_token was added for asynchronous inserts [1][2]. When using async_insert, the deduplication token acts as a user-defined identifier to prevent duplicate block insertion, taking precedence over the automatic data digest (checksum) method [3]. 2. async_insert and Deduplication By default, automatic block-level deduplication is disabled for asynchronous inserts unless explicitly enabled [4]. The setting async_insert_deduplicate=1 is required to enable deduplication for asynchronous inserts in ReplicatedMergeTree tables [5][6]. It is recommended to use wait_for_async_insert=1 when deduplication is required to ensure that retries are handled reliably and the client is aware of the final status of the insert [4]. 3. non_replicated_deduplication_window This setting enables block-level deduplication for non-replicated MergeTree tables [7][8]. By default, it is set to 0 (disabled) for non-replicated tables for backward compatibility [8]. When set to a positive integer, ClickHouse stores the hash sums of the most recently inserted blocks locally in a file to detect and ignore duplicates [7][8]. This mechanism functions similarly to the deduplication process in replicated tables, though hash sums are managed locally rather than via ClickHouse Keeper [7]. In summary, while async_insert once ignored deduplication tokens, modern versions (23.7+) support them [1][2]. For non-replicated tables, deduplication must be explicitly enabled using the non_replicated_deduplication_window setting [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- cluster/self-hosting helpers ---'
rg -n 'getIsCluster|getIsSelfHosting|CLICKHOUSE_CLUSTER|SELF_HOSTING|BUFFER_ASYNC_INSERTS' packages/db/src packages/db/code-migrations -g '!**/node_modules/**'

echo
echo '--- helper source ---'
cat -n packages/db/code-migrations/helpers.ts | sed -n '1,220p'

echo
echo '--- event-buffer insertion context ---'
cat -n packages/db/src/buffers/event-buffer.ts | sed -n '228,250p'

Repository: Openpanel-dev/openpanel

Length of output: 5860


🌐 Web query:

ClickHouse docs insert_deduplication_token async_insert support 23.7 async_insert_deduplicate wait_for_async_insert deduplication token

💡 Result:

Support for using insert_deduplication_token with asynchronous inserts was introduced in ClickHouse version 23.7 [1]. Key details regarding this functionality: 1. Enabling Deduplication for Async Inserts: By default, deduplication is disabled for asynchronous inserts. To enable it, you must explicitly set async_insert_deduplicate = 1 [2][3]. 2. Functionality: Once async_insert_deduplicate is enabled, ClickHouse allows the use of insert_deduplication_token to control deduplication on a per-insert basis, similar to how it functions with standard synchronous inserts [1][4][5]. 3. Interaction with Materialized Views: Historically, deduplication with asynchronous inserts has faced limitations when dependent materialized views are involved [6][7]. While initial support for insert_deduplication_token was added in 23.7, end-to-end deduplication—specifically ensuring that materialized views are handled consistently during asynchronous insert retries—was further refined in later releases (e.g., ClickHouse 26.1) [6][5]. 4. Best Practices: - It is strongly recommended to use async_insert=1 in combination with wait_for_async_insert=1 to ensure reliability, as wait_for_async_insert=0 (fire-and-forget) can lead to silent failures and makes it difficult for the client to confirm whether data was successfully persisted [2]. - If using insert_deduplication_token, ensure that async_insert_deduplicate is active, otherwise the token may be ignored for asynchronous operations [8][1]. For further implementation, you can specify these settings within the SETTINGS clause of your INSERT query [2]: INSERT INTO YourTable SETTINGS async_insert=1, async_insert_deduplicate=1, wait_for_async_insert=1 VALUES (...)

Citations:


Don't rely on async inserts for this flush path. events only dedupes on replicated tables (or non-replicated tables with non_replicated_deduplication_window), and getClickhouseSettings() enables async_insert without async_insert_deduplicate/wait_for_async_insert. That makes ch.insert() return before ClickHouse has committed the block, so the Redis ltrim is no longer a safe commit point.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/buffers/event-buffer.ts` around lines 177 - 194, Update the
event flush path using deduplicationToken so ClickHouse insertion is synchronous
and completes before the Redis ltrim commit point. Override the settings from
getClickhouseSettings() for this insert to disable async_insert and wait for the
insert to finish; do not rely on async-insert deduplication for correctness.

Comment on lines +256 to 268
const countByProject = new Map<string, number>();
for (const line of chunk) {
const projectId = extractProjectId(line);
if (projectId) {
countByProject.set(
projectId,
(countByProject.get(projectId) ?? 0) + 1,
);
}
}
if ((i + 1) % yieldEvery === 0) {
await this.yieldToEventLoop();
for (const [projectId, count] of countByProject) {
publishEvent('events', 'batch', { projectId, count });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Unhandled rejection risk: publishEvent calls are fire-and-forget.

publishEvent('events', 'batch', ...) at Line 267 returns a promise (packages/redis/publisher.ts forwards to redis.publish(...)), which is neither awaited nor .catch()-handled here. If the Redis pub/sub publish rejects (e.g. transient connection issue), this becomes an unhandled promise rejection, which by default crashes the Node process — a far worse outcome than the duplicate-insert bug this PR fixes, and it would take down the whole worker (all buffers), not just this flush.

🔒 Swallow/log publish errors instead of letting them propagate unhandled
       for (const [projectId, count] of countByProject) {
-        publishEvent('events', 'batch', { projectId, count });
+        publishEvent('events', 'batch', { projectId, count }).catch((err) => {
+          this.logger.warn({ err, projectId, count }, 'Failed to publish event batch notification');
+        });
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const countByProject = new Map<string, number>();
for (const line of chunk) {
const projectId = extractProjectId(line);
if (projectId) {
countByProject.set(
projectId,
(countByProject.get(projectId) ?? 0) + 1,
);
}
}
if ((i + 1) % yieldEvery === 0) {
await this.yieldToEventLoop();
for (const [projectId, count] of countByProject) {
publishEvent('events', 'batch', { projectId, count });
}
const countByProject = new Map<string, number>();
for (const line of chunk) {
const projectId = extractProjectId(line);
if (projectId) {
countByProject.set(
projectId,
(countByProject.get(projectId) ?? 0) + 1,
);
}
}
for (const [projectId, count] of countByProject) {
publishEvent('events', 'batch', { projectId, count }).catch((err) => {
this.logger.warn({ err, projectId, count }, 'Failed to publish event batch notification');
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/buffers/event-buffer.ts` around lines 256 - 268, Handle the
promise returned by publishEvent in the batch publishing loop so Redis publish
failures are caught and logged rather than becoming unhandled rejections. Update
the loop around countByProject and publishEvent, preserving the existing
per-project event payload and allowing the buffer flush to continue for other
projects.

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