Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/db/src/buffers/base-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,15 @@ export class BaseBuffer {
name: string;
logger: ILogger;
lockKey: string;
lockTimeout = 60;
// Flush-lock TTL, in seconds. It MUST comfortably exceed the maximum time a
// flush can spend inserting into ClickHouse, otherwise the lock can expire
// mid-flush and a second worker replica acquires it and re-flushes the same
// still-queued rows -> duplicate inserts (the `events` table has no dedup
// key). The server-side insert ceiling is max_execution_time = 300s (see
// INSERT_DEFAULT_SETTINGS in clickhouse/client.ts), and a flush inserts
// several chunks in sequence, so 60s was far too short. 360s keeps the lock
// held past the worst-case insert while still self-healing if a worker dies.
lockTimeout = 360;
onFlush: () => Promise<void> | void;
enableParallelProcessing: boolean;

Expand Down
97 changes: 97 additions & 0 deletions packages/db/src/buffers/event-buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,103 @@ describe('EventBuffer', () => {
expect(await eventBuffer.getBufferSize()).toBe(5);
});

it('does not replay already-committed chunks when a later chunk fails', async () => {
const prev = process.env.EVENT_BUFFER_CHUNK_SIZE;
process.env.EVENT_BUFFER_CHUNK_SIZE = '2';
const eb = new EventBuffer();

for (let i = 0; i < 4; i++) {
eb.add({
project_id: 'p13',
name: `event${i}`,
created_at: new Date(Date.now() + i).toISOString(),
} as any);
}
await eb.flush();
expect(await eb.getBufferSize()).toBe(4);

// First chunk inserts fine; the second chunk fails mid-batch.
const insertSpy = vi
.spyOn(ch, 'insert')
.mockResolvedValueOnce(undefined as any)
.mockRejectedValueOnce(new Error('ClickHouse unavailable'));

await expect(eb.processBuffer()).rejects.toThrow('ClickHouse unavailable');

// The committed chunk is trimmed; the failed chunk is retained. Crucially
// the committed chunk is NOT left in the queue, so it cannot be replayed.
expect(insertSpy).toHaveBeenCalledTimes(2);
expect(await eb.getBufferSize()).toBe(2);

// Retry: only the remaining chunk is inserted — committed events are never
// re-sent, so no duplicate rows.
insertSpy.mockReset();
insertSpy.mockResolvedValue(undefined as any);

await eb.processBuffer();

expect(insertSpy).toHaveBeenCalledTimes(1);
const retryLines = await streamToLines(
insertSpy.mock.calls[0]![0].values as Readable
);
const retryNames = retryLines.map((line) => JSON.parse(line).name);
expect(retryNames).toEqual(['event2', 'event3']);
expect(await eb.getBufferSize()).toBe(0);

if (prev === undefined) {
delete process.env.EVENT_BUFFER_CHUNK_SIZE;
} else {
process.env.EVENT_BUFFER_CHUNK_SIZE = prev;
}
insertSpy.mockRestore();
});

it('sends a deterministic dedup token so retried inserts are idempotent', async () => {
const makeEvents = () =>
[
{
project_id: 'p14',
name: 'event_a',
created_at: new Date(1000).toISOString(),
},
{
project_id: 'p14',
name: 'event_b',
created_at: new Date(2000).toISOString(),
},
] as any[];

const insertSpy = vi
.spyOn(ch, 'insert')
.mockResolvedValue(undefined as any);

const eb1 = new EventBuffer();
eb1.bulkAdd(makeEvents());
await eb1.flush();
await eb1.processBuffer();

const token1 = (insertSpy.mock.calls[0]![0] as any).clickhouse_settings
?.insert_deduplication_token as string;

insertSpy.mockClear();

// Same content queued again (e.g. a lock-expiry double-flush): the token
// must match so ClickHouse can reject the duplicate insert.
const eb2 = new EventBuffer();
eb2.bulkAdd(makeEvents());
await eb2.flush();
await eb2.processBuffer();

const token2 = (insertSpy.mock.calls[0]![0] as any).clickhouse_settings
?.insert_deduplication_token as string;

expect(typeof token1).toBe('string');
expect(token1.length).toBeGreaterThan(0);
expect(token1).toBe(token2);

insertSpy.mockRestore();
});

it('retains events in queue when ClickHouse insert fails', async () => {
eventBuffer.add({
project_id: 'p12',
Expand Down
127 changes: 76 additions & 51 deletions packages/db/src/buffers/event-buffer.ts
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';
Expand Down Expand Up @@ -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');
}

Comment on lines +177 to +194

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.

async processBuffer() {
const redis = getRedisCache();

Expand All @@ -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

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.

}

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 },
});
}
Expand Down