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
170 changes: 170 additions & 0 deletions packages/realm-server/tests/load-links-batching-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { module, test } from 'qunit';
import { basename } from 'path';
import { rri } from '@cardstack/runtime-common';
import type {
DBAdapter,
LooseSingleCardDocument,
Realm,
} from '@cardstack/runtime-common';
import { setupPermissionedRealmCached } from './helpers';

const testRealm = new URL('http://127.0.0.1:4451/test/');
const NUM_SOURCES = 50;
const NUM_TARGETS = 5;

let testDbAdapter: DBAdapter;

function buildFileSystem(): Record<string, string | LooseSingleCardDocument> {
let fs: Record<string, string | LooseSingleCardDocument> = {};

fs['target.gts'] = `
import { contains, field, CardDef } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";

export class Target extends CardDef {
@field name = contains(StringField);
}
`;

fs['source.gts'] = `
import { contains, field, linksTo, CardDef } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";
import { Target } from "./target";

export class Source extends CardDef {
@field name = contains(StringField);
@field link0 = linksTo(() => Target);
@field link1 = linksTo(() => Target);
@field link2 = linksTo(() => Target);
@field link3 = linksTo(() => Target);
@field link4 = linksTo(() => Target);
}
`;

for (let i = 0; i < NUM_TARGETS; i++) {
fs[`target-${i}.json`] = {
data: {
attributes: { name: `Target ${i}` },
meta: {
adoptsFrom: {
module: rri('./target'),
name: 'Target',
},
},
},
} as LooseSingleCardDocument;
}

for (let i = 0; i < NUM_SOURCES; i++) {
let relationships: Record<string, { links: { self: string } }> = {};
for (let j = 0; j < NUM_TARGETS; j++) {
relationships[`link${j}`] = { links: { self: `./target-${j}` } };
}
fs[`source-${i}.json`] = {
data: {
attributes: { name: `Source ${i}` },
relationships,
meta: {
adoptsFrom: {
module: rri('./source'),
name: 'Source',
},
},
},
} as LooseSingleCardDocument;
}

return fs;
}

// CS-11038 regression test: loadLinks must batch in-realm link resolution
// rather than issuing one DB round-trip per relationship. With 50 source
// cards each linking to 5 targets, the original implementation would have
// fired 250 sequential `WHERE i.url = $1` lookups. The new BFS path issues
// one batched `WHERE i.url IN (...)` per recursion depth.
module(basename(__filename), function () {
module('loadLinks batching', function (hooks) {
let realm: Realm;

setupPermissionedRealmCached(hooks, {
mode: 'before',
realmURL: testRealm,
permissions: { '*': ['read'] },
fileSystem: buildFileSystem(),
onRealmSetup({ dbAdapter, testRealm: r }) {
testDbAdapter = dbAdapter;
realm = r;
},
});

test(`searchCards with loadLinks issues 1 batched DB query per recursion depth (${NUM_SOURCES} cards × ${NUM_TARGETS} links)`, async function (assert) {
let originalExecute = testDbAdapter.execute.bind(testDbAdapter);
let perLinkLookupCount = 0;
let batchedLinkLookupCount = 0;
let dbExecute = testDbAdapter as {
execute: typeof testDbAdapter.execute;
};

try {
dbExecute.execute = async (sql, opts) => {
// `param('instance')` becomes a `$N` placeholder in the rendered
// SQL — we have to look at opts.bind to see whether this query
// is filtering for instance rows.
let bind = opts?.bind ?? [];
let normalized = sql.replace(/\s+/g, ' ');
let isBoxelIndexInstanceLookup =
/FROM boxel_index\b/.test(normalized) &&
bind.some((v) => v === 'instance');
if (isBoxelIndexInstanceLookup) {
// Old per-link path: WHERE i.url = $1 OR i.file_alias = $1
// New batched path: WHERE i.url IN ($1, ..., $N) OR i.file_alias IN (...)
if (/\bi\.url\s+IN\s*\(/.test(normalized)) {
batchedLinkLookupCount++;
} else if (/\bi\.url\s*=\s*\$/.test(normalized)) {
perLinkLookupCount++;
}
}
return originalExecute(sql, opts);
};

let result = await realm.realmIndexQueryEngine.searchCards(
{
filter: {
type: { module: rri(`${testRealm}source`), name: 'Source' },
},
},
{ loadLinks: true },
);

assert.strictEqual(
result.data.length,
NUM_SOURCES,
`search returned all ${NUM_SOURCES} source cards`,
);
assert.ok(result.included, 'included is present');
let includedCount = result.included?.length ?? 0;
assert.strictEqual(
includedCount,
NUM_TARGETS,
`included contains all ${NUM_TARGETS} unique targets`,
);

assert.strictEqual(
perLinkLookupCount,
0,
`expected 0 per-link DB lookups (the old N×M path), got ${perLinkLookupCount}`,
);
assert.ok(
batchedLinkLookupCount > 0,
`expected at least 1 batched-link DB query, got ${batchedLinkLookupCount}`,
);
assert.ok(
batchedLinkLookupCount <= 2,
`expected ≤ 2 batched-link DB queries (1 per recursion depth), got ${batchedLinkLookupCount}`,
);
} finally {
dbExecute.execute = originalExecute;
}
});
});
});
132 changes: 126 additions & 6 deletions packages/runtime-common/index-query-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,72 @@ export class IndexQueryEngine {
['i.type =', param('instance')],
any([['i.is_deleted = FALSE'], ['i.is_deleted IS NULL']]),
]),
] as Expression)) as unknown as (BoxelIndexTable & {
default_embedded_html: string | null;
})[];
let maybeResult: BoxelIndexTable | undefined = result[0];
] as Expression)) as unknown as BoxelIndexTable[];
return this.#rowToInstanceOrError(result[0], url, opts);
}

// Batch variant of getInstance: one DB round-trip for many URLs.
// Returns a map keyed by the LOOKUP URL (matching either i.url or i.file_alias)
// so callers can address results by the URL they passed in.
async getInstances(
urls: URL[],
opts?: GetEntryOptions,
): Promise<Map<string, InstanceOrError>> {
let resultMap = new Map<string, InstanceOrError>();
if (urls.length === 0) {
return resultMap;
}
let lookupHrefs = [...new Set(urls.map((u) => u.href))];
// Each chunk emits 2*N + 1 placeholders (url IN list + file_alias IN list
// + i.type param). Cap at half the existing url-batch sizes used in
// index-writer.ts (sqlite=900, pg=5000) so we stay well under both
// adapter parameter limits.
let chunkSize = this.#dbAdapter.kind === 'sqlite' ? 450 : 2500;
for (let start = 0; start < lookupHrefs.length; start += chunkSize) {
let chunk = lookupHrefs.slice(start, start + chunkSize);
let chunkSet = new Set(chunk);
let chunkParams = chunk.map((href) => [param(href)]);
let rows = (await this.#query([
`SELECT i.*, embedded_html, fitted_html`,
`FROM ${tableFromOpts(opts)} as i
WHERE`,
...every([
any([
['i.url IN', ...addExplicitParens(separatedByCommas(chunkParams))],
[
'i.file_alias IN',
...addExplicitParens(separatedByCommas(chunkParams)),
],
]),
['i.type =', param('instance')],
any([['i.is_deleted = FALSE'], ['i.is_deleted IS NULL']]),
]),
] as Expression)) as unknown as BoxelIndexTable[];
for (let row of rows) {
let mapped = this.#rowToInstanceOrError(row, undefined, opts);
if (!mapped) {
continue;
}
// A row may be addressable by its url or its file_alias.
// Index the result under whichever lookup keys the caller asked about.
if (row.url && chunkSet.has(row.url)) {
resultMap.set(row.url, mapped);
}
if (row.file_alias && chunkSet.has(row.file_alias)) {
resultMap.set(row.file_alias, mapped);
}
}
}
return resultMap;
}

// Shared row → InstanceOrError mapping for getInstance / getInstances.
// `lookupURL` is used only for error context and is optional in the batch path.
#rowToInstanceOrError(
maybeResult: BoxelIndexTable | undefined,
lookupURL: URL | undefined,
opts?: GetEntryOptions,
): InstanceOrError | undefined {
if (!maybeResult) {
return undefined;
}
Expand Down Expand Up @@ -263,7 +325,9 @@ export class IndexQueryEngine {
let instanceEntry = assertIndexEntry(maybeResult);
if (!instance) {
throw new Error(
`bug: index entry for ${url.href} with opts: ${stringify(
`bug: index entry for ${
lookupURL?.href ?? canonicalURL
} with opts: ${stringify(
opts,
)} has neither an error_doc nor a pristine_doc`,
);
Expand Down Expand Up @@ -298,7 +362,63 @@ export class IndexQueryEngine {
any([['i.is_deleted = FALSE'], ['i.is_deleted IS NULL']]),
]),
] as Expression)) as unknown as BoxelIndexTable[];
let maybeResult: BoxelIndexTable | undefined = result[0];
return this.#rowToIndexedFile(result[0]);
}

// Batch variant of getFile.
// Keys are the LOOKUP URLs the caller passed in (matching either i.url or i.file_alias).
async getFiles(
urls: URL[],
opts?: GetEntryOptions,
): Promise<Map<string, IndexedFile>> {
let resultMap = new Map<string, IndexedFile>();
if (urls.length === 0) {
return resultMap;
}
let lookupHrefs = [...new Set(urls.map((u) => u.href))];
// Same chunking discipline as getInstances — keeps placeholder count
// safely below the sqlite/pg parameter limits.
let chunkSize = this.#dbAdapter.kind === 'sqlite' ? 450 : 2500;
for (let start = 0; start < lookupHrefs.length; start += chunkSize) {
let chunk = lookupHrefs.slice(start, start + chunkSize);
let chunkSet = new Set(chunk);
let chunkParams = chunk.map((href) => [param(href)]);
let rows = (await this.#query([
`SELECT i.*`,
`FROM ${tableFromOpts(opts)} as i
WHERE`,
...every([
any([
['i.url IN', ...addExplicitParens(separatedByCommas(chunkParams))],
[
'i.file_alias IN',
...addExplicitParens(separatedByCommas(chunkParams)),
],
]),
['i.type =', param('file')],
any([['i.has_error = FALSE'], ['i.has_error IS NULL']]),
any([['i.is_deleted = FALSE'], ['i.is_deleted IS NULL']]),
]),
] as Expression)) as unknown as BoxelIndexTable[];
for (let row of rows) {
let mapped = this.#rowToIndexedFile(row);
if (!mapped) {
continue;
}
if (row.url && chunkSet.has(row.url)) {
resultMap.set(row.url, mapped);
}
if (row.file_alias && chunkSet.has(row.file_alias)) {
resultMap.set(row.file_alias, mapped);
}
}
}
return resultMap;
}

#rowToIndexedFile(
maybeResult: BoxelIndexTable | undefined,
): IndexedFile | undefined {
if (!maybeResult) {
return undefined;
}
Expand Down
Loading
Loading