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
59 changes: 53 additions & 6 deletions packages/runtime-common/index-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Memoize } from 'typescript-memoize';

import {
logger,
hasCardExtension,
hasExecutableExtension,
SupportedMimeType,
jobIdentity,
Expand Down Expand Up @@ -203,7 +204,17 @@ export class IndexRunner {
await current.#dependencyResolver.orderInvalidationsByDependencies(
invalidations,
);
await current.preWarmModulesTable(invalidations);
// Pre-warm the modules cache. Combines per-row deps (which catch
// most modules used during a from-scratch pass) with the realm-
// wide `.gts` / `.gjs` sweep (which catches sibling card modules
// referenced by string in templates — the typical
// `<Search @query={{filter: {type: {module: '.../cohort.gts', name: 'Cohort'}}}}>`
// pattern). The filesystem-mtimes walk was already paid by
// discoverInvalidations above; we just filter and reuse it.
let allRealmCardModules = Object.keys(
discoverResult.filesystemMtimes,
).filter(hasCardExtension);
await current.preWarmModulesTable(invalidations, allRealmCardModules);
let resumedRows = current.batch.resumedRows;
let resumedSkipped = 0;
current.#onProgress?.({
Expand Down Expand Up @@ -354,7 +365,14 @@ export class IndexRunner {
}
current.#scheduleClearCacheForNextRender();
}
await current.preWarmModulesTable(invalidations);
// Pre-warm: combine per-row deps with a realm-wide `.gts`/`.gjs`
// sweep. Incremental skips `discoverInvalidations` so the
// filesystem-mtimes walk hasn't happened yet — call it here.
// Typical realm sizes make this < 200 ms; one call per job.
let incrementalMtimes = await current.#reader.mtimes();
let allRealmCardModules =
Object.keys(incrementalMtimes).filter(hasCardExtension);
await current.preWarmModulesTable(invalidations, allRealmCardModules);

let hrefs = urls.map((u) => u.href);
let resumedRows = current.batch.resumedRows;
Expand Down Expand Up @@ -568,11 +586,38 @@ export class IndexRunner {
// Failures here are warned but do not fail the batch — a mid-render
// sub-prerender will still fire on demand if pre-warm misses a
// module.
private async preWarmModulesTable(invalidations: URL[]): Promise<void> {
if (invalidations.length === 0) {
private async preWarmModulesTable(
invalidations: URL[],
allRealmCardModules: string[] = [],
): Promise<void> {
if (invalidations.length === 0 && allRealmCardModules.length === 0) {
return;
}
let preWarmStart = Date.now();

// Base layer: every `.gts` / `.gjs` file in the realm, regardless of
// whether it appears in this batch's invalidation set. Catches sibling
// card modules referenced by *string* in templates (e.g.
// `<Search @query={{filter: {type: {module: '.../cohort.gts', ...}}}}>`)
// — those don't appear in any instance's runtime `deps`. Without
// this layer the search fires a same-affinity `prerenderModule`
// mid-card-render at lookup time, which is the wait-shape the
// PagePool's tab-materialization for module/command callers is
// meant to relieve.
//
// `.gts` / `.gjs` only is an optimization, not a correctness gate:
// `.ts` / `.js` files CAN host `CardDef` (e.g. command-input
// cards). If pre-warm misses such a module, the on-demand
// `lookupDefinition` read-through during the visit fires a
// `prerenderModule` for it — safe because the PagePool now
// materializes a tab for the sub-prerender instead of queueing it
// behind the render that triggered the lookup. Restricting the
// sweep to the extensions where cards live almost exclusively
// avoids paying the prerender cost on every reindex for files that
// rarely define a card (typical realms have many helper `.ts`
// files alongside their cards).
let toWarm = new Set<string>(allRealmCardModules);

let hrefs = invalidations.map((u) => u.href);
let existingRows = await this.batch.getDependencyRows(hrefs);
let bestByUrl = new Map<string, { url: string; deps: string[] | null }>();
Expand All @@ -585,13 +630,15 @@ export class IndexRunner {
}
}

let toWarm = new Set<string>();
let novelJsonUrls: URL[] = [];
for (let url of invalidations) {
// Module files in the invalidation set are deps that instances
// in the same batch will consume — pre-warm them directly. This
// covers from-scratch and atomic-update batches where most rows
// have no prior `deps` data yet.
// have no prior `deps` data yet. Unlike the realm-wide layer
// above, this includes `.ts` / `.js` helpers — only the ones the
// batch is actually touching, so cost is bounded by invalidation
// size rather than realm size.
if (hasExecutableExtension(url.href)) {
toWarm.add(url.href);
}
Expand Down
22 changes: 22 additions & 0 deletions packages/runtime-common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,19 @@ export * from './pr-manifest';
export * from './file-def-code-ref';

export const executableExtensions = ['.js', '.gjs', '.ts', '.gts'];
// Extensions covered by the realm-wide pre-warm sweep that primes the
// modules cache before the visit loop. This is an optimization, not a
// correctness gate: a `.ts` / `.js` file CAN host a `CardDef`
// (e.g. command-input cards), and if pre-warm misses one the on-demand
// `lookupDefinition` cache read-through fires a `prerenderModule` for
// it during the visit. The PagePool's tab-materialization for
// module/command callers makes that on-demand path safe (the sub-
// prerender gets its own tab instead of queueing behind the render
// that triggered it). Restricting the sweep to `.gts` / `.gjs` — where
// cards live almost exclusively in practice — avoids paying the
// prerender cost on every index for a file type that rarely contains
// card definitions.
export const cardExtensions = ['.gts', '.gjs'];
export { createResponse } from './create-response';

export * from './db-queries/db-types';
Expand Down Expand Up @@ -1007,6 +1020,15 @@ export function hasExecutableExtension(path: string): boolean {
return false;
}

export function hasCardExtension(path: string): boolean {
for (let extension of cardExtensions) {
if (path.endsWith(extension)) {
return true;
}
}
return false;
}

export function trimExecutableExtension(
input: RealmResourceIdentifier,
): RealmResourceIdentifier {
Expand Down
Loading