diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 8493f39a7..0ffcd61df 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -90,7 +90,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const slot = beginSearch(actor.id); + const slot = beginSearch(actor.id, user.id); try { const [modules, domainVisible, timezone] = await Promise.all([ resolveModuleMap(user.id), diff --git a/src/lib/search/__tests__/in-flight.test.ts b/src/lib/search/__tests__/in-flight.test.ts new file mode 100644 index 000000000..0806052b9 --- /dev/null +++ b/src/lib/search/__tests__/in-flight.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { SUPERSEDED, beginSearch, searchesInFlight } from "../in-flight"; + +describe("beginSearch", () => { + it("a newer search of the same record stops the one before it", () => { + const first = beginSearch("actor", "record-a"); + const second = beginSearch("actor", "record-a"); + expect(first.signal.aborted).toBe(true); + expect(first.signal.reason).toBe(SUPERSEDED); + expect(second.signal.aborted).toBe(false); + first.end(); + second.end(); + expect(searchesInFlight()).toBe(0); + }); + + it("searches of two different records by one actor run side by side", () => { + const own = beginSearch("actor", "record-a"); + const shared = beginSearch("actor", "record-b"); + expect(own.signal.aborted).toBe(false); + expect(shared.signal.aborted).toBe(false); + own.end(); + shared.end(); + expect(searchesInFlight()).toBe(0); + }); +}); diff --git a/src/lib/search/in-flight.ts b/src/lib/search/in-flight.ts index e58c01ee7..7d0a6c818 100644 --- a/src/lib/search/in-flight.ts +++ b/src/lib/search/in-flight.ts @@ -1,12 +1,14 @@ /** - * One search in flight per actor. + * One search in flight per actor and record. * * The palette searches once per pause in typing, and the client cancels the * previous request when the text changes. A cancelled fetch does not stop the * server, though: without this, every keystroke-pause left a search reading * and decrypting on the server until its own budget ran out, so a fast typist - * held several at once. A new search by the same actor aborts the one before - * it, which stops at its next budget check. + * held several at once. A new search by the same actor in the same record + * aborts the one before it, which stops at its next budget check. Searches + * of two different records (a tab on one's own record, another on a record + * shared with them) are two separate palettes and never cancel each other. * * Per process: on a multi-instance deploy two searches by one actor on two * instances both run, each still bounded by its own time budget and by the @@ -18,20 +20,22 @@ const inFlight = new Map(); export const SUPERSEDED = "search.superseded"; export interface SearchSlot { - /** Aborted when the same actor starts a newer search. */ + /** Aborted when the same actor starts a newer search of the same record. */ signal: AbortSignal; /** Release the slot once the search answered (or failed). */ end: () => void; } -export function beginSearch(actorId: string): SearchSlot { - inFlight.get(actorId)?.abort(SUPERSEDED); +export function beginSearch(actorId: string, recordId: string): SearchSlot { + // Ids are cuids, so the separator cannot occur inside either part. + const key = `${actorId}:${recordId}`; + inFlight.get(key)?.abort(SUPERSEDED); const controller = new AbortController(); - inFlight.set(actorId, controller); + inFlight.set(key, controller); return { signal: controller.signal, end: () => { - if (inFlight.get(actorId) === controller) inFlight.delete(actorId); + if (inFlight.get(key) === controller) inFlight.delete(key); }, }; }