Skip to content

Commit a42e25f

Browse files
authored
Merge pull request #5128 from cardstack/cs-11393-skip-loadlinks-prerender-searches
Skip loadLinks for prerender-issued searches; remove dead per-instance cache
2 parents fe89b87 + b16596c commit a42e25f

14 files changed

Lines changed: 304 additions & 834 deletions

.claude/skills/indexing-diagnostics/SKILL.md

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -812,35 +812,30 @@ It is **not** persisted in `boxel_index.diagnostics` (that's the client side). S
812812
**The lines.**
813813

814814
```
815-
corr=<id> job=<jobId> handler=Nms parse=… resolveRealms=… sql=… loadLinks=… stringify=… coalescedWait=… | busyMs(parallel-sum) populate=… cacheRead=… cacheWrite=… | results=… cacheHit=… cacheMiss=… (realm:search-timing)
815+
corr=<id> job=<jobId> handler=Nms parse=… resolveRealms=… sql=… stringify=… coalescedWait=… | results=… (realm:search-timing)
816816
--> QUERY <accept> <url>: 200 [job: <jobId>] corr=<id> dur=Nms (realm:requests)
817817
eventLoopLagMs(mean/p99/max)=…/…/… inFlightSearch=… heapMB=… (realm:health)
818818
```
819819

820-
The first `|`-section is the **sequential wall-clock timeline** (these sum to ≈ `handler`). The `busyMs(parallel-sum)` section is the inner per-result work that runs **concurrently** inside `loadLinks`'s `Promise.all`, so it is summed across N parallel ops and is **NOT wall-clock**`populate` can read as millions of ms on a large result set. Read it as a **ratio** (which sub-step dominates within `loadLinks`) or divide by `results` for a per-item average; never add it to the timeline.
820+
The `|`-section is the **sequential wall-clock timeline** (these sum to ≈ `handler`).
821+
822+
**Note — a prerender search skips `loadLinks`.** These lines emit only for prerender `_federated-search` (the correlation id is prerender-gated), and a prerender search skips the relationship-assembly pass: the host re-resolves every result from card+source and reads only `data[].id`, so the realm-server returns each result's pristine row (id + attributes + any static-link relationships) + page meta and does not run `populateQueryFields`. The line therefore carries **no `loadLinks` stage and no `busyMs(parallel-sum)` section** (no `populate` / `cacheRead` / `cacheWrite` / `cacheHit` / `cacheMiss`) — those are the per-result umbrella assembly + per-instance wire-format cache, which do not run on this path. The dominant cost on a prerender search is `sql` (or `stringify` for a fat result set, or queue-wait — see branch 4).
821823

822824
Wall-clock timeline stages:
823825

824826
- `parse` — request body → Query parse.
825827
- `resolveRealms` — federated realm resolution / lazy-mount.
826-
- `sql` — the `IndexQueryEngine.searchCards` query (the actual SQL).
827-
- `loadLinks` — the post-SQL relationship-assembly pass over the whole result set (the wall-clock of the parallel `populate`/cache work below).
828-
- `stringify``JSON.stringify` of the response wire-format.
829-
- `coalescedWait` — this request coalesced onto an already-in-flight identical search (CS-11121 dedup) and waited for it; the real sql/loadLinks work is on the **leader's** line, not this one.
828+
- `sql` — the `IndexQueryEngine.searchCards` query (the actual SQL). For a prerender search this is essentially the whole `handler`.
829+
- `stringify``JSON.stringify` of the response wire-format (for a prerender search, the pristine result rows + page meta, with no query-field umbrellas or `included[]`).
830+
- `coalescedWait` — this request coalesced onto an already-in-flight identical search (in-flight dedup) and waited for it; the real sql work is on the **leader's** line, not this one.
830831
- `handler=` — handler entry → response assembled (≈ the sum of the wall-clock stages).
831832

832-
Busy-time (parallel-sum, NOT wall-clock):
833-
834-
- `populate` — the per-instance query-field assembly (definition lookup + field-tree walk), summed across all results.
835-
- `cacheRead` / `cacheWrite` (+ `cacheHit` / `cacheMiss` counters) — the per-instance wire-format cache (`job_scoped_instance_cache`) round-trips, summed across all results.
836-
837833
**Reading it — the decision tree.**
838834

839-
1. **`sql=` is the bulk of `handler`** → a genuinely slow query. Rare here (the premise is fast SQL); confirm with `pg_stat_activity` / `EXPLAIN`. Fast SQL but large `sql=` means lock / connection-pool wait inside the query call.
840-
2. **`loadLinks` dominates `handler`** → the post-SQL relationship/query-field assembly is the cost (the per-instance wire-format work). Use the `busyMs(parallel-sum)` ratio to see what _inside_ loadLinks dominates: `populate ≫ cacheRead/cacheWrite` means it's the CPU field-walk, not the cache DB round-trips. Check `cacheHit` / `cacheMiss`: a low hit rate means the instance cache isn't helping (e.g. unique-per-card queries) so every result is assembled from scratch.
841-
3. **`stringify` dominates** → serializing a large federated response. Look at `results=` for a fat result set.
842-
4. **`dur` (realm:requests) ≫ `handler` (realm:search-timing)** → the time is NOT inside the handler. It was spent **queued before the handler ran** (or sending). This is the saturation fingerprint: cross-reference `realm:health` near the same timestamp — if `eventLoopLagMs` spiked into the hundreds/thousands with `inFlightSearch` high, the single-threaded realm-server's event loop was starved (synchronous post-SQL serialization across many concurrent searches), so the request sat unserviced even though, once it ran, the handler was fast. That is the CS-10820 saturation class seen from the server side.
843-
5. **`coalescedWait` dominates** → this follower waited on another in-flight identical search. Find the leader (same job + query, overlapping time); its line carries the real breakdown.
835+
1. **`sql=` is the bulk of `handler`** → a genuinely slow query. Confirm with `pg_stat_activity` / `EXPLAIN`. Fast SQL but large `sql=` means lock / connection-pool wait inside the query call. (For a prerender search this is the typical shape — the post-SQL assembly doesn't run on that path, so `sql` carries most of the handler.)
836+
2. **`stringify` dominates** → serializing a large federated response. Look at `results=` for a fat result set.
837+
3. **`dur` (realm:requests) ≫ `handler` (realm:search-timing)** → the time is NOT inside the handler. It was spent **queued before the handler ran** (or sending). This is the saturation fingerprint: cross-reference `realm:health` near the same timestamp — if `eventLoopLagMs` spiked into the hundreds/thousands with `inFlightSearch` high, the single-threaded realm-server's event loop was starved, so the request sat unserviced even though, once it ran, the handler was fast. That is the CS-10820 saturation class seen from the server side.
838+
4. **`coalescedWait` dominates** → this follower waited on another in-flight identical search. Find the leader (same job + query, overlapping time); its line carries the real breakdown.
844839

845840
**Getting the logs.** These emit from the **realm-server** process (`handle-search``searchRealms`). Reach them with the `tail-logs` skill (Loki, realm-server family) or, for staging/prod CloudWatch, the `aws-access` skill:
846841

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
-- This is auto-generated by packages/realm-server/scripts/convert-to-sqlite.ts
2+
-- Please don't directly modify this file
3+
4+
CREATE TABLE IF NOT EXISTS bot_commands (
5+
id NOT NULL,
6+
bot_id NOT NULL,
7+
command TEXT NOT NULL,
8+
command_filter BLOB NOT NULL,
9+
created_at NOT NULL,
10+
PRIMARY KEY ( id )
11+
);
12+
13+
CREATE TABLE IF NOT EXISTS bot_registrations (
14+
id NOT NULL,
15+
username TEXT NOT NULL,
16+
created_at NOT NULL,
17+
PRIMARY KEY ( id )
18+
);
19+
20+
CREATE TABLE IF NOT EXISTS boxel_index (
21+
url TEXT NOT NULL,
22+
file_alias TEXT NOT NULL,
23+
type TEXT NOT NULL,
24+
realm_version INTEGER NOT NULL,
25+
realm_url TEXT NOT NULL,
26+
pristine_doc BLOB,
27+
search_doc BLOB,
28+
error_doc BLOB,
29+
deps BLOB DEFAULT '[]',
30+
types BLOB,
31+
isolated_html TEXT,
32+
indexed_at,
33+
is_deleted BOOLEAN,
34+
last_modified,
35+
embedded_html BLOB,
36+
atom_html TEXT,
37+
fitted_html BLOB,
38+
display_names BLOB,
39+
resource_created_at,
40+
icon_html TEXT,
41+
head_html TEXT,
42+
has_error BOOLEAN DEFAULT false NOT NULL,
43+
last_known_good_deps BLOB,
44+
markdown TEXT,
45+
diagnostics BLOB,
46+
PRIMARY KEY ( url, realm_url, type )
47+
);
48+
49+
CREATE TABLE IF NOT EXISTS boxel_index_working (
50+
url TEXT NOT NULL,
51+
file_alias TEXT NOT NULL,
52+
type TEXT NOT NULL,
53+
realm_version INTEGER NOT NULL,
54+
realm_url TEXT NOT NULL,
55+
pristine_doc BLOB,
56+
search_doc BLOB,
57+
error_doc BLOB,
58+
deps BLOB DEFAULT '[]',
59+
types BLOB,
60+
icon_html TEXT,
61+
isolated_html TEXT,
62+
indexed_at,
63+
is_deleted BOOLEAN,
64+
last_modified,
65+
embedded_html BLOB,
66+
atom_html TEXT,
67+
fitted_html BLOB,
68+
display_names BLOB,
69+
resource_created_at,
70+
head_html TEXT,
71+
has_error BOOLEAN DEFAULT false NOT NULL,
72+
last_known_good_deps BLOB,
73+
markdown TEXT,
74+
diagnostics BLOB,
75+
job_id INTEGER,
76+
PRIMARY KEY ( url, realm_url, type )
77+
);
78+
79+
CREATE TABLE IF NOT EXISTS incoming_webhooks (
80+
id NOT NULL,
81+
username TEXT NOT NULL,
82+
webhook_path TEXT NOT NULL,
83+
verification_type TEXT NOT NULL,
84+
verification_config BLOB NOT NULL,
85+
signing_secret TEXT NOT NULL,
86+
created_at NOT NULL,
87+
updated_at NOT NULL,
88+
PRIMARY KEY ( id )
89+
);
90+
91+
CREATE TABLE IF NOT EXISTS module_transpile_cache (
92+
realm_url TEXT NOT NULL,
93+
canonical_path TEXT NOT NULL,
94+
body TEXT,
95+
headers BLOB,
96+
dependency_keys BLOB,
97+
generation DEFAULT 0 NOT NULL,
98+
created_at,
99+
PRIMARY KEY ( realm_url, canonical_path )
100+
);
101+
102+
CREATE TABLE IF NOT EXISTS modules (
103+
url TEXT NOT NULL,
104+
cache_scope TEXT NOT NULL,
105+
auth_user_id TEXT NOT NULL,
106+
resolved_realm_url TEXT NOT NULL,
107+
definitions BLOB,
108+
deps BLOB,
109+
error_doc BLOB,
110+
created_at,
111+
file_alias TEXT,
112+
url_hash TEXT GENERATED ALWAYS AS (url) STORED NOT NULL,
113+
diagnostics BLOB,
114+
PRIMARY KEY ( url, cache_scope, auth_user_id )
115+
);
116+
117+
CREATE TABLE IF NOT EXISTS realm_file_meta (
118+
realm_url TEXT NOT NULL,
119+
file_path TEXT NOT NULL,
120+
created_at INTEGER NOT NULL,
121+
content_hash TEXT,
122+
content_size INTEGER,
123+
PRIMARY KEY ( realm_url, file_path )
124+
);
125+
126+
CREATE TABLE IF NOT EXISTS realm_meta (
127+
realm_url TEXT NOT NULL,
128+
realm_version INTEGER NOT NULL,
129+
value BLOB NOT NULL,
130+
indexed_at,
131+
PRIMARY KEY ( realm_url, realm_version )
132+
);
133+
134+
CREATE TABLE IF NOT EXISTS realm_metadata (
135+
url TEXT NOT NULL,
136+
show_as_catalog BOOLEAN,
137+
publishable BOOLEAN,
138+
created_at DEFAULT CURRENT_TIMESTAMP NOT NULL,
139+
updated_at DEFAULT CURRENT_TIMESTAMP NOT NULL,
140+
PRIMARY KEY ( url )
141+
);
142+
143+
CREATE TABLE IF NOT EXISTS realm_registry (
144+
id DEFAULT (hex(randomblob(16))) NOT NULL,
145+
url TEXT NOT NULL,
146+
kind TEXT NOT NULL,
147+
disk_id TEXT NOT NULL,
148+
owner_username TEXT NOT NULL,
149+
source_url TEXT,
150+
last_published_at,
151+
pinned BOOLEAN DEFAULT false NOT NULL,
152+
created_at DEFAULT CURRENT_TIMESTAMP NOT NULL,
153+
updated_at DEFAULT CURRENT_TIMESTAMP NOT NULL,
154+
PRIMARY KEY ( id )
155+
);
156+
157+
CREATE TABLE IF NOT EXISTS realm_user_permissions (
158+
realm_url TEXT NOT NULL,
159+
username TEXT NOT NULL,
160+
read BOOLEAN NOT NULL,
161+
write BOOLEAN NOT NULL,
162+
realm_owner BOOLEAN DEFAULT false NOT NULL,
163+
PRIMARY KEY ( realm_url, username )
164+
);
165+
166+
CREATE TABLE IF NOT EXISTS realm_versions (
167+
realm_url TEXT NOT NULL,
168+
current_version INTEGER NOT NULL,
169+
PRIMARY KEY ( realm_url )
170+
);
171+
172+
CREATE TABLE IF NOT EXISTS webhook_commands (
173+
id NOT NULL,
174+
incoming_webhook_id NOT NULL,
175+
command TEXT NOT NULL,
176+
command_filter BLOB,
177+
created_at NOT NULL,
178+
updated_at NOT NULL,
179+
PRIMARY KEY ( id )
180+
);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
exports.shorthands = undefined;
2+
3+
// Drop the job-scoped per-instance wire-format cache. Prerender `_federated-
4+
// search` no longer runs the `loadLinks` relationship-assembly pass at all (it
5+
// returns the matching result ids and the host re-resolves each card from
6+
// card+source), so the only code that ever read or wrote this table is gone.
7+
// Nothing else references it — it would otherwise be a table, a janitor timer,
8+
// and a NOTIFY-listener participant that no path touches.
9+
exports.up = (pgm) => {
10+
pgm.sql(`DROP TABLE IF EXISTS job_scoped_instance_cache;`);
11+
};
12+
13+
// Recreate the table as it was (UNLOGGED cache keyed by `<jobId>.<reservationId>`
14+
// + instance url) so the migration is reversible. node-pg-migrate's createTable
15+
// can't express UNLOGGED, so build it with raw SQL.
16+
exports.down = (pgm) => {
17+
pgm.sql(`
18+
CREATE UNLOGGED TABLE job_scoped_instance_cache (
19+
job_id varchar NOT NULL,
20+
url varchar NOT NULL,
21+
result text NOT NULL,
22+
created_at timestamptz NOT NULL DEFAULT NOW(),
23+
PRIMARY KEY (job_id, url)
24+
);
25+
CREATE INDEX job_scoped_instance_cache_created_at_idx
26+
ON job_scoped_instance_cache (created_at);
27+
`);
28+
};

packages/realm-server/handlers/handle-search.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,14 @@ export default function handleSearch(opts: {
7979
}
8080

8181
let cacheOnlyDefinitions = ctxt.get(DURING_PRERENDER_HEADER).length > 0;
82-
// Inside a prerender, leave `relationships.{field}.data` populated
83-
// for query-backed `linksTo` / `linksToMany` but skip transitive
84-
// expansion into `included[]`. The host's prerender-mode getter
85-
// resolves the listed IDs from its seed and skips the live
86-
// re-query, so the eager closure is a wasted round-trip in this
87-
// path. Same gating as `cacheOnlyDefinitions`.
88-
let skipQueryBackedExpansion = cacheOnlyDefinitions;
89-
// Inside a prerender the host never reads the response's
90-
// `included[]` — it resolves every linked card by URL via
91-
// card+source (query fields from the seed umbrella, static links via
92-
// a lazy-loading `not-loaded` sentinel). Omit `included[]` entirely:
93-
// seed the root result cards and skip the static-link BFS that builds
94-
// it. Same gating as `cacheOnlyDefinitions`.
82+
// Inside a prerender the search skips the `loadLinks`
83+
// relationship-assembly pass entirely: the host re-resolves every
84+
// result card from its raw card+source file and reads only
85+
// `data[].id`, so the query-field `relationships.{field}.data`
86+
// umbrellas and the transitive `included[]` are throwaway work
87+
// here. The response still carries each result's pristine row (id +
88+
// attributes + static-link relationships) and page meta — just no
89+
// umbrellas and no `included[]`. Same gating as `cacheOnlyDefinitions`.
9590
let omitIncluded = cacheOnlyDefinitions;
9691
// The host's `_federated-search` fetch wrapper stamps
9792
// `x-boxel-job-priority` while rendering inside a prerender tab.
@@ -105,25 +100,20 @@ export default function handleSearch(opts: {
105100
ctxt.get(PRERENDER_JOB_PRIORITY_HEADER),
106101
);
107102
// `<jobId>.<reservationId>` identity stamped by indexer-driven prerender
108-
// requests. Threaded into searchOpts so the per-instance wire-format cache
109-
// (`job_scoped_instance_cache`, consulted inside `loadLinks`) scopes its
110-
// entries to one indexing job; also reused below as the query-level
111-
// cache's job key. Absent for live / external callers.
103+
// requests; used below as the job-scoped search cache's job key (the
104+
// whole-doc `_federated-search` response cache). Absent for live /
105+
// external callers, which therefore bypass the cache.
112106
let prerenderJobId = sanitizePrerenderJobId(
113107
ctxt.get(PRERENDER_JOB_ID_HEADER),
114108
);
115109
let searchOpts: {
116110
cacheOnlyDefinitions?: true;
117-
skipQueryBackedExpansion?: true;
118111
omitIncluded?: true;
119112
priority?: number;
120-
jobIdentity?: string;
121113
} = {};
122114
if (cacheOnlyDefinitions) searchOpts.cacheOnlyDefinitions = true;
123-
if (skipQueryBackedExpansion) searchOpts.skipQueryBackedExpansion = true;
124115
if (omitIncluded) searchOpts.omitIncluded = true;
125116
if (jobPriority !== null) searchOpts.priority = jobPriority;
126-
if (prerenderJobId) searchOpts.jobIdentity = prerenderJobId;
127117
let normalizedSearchOpts =
128118
Object.keys(searchOpts).length > 0 ? searchOpts : undefined;
129119
// `loggingCorrelationId` / `timings` are deliberately kept OUT of `searchOpts`:

0 commit comments

Comments
 (0)