fix(search): key the in-flight search by actor and record

Two tabs searching two different records (one's own and a shared one)
cancelled each other, because a new search aborted any earlier one by
the same actor. The slot is now held per actor and record.
This commit is contained in:
Marc-André Bombeck committed 2026-10-11 15:17:43 +02:00
1 parent 24f06884b3
commit 88aec7edb6
3 files changed
+39 -9

No files matched your search

+1 -1
View File
@@ -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),
@@ -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);
});
});
+12 -8
View File
@@ -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<string, AbortController>();
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);
},
};
}