-
Notifications
You must be signed in to change notification settings - Fork 399
fix(db): prevent duplicate event inserts on lock expiry and partial-batch retry #417
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,4 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { createHash } from 'node:crypto'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getRedisCache, publishEvent } from '@openpanel/redis'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ch, chQuery } from '../clickhouse/client'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { IClickhouseEvent } from '../services/event.service'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -173,6 +174,24 @@ export class EventBuffer extends BaseBuffer { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return this.queueKey; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 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'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async processBuffer() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const redis = getRedisCache(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -189,64 +208,70 @@ export class EventBuffer extends BaseBuffer { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // We don't need to JSON.parse the events at all — they're already | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // valid JSONEachRow lines (one stringified event per Redis entry). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The client's custom `json.stringify` (set in CLICKHOUSE_OPTIONS) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // passes strings through unchanged, so the bytes go straight from | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Redis → CH HTTP body. This skips: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - JSON.parse × N (50–300ms for N=100k) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - The @clickhouse/client's internal JSON.stringify × N (same) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // - All the intermediate object allocations (saves ~200MB heap) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Process chunks in queue order, committing each one before the next. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // A chunk is trimmed from the front of the queue only AFTER its insert | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // succeeds, and its per-project realtime notification is published only | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // then. If a chunk insert throws, we stop and let the error propagate to | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // tryFlush: already-committed chunks stay trimmed, and only the untrimmed | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // remainder is retried on the next cycle. Previously every chunk was | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // inserted and the whole slice was trimmed in a single `ltrim` at the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // very end, so a failure on a later chunk replayed the already-inserted | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // earlier chunks on retry — duplicate rows, since the `events` table has | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // no dedup key. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // We still need `project_id` per row for the per-project pub/sub. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // extractProjectId() does an indexOf-based fast path that's ~50× | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // faster than JSON.parse, and falls back to a real parse on the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // rare line where `project_id` appears more than once (e.g. a | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // user-supplied `properties.project_id`) — so the count is always | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // attributed to the top-level field, never a nested one. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const countByProject = new Map<string, number>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const yieldEvery = this.getYieldInterval(queueEvents.length, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| min: 1000, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| max: 5000, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (let i = 0; i < queueEvents.length; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const projectId = extractProjectId(queueEvents[i]!); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (projectId) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| countByProject.set( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| projectId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (countByProject.get(projectId) ?? 0) + 1, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // We never JSON.parse the events: they're already valid JSONEachRow lines | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // (one stringified event per Redis entry), and the client's custom | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `json.stringify` (set in CLICKHOUSE_OPTIONS) passes strings through | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // unchanged, so the bytes go straight from Redis → CH HTTP body. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `project_id` for the pub/sub is pulled with extractProjectId()'s | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // indexOf fast path (falls back to JSON.parse only on the rare line where | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `project_id` appears more than once). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const chunks = this.chunks(queueEvents, this.chunkSize); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let rowsProcessed = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let chInsertMs = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let trimMs = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const chunk of chunks) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const chStart = performance.now(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await ch.insert({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| table: 'events', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Stream the raw JSONEachRow lines straight through — already | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // serialized in Redis, no client-side parse/stringify needed. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| values: this.jsonEachRowStream(chunk), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| format: 'JSONEachRow', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| clickhouse_settings: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...this.getClickhouseSettings(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| insert_deduplication_token: this.deduplicationToken(chunk), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| chInsertMs += performance.now() - chStart; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Commit this chunk: remove exactly the entries we just inserted from | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the front of the queue. Order is preserved because we always insert | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // and trim the head chunk first. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const trimStart = performance.now(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await redis.ltrim(this.queueKey, chunk.length, -1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| trimMs += performance.now() - trimStart; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+256
to
268
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win Unhandled rejection risk:
🔒 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const chStart = performance.now(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await this.parallelLimit( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.chunks(queueEvents, this.chunkSize), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (chunk) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ch.insert({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| table: 'events', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Stream the raw JSONEachRow lines straight through — already | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // serialized in Redis, no client-side parse/stringify needed. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| values: this.jsonEachRowStream(chunk), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| format: 'JSONEachRow', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| clickhouse_settings: this.getClickhouseSettings(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const chInsertMs = performance.now() - chStart; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const [projectId, count] of countByProject) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| publishEvent('events', 'batch', { projectId, count }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rowsProcessed += chunk.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const trimStart = performance.now(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await redis.ltrim(this.queueKey, queueEvents.length, -1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const trimMs = performance.now() - trimStart; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.reportFlushStats({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rowsProcessed: queueEvents.length, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rowsProcessed, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| phases: { lrangeMs, chInsertMs, trimMs }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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:
Repository: Openpanel-dev/openpanel
Length of output: 161
🏁 Script executed:
Repository: Openpanel-dev/openpanel
Length of output: 50380
🏁 Script executed:
Repository: Openpanel-dev/openpanel
Length of output: 28575
🏁 Script executed:
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:
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_tokenwith 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 setasync_insert_deduplicate = 1[2][3]. 2. Functionality: Onceasync_insert_deduplicateis enabled, ClickHouse allows the use ofinsert_deduplication_tokento 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 forinsert_deduplication_tokenwas 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 useasync_insert=1in combination withwait_for_async_insert=1to ensure reliability, aswait_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 usinginsert_deduplication_token, ensure thatasync_insert_deduplicateis active, otherwise the token may be ignored for asynchronous operations [8][1]. For further implementation, you can specify these settings within theSETTINGSclause of yourINSERTquery [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.
eventsonly dedupes on replicated tables (or non-replicated tables withnon_replicated_deduplication_window), andgetClickhouseSettings()enablesasync_insertwithoutasync_insert_deduplicate/wait_for_async_insert. That makesch.insert()return before ClickHouse has committed the block, so the Redisltrimis no longer a safe commit point.🤖 Prompt for AI Agents