Skip to content

Commit af896f0

Browse files
committed
fix(webapp): JSON-encode the run-set cache key to avoid separator collisions
A tag containing a comma keyed the same as two separate tags, so the resolve+hydrate coalescing cache could serve the wrong runs for up to its TTL. Encode the tag/column arrays instead of joining them.
1 parent 6e58291 commit af896f0

2 files changed

Lines changed: 12 additions & 2 deletions

File tree

apps/webapp/app/services/realtime/notifierRealtimeClient.server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -529,8 +529,11 @@ export class NotifierRealtimeClient implements RealtimeStreamClient {
529529
/** Stable cache key for the resolve+hydrate cache. Same key => same id-set and the
530530
* same projected columns, so cached rows always match the requesting feed. */
531531
#runSetCacheKey(environmentId: string, filter: RunSetFilter, skipColumns: string[]): string {
532-
const tags = filter.tags && filter.tags.length > 0 ? [...filter.tags].sort().join(",") : "";
533-
const cols = skipColumns.length > 0 ? [...skipColumns].sort().join(",") : "";
532+
// JSON-encode the arrays (not a join) so a value containing the separators —
533+
// e.g. a tag with a comma — can't collide: ["a,b"] must not key the same as
534+
// ["a","b"], which are different ClickHouse filters.
535+
const tags = filter.tags && filter.tags.length > 0 ? JSON.stringify([...filter.tags].sort()) : "";
536+
const cols = skipColumns.length > 0 ? JSON.stringify([...skipColumns].sort()) : "";
534537
const maxListResults = this.options.maxListResults ?? DEFAULT_MAX_LIST_RESULTS;
535538
return `${environmentId}|${tags}|${filter.batchId ?? ""}|${
536539
filter.createdAtAfter?.getTime() ?? ""

apps/webapp/test/realtime/notifierRunSetCache.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ describe("NotifierRealtimeClient tag-list createdAt bucketing", () => {
157157
expect(resolveSpy).toHaveBeenCalledTimes(2);
158158
});
159159

160+
it("does not collide a comma-containing tag with two separate tags", async () => {
161+
const { client, resolveSpy } = makeClient({ runSetCreatedAtBucketMs: 60 * 60_000 });
162+
await snapshotTag(client, ["a,b"]); // one tag "a,b"
163+
await snapshotTag(client, ["a", "b"]); // two tags a OR b — a different filter
164+
expect(resolveSpy).toHaveBeenCalledTimes(2);
165+
});
166+
160167
it("keeps each feed's exact lower bound when bucketing is disabled (0)", async () => {
161168
vi.useFakeTimers({ toFake: ["Date"] });
162169
vi.setSystemTime(new Date("2026-06-07T10:00:30.500Z"));

0 commit comments

Comments
 (0)