Skip to content

Commit 00d580e

Browse files
committed
fix: squash sessions
1 parent 577a316 commit 00d580e

2 files changed

Lines changed: 88 additions & 40 deletions

File tree

packages/db/src/buffers/session-buffer.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,48 @@ describe('SessionBuffer', () => {
105105
insertSpy.mockRestore();
106106
});
107107

108+
it('squash does not orphan the creation row when create + updates share a batch', async () => {
109+
// Regression: a brand-new session whose creation event and subsequent
110+
// updates all land in one flush window. getSession emits (+1,v1) for the
111+
// creation then (-1,vN)/(+1,vN+1) pairs for each update. The squash must
112+
// net per version so nothing is left un-collapsible: a (-1,V) row only
113+
// collapses against a (+1,V) of the SAME version in CH.
114+
const t0 = Date.now();
115+
const EVENTS = 8;
116+
for (let i = 0; i < EVENTS; i++) {
117+
await sessionBuffer.add(
118+
makeEvent({ created_at: new Date(t0 + i * 1000).toISOString() }),
119+
);
120+
}
121+
122+
const inserted: Array<{ sign: number; version: number }> = [];
123+
const insertSpy = vi
124+
.spyOn(ch, 'insert')
125+
.mockImplementation(async ({ values }: any) => {
126+
for (const v of values) {
127+
inserted.push({ sign: v.sign, version: v.version });
128+
}
129+
return undefined as any;
130+
});
131+
132+
await sessionBuffer.processBuffer();
133+
134+
// Net the inserted rows per version exactly as CH's
135+
// VersionedCollapsingMergeTree(sign, version) would: a row survives only
136+
// if positives and negatives at its version don't cancel.
137+
const netByVersion = new Map<number, number>();
138+
for (const { sign, version } of inserted) {
139+
netByVersion.set(version, (netByVersion.get(version) ?? 0) + sign);
140+
}
141+
const survivors = [...netByVersion.entries()].filter(([, net]) => net !== 0);
142+
143+
// A fresh session must collapse to exactly one final +1 row, with no
144+
// orphaned negatives (the bug left a permanent (-1, v1)).
145+
expect(survivors).toEqual([[EVENTS, 1]]);
146+
147+
insertSpy.mockRestore();
148+
});
149+
108150
it('retains sessions in queue when ClickHouse insert fails', async () => {
109151
await sessionBuffer.add(makeEvent({}));
110152

packages/db/src/buffers/session-buffer.ts

Lines changed: 46 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -253,17 +253,26 @@ export class SessionBuffer extends BaseBuffer {
253253
}
254254

255255
/**
256-
* Squash multiple (sign=-1, sign=+1) pairs for the same session id into
257-
* just the boundary rows: the oldest -1 (cancels the previous CH state)
258-
* and the newest +1 (the final state). Single-row sessions (no update
259-
* pair yet) pass through unchanged.
256+
* Squash the (sign=-1, sign=+1) rows for each session id down to the
257+
* minimal set that produces the same collapsed state in ClickHouse, by
258+
* netting the signs per version. Single-row sessions pass through
259+
* unchanged.
260260
*
261-
* Correctness depends on the invariant maintained by `getSession`: every
262-
* update for an existing session emits exactly one -1 at version V and
263-
* one +1 at V+1, where V is the existing version. The oldest -1 in the
264-
* batch cancels CH's previous +1, and the newest +1 becomes the new
265-
* canonical row. Intermediate (-1, +1) pairs cancel each other in CH
266-
* either way — keeping them is wasted work.
261+
* `sessions` uses VersionedCollapsingMergeTree(sign, version): a (-1, V)
262+
* row only collapses against a (+1, V) row of the SAME version. So the
263+
* net effect of a batch on a session is computed per-version — sum the
264+
* signs at each version and emit only the rows needed to realise that
265+
* net. Versions that fully cancel (net 0) are dropped; the surviving
266+
* rows are normally the final (+1, maxVersion) plus, for a mid-life
267+
* session, the (-1, V) that cancels CH's resident row.
268+
*
269+
* This per-version netting is required because a session's creation row
270+
* (+1 at the base version) can land in the SAME batch as its updates.
271+
* The first update emits a (-1, baseVersion) whose only partner is that
272+
* in-batch creation (+1, baseVersion). They must cancel each other here;
273+
* naively keeping "oldest -1 + newest +1" would drop the creation +1
274+
* while keeping its canceller, leaving a permanently un-collapsible (-1)
275+
* row in CH (duplicate session rows + negative bounce counts).
267276
*/
268277
private squashSessionsByVersion(
269278
sessions: IClickhouseSession[]
@@ -274,12 +283,11 @@ export class SessionBuffer extends BaseBuffer {
274283

275284
const grouped = new Map<string, IClickhouseSession[]>();
276285
for (const s of sessions) {
277-
const key = s.id;
278-
const arr = grouped.get(key);
286+
const arr = grouped.get(s.id);
279287
if (arr) {
280288
arr.push(s);
281289
} else {
282-
grouped.set(key, [s]);
290+
grouped.set(s.id, [s]);
283291
}
284292
}
285293

@@ -291,27 +299,36 @@ export class SessionBuffer extends BaseBuffer {
291299
out.push(entries[0]!);
292300
continue;
293301
}
294-
let oldestNeg: IClickhouseSession | null = null;
295-
let newestPos: IClickhouseSession | null = null;
302+
303+
const netByVersion = new Map<
304+
number,
305+
{ net: number; pos?: IClickhouseSession; neg?: IClickhouseSession }
306+
>();
296307
for (const e of entries) {
297-
if (e.sign === -1) {
298-
if (!oldestNeg || e.version < oldestNeg.version) {
299-
oldestNeg = e;
300-
}
301-
} else if (
302-
e.sign === 1 &&
303-
(!newestPos || e.version > newestPos.version)
304-
) {
305-
newestPos = e;
308+
const slot = netByVersion.get(e.version) ?? { net: 0 };
309+
slot.net += e.sign;
310+
if (e.sign === 1) {
311+
slot.pos = e;
312+
} else if (e.sign === -1) {
313+
slot.neg = e;
306314
}
315+
netByVersion.set(e.version, slot);
307316
}
317+
308318
const emitted: IClickhouseSession[] = [];
309-
if (oldestNeg) {
310-
emitted.push(oldestNeg);
311-
}
312-
if (newestPos) {
313-
emitted.push(newestPos);
319+
for (const slot of netByVersion.values()) {
320+
if (slot.net === 0) {
321+
continue;
322+
}
323+
const row = slot.net > 0 ? slot.pos : slot.neg;
324+
if (!row) {
325+
continue;
326+
}
327+
for (let i = 0; i < Math.abs(slot.net); i++) {
328+
emitted.push(row);
329+
}
314330
}
331+
315332
squashedCount += entries.length - emitted.length;
316333
out.push(...emitted);
317334
}
@@ -328,17 +345,6 @@ export class SessionBuffer extends BaseBuffer {
328345
);
329346
}
330347

331-
console.log(
332-
squashedCount > 0
333-
? 'Session batch squashed'
334-
: 'Session batch not squashed',
335-
{
336-
inputRows: sessions.length,
337-
outputRows: out.length,
338-
dropped: squashedCount,
339-
}
340-
);
341-
342348
return out;
343349
}
344350

0 commit comments

Comments
 (0)