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
48 changes: 46 additions & 2 deletions .claude/skills/indexing-diagnostics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,25 @@ WHERE timing_diagnostics->>'renderElapsedMs' IS NOT NULL
GROUP BY 1
ORDER BY p95_ms DESC NULLS LAST
LIMIT 20;

-- Rows where the render.meta computed-field traversal dominates the
-- render budget. `computedCalls` + the host-side `searchDocMs` /
-- `serializeMs` are emitted by the host route per row. Use these to
-- find aggregate-style cards that are eating their render budget on
-- compute work rather than data loads or template stalls.
SELECT
url,
to_timestamp((timing_diagnostics->>'indexedAt')::bigint / 1000) AS indexed_at,
(timing_diagnostics->>'computedCalls')::int AS calls,
(timing_diagnostics->>'computedCacheHits')::int AS cache_hits,
(timing_diagnostics->>'serializeMs')::numeric AS serialize_ms,
(timing_diagnostics->>'searchDocMs')::numeric AS search_doc_ms,
(timing_diagnostics->>'renderElapsedMs')::int AS render_ms
FROM boxel_index
WHERE realm_url = 'https://localhost:4201/user/your-realm/'
AND timing_diagnostics->>'computedCalls' IS NOT NULL
ORDER BY (timing_diagnostics->>'computedCalls')::int DESC NULLS LAST
LIMIT 20;
```

## Mode C — a worker job is stuck or got rejected
Expand Down Expand Up @@ -735,7 +754,30 @@ WHERE timing_diagnostics->>'requestId' = '<request-id>';
],
"docsInFlight": 3, // legacy count, kept for rollback safety
"capturedDom": "<section data-prerender>…</section>",
"blockedTimerSummary": "Timers blocked during prerender: …"
"blockedTimerSummary": "Timers blocked during prerender: …",
"computedCalls": 187, // distinct `computeVia` invocations during this row's
// render.meta traversal (serializeCard + searchDoc combined).
// Host-emitted; pass-scoped memo elides repeated reads of
// the same `(instance, fieldName)` so this number reflects
// distinct compute work, not total field-access pressure.
// Absent on rows produced by host builds before CS-11208.
"computedCacheHits": 374, // repeated reads of the same `(instance, fieldName)`
// that hit the pass-scoped memo. `computedCalls +
// computedCacheHits` is the total computed-read pressure
// of the render.meta pass; the ratio tells you how much
// duplicate work the memo elided. A high `cacheHits`
// count relative to `calls` is normal for cards that
// serialize + searchDoc the same field (every contains /
// contains-many / links-to field does this).
"serializeMs": 42.1, // host-side wall-clock of `serializeCard(instance, {
// includeComputeds: true })` for this card.
"searchDocMs": 18.3 // host-side wall-clock of `searchDoc(instance)` for
// this card. Sum with `serializeMs` to get the host's
// contribution to `renderElapsedMs`. Pairs with
// `computedCalls` so you can normalize: a card with
// `computedCalls=500, searchDocMs=80` is ~6 calls/ms
// — a sign of a hot compute that may be worth a
// dependency-aware skip.
}
```

Expand All @@ -752,6 +794,7 @@ All ms values are server-observed walltime.
- `recentQueryLoads[*].ms` is the wall time a completed query-field/search load ultimately took. The store keeps a bounded top-N so even queries that resolved just before the timer fired stay visible. Compare with `renderElapsedMs` to see which fraction of the render budget went to query work.
- `cardDocLoadsInFlight[*].ageMs` / `fileMetaDocLoadsInFlight[*].ageMs` mirror the query version for linked-field (card doc) / file-meta loads. One URL with a very large `ageMs` = one slow linksTo target; many URLs with small `ageMs` = fan-out.
- `recentCardDocLoads[*].ms` / `recentFileMetaLoads[*].ms` are the completed-load histories; same usage as `recentQueryLoads`.
- `computedCalls` + `computedCacheHits` together represent total compute pressure on the render.meta pass. The split tells you how much duplicate work the pass-scoped memo absorbed — a 1:0 ratio means every field was read once, a 1:5 ratio means the cards re-read each computed five extra times (typical for cards where many sibling fields share a computed input). `searchDocMs` + `serializeMs` are the host's contribution to `renderElapsedMs`; comparing `computedCalls / (searchDocMs + serializeMs)` across cards finds the slow-per-call computes that are worth profiling.

Keep the field names in lock-step with the type in `packages/runtime-common/index.ts`.

Expand All @@ -774,6 +817,7 @@ Walk the fields top-down. The _first_ positive signal wins; stop there.
| `renderStage` = `waiting-stability` with empty in-flight arrays | **Render stall** | Nothing is loading but settlement never finishes. Classic Glimmer tracking loop — template is invalidating itself. `capturedDom` usually shows the partially-rendered component. `blockedTimerSummary` will list swallowed timers that may hint at a scheduling loop. |
| `currentlyEvaluatingModule` non-null, or `stageAgeMs` large with empty in-flight arrays | **Synchronous browser stall (typically Glimmer compile during module eval)** | `recentModuleEvaluations` shows the worst offenders. A single URL with `ms > 5000` usually means "this module has a giant template that takes forever to compile". Many small entries (say 50+ at 100–500 ms each) summing into the stall budget mean card fan-out where each dependent card contributes a compile. Split the module, lazy-load the template, or reduce the component fan-out. |
| `blockedTimerSummary` populated | **Supplementary** | Tells you which timer-driven code is fighting the render. Not a root cause on its own. |
| `computedCalls` large (e.g. > 1000) AND `searchDocMs + serializeMs` ≈ `renderElapsedMs` | **Computed-field hot path** | The render.meta traversal itself is the bottleneck, not data loads or browser stalls. Look at `computedCalls / (searchDocMs + serializeMs)` — > ~5 calls/ms is fast, < ~1 call/ms means a few slow `computeVia` functions dominate. Inspect the card class for aggregate computeds that scan a `linksToMany` relation on every read (Portfolio-over-Policies style) and consider hoisting the scan into a shared rollup or adding `computeDeps` so the field can be skipped when its inputs don't change. The pass-scoped memo already eliminates duplicate reads in one traversal (visible in `computedCacheHits`); further wins require structural changes to the card. |

### Special cases

Expand Down Expand Up @@ -935,7 +979,7 @@ Slot-by-slot:
| `realms-staging.stack.cards` | `https://boxel-host-staging.stack.cards` |
| `realms.stack.cards` | `https://boxel-host.stack.cards` |
| `realm-server.<slug>.localhost` | `http://host.<slug>.localhost` (BOXEL_ENVIRONMENT mode) |
| `localhost` or `*.localhost` (standard) | `https://localhost:4200` |
| `localhost` or `*.localhost` (standard) | `https://localhost:4200` |

If the realm host doesn't match any of these patterns, ask the user — don't guess. Constrain `realms-` matching to `*.stack.cards` so any future deployment using a `realms-` prefix on a different domain isn't silently mapped to a wrong (and possibly non-existent) host.

Expand Down
21 changes: 15 additions & 6 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ import {
} from './card-serialization';
import {
assertScalar,
beginComputePass,
endComputePass,
entangleWithCardTracking,
getDataBucket,
getFieldDescription,
Expand All @@ -169,6 +171,7 @@ import {
relationshipMeta,
setFieldDescription,
setRealmContextOnField,
type ComputePassSnapshot,
type NotLoadedValue,
} from './field-support';
import { TextInputValidator } from './text-input-validator';
Expand All @@ -189,6 +192,8 @@ interface CardOrFieldTypeIconSignature {
export type CardOrFieldTypeIcon = ComponentLike<CardOrFieldTypeIconSignature>;

export {
beginComputePass,
endComputePass,
deserialize,
getCardMeta,
getDataBucket,
Expand All @@ -210,6 +215,7 @@ export {
ensureQueryFieldSearchResource,
getStore,
type BoxComponent,
type ComputePassSnapshot,
type DeserializeOpts,
type GetMenuItemParams,
type JSONAPISingleResourceDocument,
Expand Down Expand Up @@ -989,7 +995,9 @@ class Contains<CardT extends FieldDefConstructor> implements Field<CardT, any> {
[this.name]: {
adoptsFrom: identifyCard(
this.card,
opts?.useAbsoluteURL ? undefined : opts?.maybeRelativeReference,
opts?.useAbsoluteURL
? undefined
: opts?.maybeRelativeReference,
),
},
},
Expand Down Expand Up @@ -2276,9 +2284,13 @@ export class BaseDef {
}
return [fieldName, { id: makeAbsoluteURL(rawValue.reference) }];
}
// Reuse the value we already peeked above instead of re-reading
// through the descriptor — for computed fields the descriptor
// get path re-invokes `computeVia`, doubling the work for every
// contains/contains-many/links-to field in the search doc.
return [
fieldName,
getQueryableValue(field!, value[fieldName], [value, ...stack]),
getQueryableValue(field!, rawValue, [value, ...stack]),
];
}),
);
Expand Down Expand Up @@ -4154,10 +4166,7 @@ export type SignatureFor<CardT extends BaseDefConstructor> = {
// mutated. Field invocations (field !== undefined) go through `fieldComponent`
// → `Box.field(name)` (already cached on the parent Box), so they bypass
// this cache.
const componentByModel = new WeakMap<
object,
Map<string, BoxComponent>
>();
const componentByModel = new WeakMap<object, Map<string, BoxComponent>>();

function codeRefCacheKey(codeRef: CodeRef | undefined): string {
return codeRef ? JSON.stringify(codeRef) : '';
Expand Down
65 changes: 65 additions & 0 deletions packages/base/field-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,49 @@ const fieldOverrides = initSharedState(
() => new WeakMap<BaseDef, Map<string, any>>(),
);

// Pass-scoped computed-field memo. When non-null, `getter` consults a
// per-instance Map before invoking `computeVia` and stores the result for
// the duration of the synchronous traversal that opened the pass (see
// `beginComputePass`). Off-pass reads pay only a single null check on this
// module local and follow the original path — the JIT branch-predicts the
// off-pass case in the host-UI hot loop.
let passComputeMemo: WeakMap<BaseDef, Map<string, any>> | null = null;
// Counters snapshotted by the render/meta route to populate
// `boxel_index.timing_diagnostics`. They are unconditional integer
// increments inside `getter` — cheap enough to keep on in production, but
// only meaningful between `beginComputePass`/`endComputePass`.
let computedCallCount = 0;
let computedCacheHitCount = 0;

export interface ComputePassSnapshot {
calls: number;
cacheHits: number;
}

// Open a synchronous compute-memo pass. Callers MUST pair this with
// `endComputePass()` and must not await between the two — the WeakMap
// would otherwise be observable across reactive cycles. Intended for
// pure traversals like `serializeCard` + `searchDoc` inside one
// `render.meta` capture.
export function beginComputePass(): void {
passComputeMemo = new WeakMap();
computedCallCount = 0;
computedCacheHitCount = 0;
}

// Close the pass and return the per-traversal counter delta. The memo
// is dropped so subsequent `getter` calls run `computeVia` fresh.
export function endComputePass(): ComputePassSnapshot {
passComputeMemo = null;
let snapshot = {
calls: computedCallCount,
cacheHits: computedCacheHitCount,
};
computedCallCount = 0;
computedCacheHitCount = 0;
return snapshot;
}

export function getter<CardT extends BaseDefConstructor>(
instance: BaseDef,
field: Field<CardT>,
Expand All @@ -63,10 +106,32 @@ export function getter<CardT extends BaseDefConstructor>(
cardTracking.get(instance);

if (field.computeVia) {
// Fast path when no pass is open: skip the counter + memo entirely
// so production reads pay only one branch on the module-local null
// check. JIT branch-predicts this and the original behaviour is
// unchanged.
if (passComputeMemo === null) {
let value = field.computeVia.bind(instance)();
if (value === undefined) {
value = field.emptyValue(instance);
}
return value as BaseInstanceType<CardT>;
}
let perInstance = passComputeMemo.get(instance);
if (perInstance && perInstance.has(field.name)) {
computedCacheHitCount++;
return perInstance.get(field.name);
}
computedCallCount++;
let value = field.computeVia.bind(instance)();
if (value === undefined) {
value = field.emptyValue(instance);
}
if (!perInstance) {
perInstance = new Map();
passComputeMemo.set(instance, perInstance);
}
perInstance.set(field.name, value);
return value as BaseInstanceType<CardT>;
} else {
if (deserialized.has(field.name)) {
Expand Down
Loading
Loading