Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/host/app/lib/search-in-flight-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
normalizeQueryForSignature,
type Query,
} from '@cardstack/runtime-common';

// Stable digest key for store-side `_federated-search` in-flight dedup.
// Mirrors `runtime-common/realm-index-query-engine.ts:searchInFlightKey`
// but takes a realms array (the host fires federated searches against
// one or more realms; the realm-server engine is per-realm so its
// version takes a single URL).
//
// Returns undefined if the inputs can't be serialized deterministically —
// caller falls back to running uncoalesced so dedup is best-effort, never
// a correctness boundary.
//
// `realms` order is preserved (not sorted): the realm-server's
// `_federated-search` iterates the array and concatenates results in
// that order, so `[a, b]` and `[b, a]` are different requests.
export function searchInFlightKey(
realms: string[],
query: Query,
): string | undefined {
try {
return JSON.stringify([realms, normalizeQueryForSignature(query)]);
} catch {
return undefined;
}
}
8 changes: 8 additions & 0 deletions packages/host/app/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ export default class RenderRoute extends Route<Model> {
if (isTesting()) {
(globalThis as any).__boxelRenderContext = undefined;
}
// Drop any pending `_federated-search` in-flight entries the
// render-context coalescer accumulated during this visit. Entries
// self-clear on settle, but a deactivate while one is still
// in-flight could otherwise let a same-key caller arriving in the
// next render coalesce onto a promise belonging to the previous
// visit. The window is small (typically <1s per search) but the
// cost of an explicit clear is also small.
this.store.clearInFlightSearch();
(globalThis as any).__renderModel = undefined;
(globalThis as any).__docsInFlight = undefined;
(globalThis as any).__boxelRenderStage = undefined;
Expand Down
113 changes: 73 additions & 40 deletions packages/host/app/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
type LooseSingleResourceDocument,
type StoreReadType,
type CardResource,
type LinkableCollectionDocument,
type RealmIdentifier,
type RealmResourceIdentifier,
type Saved,
Expand All @@ -90,6 +91,7 @@ import {
duringPrerenderHeaders,
jobIdHeader,
} from '../lib/prerender-fetch-headers';
import { searchInFlightKey } from '../lib/search-in-flight-key';
import { errorJsonApiToErrorEntry } from '../lib/window-error-handler';
import { getSearch } from '../resources/search';
import {
Expand Down Expand Up @@ -234,6 +236,14 @@ export default class StoreService extends Service implements StoreInterface {
> = new Map();
private inflightCardMutations: Map<string, Promise<void>> = new Map();
private inflightCardLoads: Map<string, Deferred<void>> = new Map();
// Coalesce concurrent same-(realms, query) `_federated-search` HTTP
// calls during a prerender. Mirrors
// `RealmIndexQueryEngine.#inFlightSearch` server-side. Gated on
// `__boxelRenderContext` so live user searches stay uncoalesced —
// write-then-read freshness story unchanged outside prerender.
// Entries self-clear on `.finally()` via identity check.
private inflightSearch: Map<string, Promise<LinkableCollectionDocument>> =
new Map();
private store: CardStore;
protected isRenderStore = false;

Expand Down Expand Up @@ -281,6 +291,7 @@ export default class StoreService extends Service implements StoreInterface {
this.inflightGetFileMeta = new Map();
this.inflightCardMutations = new Map();
this.inflightCardLoads = new Map();
this.inflightSearch = new Map();
this.autoSaveQueues = new Map();
this.autoSavePromises = new Map();
this.store = this.createCardStore();
Expand All @@ -291,6 +302,16 @@ export default class StoreService extends Service implements StoreInterface {
await this.ready;
}

// Drop every pending in-flight search entry. Callers awaiting an
// existing promise still get their answer (the underlying HTTP is
// already in motion); only *new* same-key callers after the drop
// miss the map and re-fetch. Wire this to anything the host
// recognizes as an invalidation boundary — render-route deactivate
// is the obvious one inside a prerender tab.
clearInFlightSearch(): void {
this.inflightSearch.clear();
}

resetCache(opts?: { preserveReferences?: boolean }) {
storeLogger.debug('resetting store cache');
if (!opts?.preserveReferences) {
Expand All @@ -303,6 +324,7 @@ export default class StoreService extends Service implements StoreInterface {
this.inflightGetFileMeta = new Map();
this.inflightCardMutations = new Map();
this.inflightCardLoads = new Map();
this.inflightSearch = new Map();
this.autoSaveQueues = new Map();
this.autoSavePromises = new Map();
this.store = this.createCardStore();
Expand Down Expand Up @@ -892,45 +914,7 @@ export default class StoreService extends Service implements StoreInterface {
realms: string[],
dependencyTrackingContext?: RuntimeDependencyTrackingContext,
): Promise<{ instances: T[]; meta: QueryResultsMeta }> {
let realmServerURLs = this.realmServer.getRealmServersForRealms(realms);
// TODO remove this assertion after multi-realm server/federated identity is supported
this.realmServer.assertOwnRealmServer(realmServerURLs);
let [realmServerURL] = realmServerURLs;
let searchURL = new URL('_federated-search', realmServerURL);
let response = await this.realmServer.maybeAuthedFetchForRealms(
searchURL.href,
realms,
{
method: 'QUERY',
headers: {
Accept: SupportedMimeType.CardJson,
'Content-Type': 'application/json',
...duringPrerenderHeaders(),
...consumingRealmHeader(),
...jobIdHeader(),
...jobPriorityHeader(),
},
body: JSON.stringify({ ...query, realms }),
},
);
if (!response.ok) {
let responseText = await response.text();
let err = new Error(
`status: ${response.status} - ${response.statusText}. ${responseText}`,
) as any;
err.status = response.status;
err.responseText = responseText;
err.responseHeaders = response.headers;
throw err;
}
let json = await response.json();
if (!isLinkableCollectionDocument(json)) {
throw new Error(
`The realm search response was not a valid collection document:
${JSON.stringify(json, null, 2)}`,
);
}
let collectionDoc = json;
let collectionDoc = await this.fetchSearchDoc(query, realms);

// Hydrate each result into the store
let instances = (
Expand Down Expand Up @@ -962,6 +946,55 @@ export default class StoreService extends Service implements StoreInterface {
resources: (CardResource<Saved> | FileMetaResource)[];
meta: QueryResultsMeta;
}> {
let doc = await this.fetchSearchDoc(query, realms);
return { resources: doc.data, meta: doc.meta };
}

// Shared HTTP+JSON path for both `fetchSearchData` (raw resources for
// data-only callers) and `fetchAndHydrateSearchResults` (instances
// hydrated into the store). Sits between `store.search` and
// `_federated-search`.
//
// Inside a prerender tab (`__boxelRenderContext === true`)
// concurrent same-(realms, query) callers share one in-flight
// promise. Outside a prerender, every caller runs uncoalesced so
// live-SPA write-then-read flows keep their current freshness
// semantics.
private async fetchSearchDoc(
query: Query,
realms: string[],
): Promise<LinkableCollectionDocument> {
let key = (globalThis as any).__boxelRenderContext
? searchInFlightKey(realms, query)
: undefined;
if (key !== undefined) {
let existing = this.inflightSearch.get(key);
if (existing) {
return await existing;
}
let pending = this.fetchSearchDocUncoalesced(query, realms).finally(
() => {
// Identity-check before deletion: a concurrent
// `clearInFlightSearch()` could in principle have removed
// (and a later caller re-set) this slot while we were
// in-flight. Only clean up if the map still points at *this*
// pending promise. Mirrors
// `RealmIndexQueryEngine.searchCards` server-side.
if (this.inflightSearch.get(key) === pending) {
this.inflightSearch.delete(key);
}
},
);
this.inflightSearch.set(key, pending);
return await pending;
}
return await this.fetchSearchDocUncoalesced(query, realms);
}

private async fetchSearchDocUncoalesced(
query: Query,
realms: string[],
): Promise<LinkableCollectionDocument> {
let realmServerURLs = this.realmServer.getRealmServersForRealms(realms);
// TODO remove this assertion after multi-realm server/federated identity is supported
this.realmServer.assertOwnRealmServer(realmServerURLs);
Expand Down Expand Up @@ -1000,7 +1033,7 @@ export default class StoreService extends Service implements StoreInterface {
${JSON.stringify(json, null, 2)}`,
);
}
return { resources: json.data, meta: json.meta };
return json;
}

getSearchResource<T extends CardDef | FileDef = CardDef>(
Expand Down
Loading
Loading