Skip to content

Commit 6270c82

Browse files
fix(replay): clamp ref flush window, real setting name, stable log_comment
Review follow-ups on the session_replay_blocks ref insert settings: - REPLAY_BLOCKS_ASYNC_INSERT_TIMEOUT_MS is clamped to [200, 10000] ms with a warning (0 still means ClickHouse's adaptive default). A value at or above REPLAY_BLOCKS_TIMEOUT_MS (20 s) would abort every ref insert, and a non-numeric value such as "2s" now falls back to the 2 s default instead of parsing as a 2 ms window. - Use async_insert_busy_timeout_max_ms (the real name; _ms is an alias the client types do not know). - Pass a fixed log_comment on ref inserts. ClickHouse only coalesces async inserts with identical settings, and the client wrapper stamps a per-request log_comment (trace_id, span_id), so every ref insert was its own bucket and its own part regardless of the busy timeout. Caller settings override the wrapper's. - Biome import formatting in the test.
1 parent 3970ddf commit 6270c82

2 files changed

Lines changed: 104 additions & 13 deletions

File tree

‎packages/db/src/services/replay-block.service.test.ts‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ vi.mock('../clickhouse/client', () => ({
2929
ch: { insert: (args: any) => insertMock(args) },
3030
}));
3131

32-
const { writeSessionReplayBlocks, replayBlockRefsInsertSettings } = await import(
33-
'./replay-block.service'
34-
);
32+
const {
33+
writeSessionReplayBlocks,
34+
replayBlockRefsInsertSettings,
35+
parseRefsAsyncInsertTimeoutMs,
36+
} = await import('./replay-block.service');
3537
const { decompressReplayBlock } = await import('../blob/replay-blocks');
3638

3739
const chunk = (
@@ -218,15 +220,57 @@ describe('writeSessionReplayBlocks', () => {
218220
async_insert: 1,
219221
wait_for_async_insert: 1,
220222
async_insert_use_adaptive_busy_timeout: 0,
221-
async_insert_busy_timeout_ms: 2000,
223+
async_insert_busy_timeout_max_ms: 2000,
222224
});
223225
// 0 falls back to ClickHouse's adaptive default
224226
const off = replayBlockRefsInsertSettings(0);
225-
expect(off).not.toHaveProperty('async_insert_busy_timeout_ms');
227+
expect(off).not.toHaveProperty('async_insert_busy_timeout_max_ms');
226228
expect(off).not.toHaveProperty('async_insert_use_adaptive_busy_timeout');
227229
expect(off.wait_for_async_insert).toBe(1);
228230
});
229231

232+
it('ref inserts carry identical settings (incl. a fixed log_comment) so CH can coalesce them', async () => {
233+
await writeSessionReplayBlocks([
234+
{ line: chunk('A', 0, '2026-10-01 10:00:00.000'), offset: 1 },
235+
]);
236+
await writeSessionReplayBlocks([
237+
{ line: chunk('B', 0, '2026-10-01 10:00:05.000'), offset: 2 },
238+
]);
239+
const [first, second] = insertMock.mock.calls.map(
240+
(c) => c[0].clickhouse_settings,
241+
);
242+
expect(first).toEqual(second);
243+
// an explicit log_comment, so the client wrapper's per-request one
244+
// (trace_id/span_id) does not split every insert into its own bucket
245+
expect(typeof first.log_comment).toBe('string');
246+
const comment = JSON.parse(first.log_comment);
247+
expect(comment).not.toHaveProperty('trace_id');
248+
expect(comment).not.toHaveProperty('span_id');
249+
expect(comment).not.toHaveProperty('user_id');
250+
});
251+
252+
it('clamps REPLAY_BLOCKS_ASYNC_INSERT_TIMEOUT_MS to [200, 10000] ms and warns', () => {
253+
const warn = vi.fn();
254+
expect(parseRefsAsyncInsertTimeoutMs(undefined, warn)).toBe(2000);
255+
expect(parseRefsAsyncInsertTimeoutMs('', warn)).toBe(2000);
256+
expect(parseRefsAsyncInsertTimeoutMs('0', warn)).toBe(0); // adaptive
257+
expect(parseRefsAsyncInsertTimeoutMs('1500', warn)).toBe(1500);
258+
expect(parseRefsAsyncInsertTimeoutMs('200', warn)).toBe(200);
259+
expect(parseRefsAsyncInsertTimeoutMs('10000', warn)).toBe(10_000);
260+
expect(warn).not.toHaveBeenCalled();
261+
262+
expect(parseRefsAsyncInsertTimeoutMs('2', warn)).toBe(200);
263+
expect(parseRefsAsyncInsertTimeoutMs('20000', warn)).toBe(10_000);
264+
expect(warn).toHaveBeenCalledTimes(2);
265+
266+
// not a whole number of ms (a typo like "2s"): default, not a 2 ms window
267+
warn.mockClear();
268+
expect(parseRefsAsyncInsertTimeoutMs('2s', warn)).toBe(2000);
269+
expect(parseRefsAsyncInsertTimeoutMs('-5', warn)).toBe(2000);
270+
expect(parseRefsAsyncInsertTimeoutMs('abc', warn)).toBe(2000);
271+
expect(warn).toHaveBeenCalledTimes(3);
272+
});
273+
230274
it('propagates an append failure (caller decides) and inserts no refs', async () => {
231275
appendMock.mockRejectedValueOnce(new Error('azure down'));
232276
await expect(

‎packages/db/src/services/replay-block.service.ts‎

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createLogger } from '@openpanel/logger';
12
import {
23
appendReplayBlock,
34
compressReplayBlock,
@@ -137,25 +138,71 @@ const BLOCK_WRITE_CONCURRENCY = (() => {
137138
// window turns that into roughly one part per node per window. The consumer
138139
// waits for the flush (wait_for_async_insert) before resolving offsets, so the
139140
// window is added latency per Kafka batch, well inside the batch deadline.
140-
// 0 = ClickHouse's default (adaptive) behaviour.
141-
const REFS_ASYNC_INSERT_TIMEOUT_MS = (() => {
142-
const n = Number.parseInt(
143-
process.env.REPLAY_BLOCKS_ASYNC_INSERT_TIMEOUT_MS ?? '',
144-
10,
141+
// 0 = ClickHouse's default (adaptive) behaviour. Other values are clamped to
142+
// [MIN, MAX]: at or above REPLAY_BLOCKS_TIMEOUT_MS (20 s) every ref insert
143+
// would abort, and a typo like "2s" must not become a 2 ms window.
144+
export const REFS_ASYNC_INSERT_TIMEOUT_DEFAULT_MS = 2000;
145+
export const REFS_ASYNC_INSERT_TIMEOUT_MIN_MS = 200;
146+
export const REFS_ASYNC_INSERT_TIMEOUT_MAX_MS = 10_000;
147+
148+
/** Parse REPLAY_BLOCKS_ASYNC_INSERT_TIMEOUT_MS (exported for tests). */
149+
export function parseRefsAsyncInsertTimeoutMs(
150+
raw: string | undefined,
151+
warn: (msg: string, meta: Record<string, unknown>) => void = () => {},
152+
): number {
153+
const value = (raw ?? '').trim();
154+
if (value === '') return REFS_ASYNC_INSERT_TIMEOUT_DEFAULT_MS;
155+
if (!/^\d+$/.test(value)) {
156+
warn(
157+
'REPLAY_BLOCKS_ASYNC_INSERT_TIMEOUT_MS is not a whole number of ms; using the default',
158+
{ value: raw, usedMs: REFS_ASYNC_INSERT_TIMEOUT_DEFAULT_MS },
159+
);
160+
return REFS_ASYNC_INSERT_TIMEOUT_DEFAULT_MS;
161+
}
162+
const n = Number.parseInt(value, 10);
163+
if (n === 0) return 0;
164+
const clamped = Math.min(
165+
REFS_ASYNC_INSERT_TIMEOUT_MAX_MS,
166+
Math.max(REFS_ASYNC_INSERT_TIMEOUT_MIN_MS, n),
145167
);
146-
return Number.isFinite(n) && n >= 0 ? n : 2000;
147-
})();
168+
if (clamped !== n) {
169+
warn('REPLAY_BLOCKS_ASYNC_INSERT_TIMEOUT_MS out of range; clamped', {
170+
value: n,
171+
usedMs: clamped,
172+
minMs: REFS_ASYNC_INSERT_TIMEOUT_MIN_MS,
173+
maxMs: REFS_ASYNC_INSERT_TIMEOUT_MAX_MS,
174+
});
175+
}
176+
return clamped;
177+
}
178+
179+
const REFS_ASYNC_INSERT_TIMEOUT_MS = parseRefsAsyncInsertTimeoutMs(
180+
process.env.REPLAY_BLOCKS_ASYNC_INSERT_TIMEOUT_MS,
181+
(msg, meta) => createLogger({ name: 'replay-blocks' }).warn(msg, meta),
182+
);
183+
184+
// ClickHouse only coalesces async inserts whose settings are identical. The
185+
// client wrapper stamps a per-request log_comment (trace_id, span_id), which
186+
// made every ref insert its own async-insert bucket and so its own part,
187+
// whatever the busy timeout. A fixed log_comment here (caller settings win over
188+
// the wrapper's) keeps the settings identical across inserts; per-insert trace
189+
// attribution means little once inserts are flushed together anyway.
190+
const REFS_LOG_COMMENT = JSON.stringify({
191+
service: process.env.OTEL_SERVICE_NAME ?? 'openpanel-unknown',
192+
endpoint: 'replay.insertReplayBlocks',
193+
});
148194

149195
/** ClickHouse settings for a refs insert (exported for tests). */
150196
export function replayBlockRefsInsertSettings(timeoutMs: number) {
151197
return {
152198
async_insert: 1,
153199
wait_for_async_insert: 1,
154200
input_format_skip_unknown_fields: 1,
201+
log_comment: REFS_LOG_COMMENT,
155202
...(timeoutMs > 0
156203
? {
157204
async_insert_use_adaptive_busy_timeout: 0,
158-
async_insert_busy_timeout_ms: timeoutMs,
205+
async_insert_busy_timeout_max_ms: timeoutMs,
159206
}
160207
: {}),
161208
} as const;

0 commit comments

Comments
 (0)