diff --git a/packages/realm-server/prerender/prerenderer.ts b/packages/realm-server/prerender/prerenderer.ts index 078238f61c..a406d2e47a 100644 --- a/packages/realm-server/prerender/prerenderer.ts +++ b/packages/realm-server/prerender/prerenderer.ts @@ -110,6 +110,14 @@ export class Prerenderer { `batch ownership cleared for ${affinityKey} due to affinity disposal`, ); } + // Drop the affinity's icon memo with the rest of its warm state. + // Disposal also clears the ownership entry above, which makes the + // owner-matched clear in `releaseBatch` a no-op for this affinity — + // without this, a job whose affinity is disposed mid-run (cancel, + // idle eviction, capacity pressure) would leave its memo behind + // until another job for the same realm replaced it. A still-running + // job just re-renders each type's icon once as the memo re-warms. + this.#renderRunner.clearIconMemo(affinityKey); }, }); this.#renderRunner = new RenderRunner({ @@ -265,6 +273,10 @@ export class Prerenderer { let owner = this.#batchOwnership.get(affinityKey); if (owner?.batchId === batchId) { this.#batchOwnership.delete(affinityKey); + // The job's icon memo has no readers once its batch releases the + // affinity (a later job carries a different job key), so drop it + // rather than letting it idle until the next job replaces it. + this.#renderRunner.clearIconMemo(affinityKey); log.debug(`batch ${batchId} released ownership of ${affinityKey}`); } } @@ -279,6 +291,13 @@ export class Prerenderer { return owner ? { batchId: owner.batchId, since: owner.since } : undefined; } + // Read-only observability accessor used by tests. Callers outside of + // tests should not rely on this shape; it's a debugging surface, not a + // stable API. + getIconMemo(affinityKey: string) { + return this.#renderRunner.getIconMemo(affinityKey); + } + // Back-compat static re-export: older callers / tests reference // `Prerenderer.decorateRenderErrorsWithTimings`. Delegates to the free // function in `render-settlement.ts`. diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index 51e7166408..b917494b3a 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -80,6 +80,16 @@ type PoolInfo = { timedOut: boolean; }; +// One indexing job's icon renderings for one affinity: the captured icon +// markup keyed by the type's internal key, plus hit/miss counters for the +// stats line emitted when the memo is replaced or released. +type IconMemo = { + jobKey: string; + icons: Map; + hits: number; + misses: number; +}; + const CLEAR_CACHE_RETRY_SIGNATURES: readonly (readonly string[])[] = [ // this is a side effect of glimmer scoped styles moving a DOM node that // glimmer is tracking. when we go to teardown the component glimmer gets mad @@ -133,6 +143,17 @@ export class RenderRunner { byAffinity: new Map(), }; #lastAuthByAffinity = new Map(); + // Job-scoped icon memo, one slot per affinity. Icon HTML is a pure + // function of the card's type — the type's static `icon` component, never + // the instance — so within one indexing job the first visit for a type + // renders the icon and every later visit of that type reuses the captured + // markup, skipping the icon route. Indexing serializes per realm (= per + // affinity), so at most one job is active per slot: a visit carrying a + // different job key (jobId + loader epoch) replaces the slot outright. A + // module edit arrives as a new job with a new key, so stale markup cannot + // leak across module changes. Visits without a jobId (on-demand renders) + // never touch the memo. + #iconMemoByAffinity = new Map(); constructor(options: { pagePool: PagePool; boxelHostURL: string }) { this.#pagePool = options.pagePool; @@ -172,6 +193,70 @@ export class RenderRunner { this.#lastAuthByAffinity.delete(affinityKey); } + // Resolve the icon memo for a visit. Only indexing visits participate + // (they carry a jobId); the loader epoch rides the job key as + // belt-and-braces so a memo can never outlive the module surface it was + // rendered against. + #iconMemoFor( + affinityKey: string, + jobId: string | undefined, + loaderEpoch: string | undefined, + ): IconMemo | undefined { + if (!jobId) { + return undefined; + } + let jobKey = `${jobId}|${loaderEpoch ?? ''}`; + let memo = this.#iconMemoByAffinity.get(affinityKey); + if (!memo || memo.jobKey !== jobKey) { + if (memo) { + this.#logIconMemoStats(affinityKey, memo, 'superseded'); + } + memo = { jobKey, icons: new Map(), hits: 0, misses: 0 }; + this.#iconMemoByAffinity.set(affinityKey, memo); + } + return memo; + } + + // Drop an affinity's icon memo. Called when the indexing batch that owns + // the affinity releases it; the job-key check in #iconMemoFor already + // guarantees a later job never reads another job's entries, so this is + // memory hygiene, not a correctness gate. + clearIconMemo(affinityKey: string) { + let memo = this.#iconMemoByAffinity.get(affinityKey); + if (memo) { + this.#logIconMemoStats(affinityKey, memo, 'released'); + this.#iconMemoByAffinity.delete(affinityKey); + } + } + + // Read-only observability accessor used by tests. Callers outside of + // tests should not rely on this shape; it's a debugging surface, not a + // stable API. + getIconMemo(affinityKey: string): + | { + jobKey: string; + types: string[]; + hits: number; + misses: number; + } + | undefined { + let memo = this.#iconMemoByAffinity.get(affinityKey); + return memo + ? { + jobKey: memo.jobKey, + types: [...memo.icons.keys()], + hits: memo.hits, + misses: memo.misses, + } + : undefined; + } + + #logIconMemoStats(affinityKey: string, memo: IconMemo, reason: string) { + log.debug( + `icon memo stats affinity=${affinityKey} types=${memo.icons.size} hits=${memo.hits} misses=${memo.misses} (${reason})`, + ); + } + // Builds the per-render profile context threaded into `withTimeout`. // The affinity key drives the airtight affinity-scoped CPU-profiler // gate (only the render whose affinity exactly matches @@ -1124,6 +1209,15 @@ export class RenderRunner { return; }; + let emptyMeta: PrerenderMeta = { + serialized: null, + searchDoc: null, + displayNames: null, + deps: null, + types: null, + }; + let meta: PrerenderMeta = emptyMeta; + if (runHtmlSteps) { let isolatedStart = Date.now(); let isolatedResult = await withTimeout( @@ -1166,46 +1260,74 @@ export class RenderRunner { capturedDeps = await this.#readCapturedDeps(page); } } else { - // An index visit never touches the html route, so the icon render - // is its entry into the render app; subsequent steps are in-page - // child transitions. - let iconResult = await withTimeout( - page, + // An index visit never touches the html route, so render.meta is + // its entry into the render app; the icon runs after it as an + // in-page child transition. Meta goes first because the icon is a + // pure function of the card's type (`types[0]`, which meta + // resolves): the job-scoped memo lets the first visit for a type + // render the icon and every later visit of that type reuse the + // captured markup, skipping the icon route. + let metaResult = await runTimedStep( + 'visit card render.meta', async () => { await transitionTo( page, - 'render.icon', + 'render.meta', url, nonce, serializedOptions, ); - return await renderIcon(page, captureOptions); + return await renderMeta(page, captureOptions); }, - opts?.timeoutMs, - this.#profileContext(affinityKey, url, 'card icon', jobId), ); - if (isRenderError(iconResult)) { + if (metaResult !== undefined) { + meta = metaResult; + } else { + // The entry step failed, so the page is showing the error + // route: a subsequent capture would wait on fresh render + // output the page can no longer produce and ride out the + // full render timeout, evicting the tab. Skip the icon — + // the row is an error row either way. (A card whose meta + // errors deterministically — e.g. a computed that reads a + // broken link — takes this path on every visit.) cardShortCircuit = true; - let renderError = iconResult as RenderError; - let evicted = await this.#maybeEvict( + } + if (!cardShortCircuit) { + let iconMemo = this.#iconMemoFor( affinityKey, - 'visit card icon render', - renderError, + jobId, + baseOptions.loaderEpoch, ); - applyStepError(renderError, evicted); - } else { - iconHTML = iconResult as string; + let iconTypeKey = meta.types?.[0]; + // A clearCache visit is asked for a pristine render, so it + // bypasses the memo read; its fresh capture overwrites the + // entry below. + let memoizedIconHTML = + iconMemo && iconTypeKey !== undefined && !baseOptions.clearCache + ? iconMemo.icons.get(iconTypeKey) + : undefined; + if (memoizedIconHTML !== undefined) { + iconMemo!.hits++; + iconHTML = memoizedIconHTML; + log.debug( + `card icon memo hit type=${iconTypeKey} url=${url} affinity=${affinityKey}`, + ); + } else { + let iconResult = await runTimedStep( + 'visit card icon render', + () => renderIcon(page, captureOptions), + ); + if (iconResult !== undefined) { + iconHTML = iconResult; + if (iconMemo && iconTypeKey !== undefined) { + iconMemo.misses++; + iconMemo.icons.set(iconTypeKey, iconHTML); + } + } + } } } - let emptyMeta: PrerenderMeta = { - serialized: null, - searchDoc: null, - displayNames: null, - deps: null, - types: null, - }; - let meta: PrerenderMeta = emptyMeta; let typesForAncestors: PrerenderTypes = { types: null }; let headHTML: string | null = null; let atomHTML: string | null = null; @@ -1329,7 +1451,10 @@ export class RenderRunner { } } - if (!cardShortCircuit && runIndexSteps) { + // The fused visit runs meta last, after the format renders above + // marked the linksTo / linksToMany fields they read as "used"; the + // index visit ran meta as its entry instead. + if (!cardShortCircuit && runIndexSteps && runHtmlSteps) { let finalMetaResult = await runTimedStep( 'visit card render.meta', () => renderMeta(page, captureOptions), @@ -1421,11 +1546,28 @@ export class RenderRunner { timeoutMs: opts?.timeoutMs, }; - // stash file data for the render route model hook to consume - await page.evaluate((data) => { - (globalThis as any).__boxelFileRenderData = data; - }, effectiveFileData); - didStashFileRenderData = true; + // The index visit's only page work for a file is its icon, which + // is a pure function of the file's type (`effectiveTypes[0]`, + // resolved by the extract pass): on a memo hit the pass touches + // the page not at all — including the file-data stash, which only + // the render routes consume. A clearCache visit bypasses the memo + // read (see the card pass). + let iconMemo = runHtmlSteps + ? undefined + : this.#iconMemoFor(affinityKey, jobId, baseOptions.loaderEpoch); + let iconTypeKey = effectiveTypes?.[0]; + let memoizedIconHTML = + iconMemo && iconTypeKey !== undefined && !baseOptions.clearCache + ? iconMemo.icons.get(iconTypeKey) + : undefined; + + if (memoizedIconHTML === undefined) { + // stash file data for the render route model hook to consume + await page.evaluate((data) => { + (globalThis as any).__boxelFileRenderData = data; + }, effectiveFileData); + didStashFileRenderData = true; + } let fileError: RenderError | undefined; let fileShortCircuit = false; @@ -1493,6 +1635,12 @@ export class RenderRunner { } } } + } else if (memoizedIconHTML !== undefined) { + iconMemo!.hits++; + iconHTML = memoizedIconHTML; + log.debug( + `file icon memo hit type=${iconTypeKey} url=${url} affinity=${affinityKey}`, + ); } else { // The file's icon belongs to the index half, and an index visit // never touches the html route — so the icon render is its entry @@ -1522,6 +1670,10 @@ export class RenderRunner { applyStepError(renderError, evicted); } else { iconHTML = iconResult as string; + if (iconMemo && iconTypeKey !== undefined) { + iconMemo.misses++; + iconMemo.icons.set(iconTypeKey, iconHTML); + } } } diff --git a/packages/realm-server/tests/prerendering-test.ts b/packages/realm-server/tests/prerendering-test.ts index e70bb1a721..fae5379e13 100644 --- a/packages/realm-server/tests/prerendering-test.ts +++ b/packages/realm-server/tests/prerendering-test.ts @@ -11,6 +11,7 @@ import type { } from '@cardstack/runtime-common'; import type { Realm as RuntimeRealm } from '@cardstack/runtime-common'; import type { Prerenderer } from '../prerender/index.ts'; +import { toAffinityKey } from '../prerender/affinity.ts'; import { PagePool } from '../prerender/page-pool.ts'; import { RenderRunner } from '../prerender/render-runner.ts'; import { BrowserManager } from '../prerender/browser-manager.ts'; @@ -7355,6 +7356,16 @@ module(basename(import.meta.filename), function () { } } `, + 'pet.gts': ` + import { CardDef } from '@cardstack/base/card-api'; + const PetIcon = ; + export class Pet extends CardDef { + static displayName = "Pet"; + static icon = PetIcon; + } + `, 'maple.json': { data: { attributes: { name: 'Maple' }, @@ -7366,6 +7377,59 @@ module(basename(import.meta.filename), function () { }, }, }, + 'willow.json': { + data: { + attributes: { name: 'Willow' }, + meta: { + adoptsFrom: { + module: rri('./person'), + name: 'Person', + }, + }, + }, + }, + 'rex.json': { + data: { + attributes: {}, + meta: { + adoptsFrom: { + module: rri('./pet'), + name: 'Pet', + }, + }, + }, + }, + 'owner.gts': ` + import { CardDef, StringField, field, contains, linksTo } from '@cardstack/base/card-api'; + import { Person } from './person'; + export class Owner extends CardDef { + static displayName = "Owner"; + @field friend = linksTo(Person); + @field friendName = contains(StringField, { + computeVia: function() { + return this.friend.name; + } + }); + } + `, + 'stray.json': { + data: { + attributes: {}, + relationships: { + friend: { + links: { + self: 'http://localhost:9000/link-to-nowhere', + }, + }, + }, + meta: { + adoptsFrom: { + module: rri('./owner'), + name: 'Owner', + }, + }, + }, + }, }, }, ], @@ -7748,5 +7812,218 @@ module(basename(import.meta.filename), function () { }); assert.true(result.pool.reused, 'second visit reused the pooled page'); }); + + // Icon HTML is a pure function of the card's type (the type's static + // `icon` component), so an indexing job renders each type's icon once: + // the first index visit for a type renders it and every later visit of + // that type reuses the captured markup, skipping the icon route. The + // memo is scoped to the visit's jobId; visits without one (on-demand + // renders) always render the icon and never touch the memo. + module('index-visit icon memo', function () { + let affinityKey = toAffinityKey({ + affinityType: 'realm', + affinityValue: realmURL, + }); + let indexVisit = (fileName: string, extra?: Record) => + prerenderer.prerenderVisit({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: `${realmURL}${fileName}`, + auth: auth(), + visitType: 'index', + renderOptions: { + cardRender: true, + fileExtract: true, + fileRender: true, + fileDefCodeRef: { + module: baseRRI('json-file-def'), + name: 'JsonFileDef', + }, + ...((extra?.renderOptions as object) ?? {}), + }, + ...(extra?.jobId ? { jobId: extra.jobId as string } : {}), + ...(extra?.batchId ? { batchId: extra.batchId as string } : {}), + }); + + test('one job renders each type icon once and reuses it', async function (assert) { + let jobId = 'icon-memo-reuse.1'; + let first = (await indexVisit('maple.json', { jobId })).response; + assert.notOk(first.card?.error, 'first visit completes cleanly'); + assert.ok( + first.card?.iconHTML?.startsWith('