Avoid repeated category scans while filtering the paginated Live catalog, and keep the search timing and query-plan reproductions in the regression suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
176 lines
9.0 KiB
JavaScript
176 lines
9.0 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { performance } = require('node:perf_hooks');
|
|
const Database = require('better-sqlite3');
|
|
const { parseQuery, getLiveCatalog, ensureSearch } = require('../server/services/liveCatalog');
|
|
|
|
const RUN = process.env.NODECAST_LIVE_SEARCH_SHAPE_REPRO === '1';
|
|
const EXPECTED_LIVE = 38617;
|
|
const EXPECTED_PLAYLIST = 66877;
|
|
const EXPECTED_CATEGORIES = 1099;
|
|
const INDEXED_LIMIT_MS = 1000;
|
|
|
|
function createFixtureDatabase() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-live-search-shape-'));
|
|
const db = new Database(path.join(dir, 'content.db'));
|
|
db.pragma('journal_mode = OFF');
|
|
db.exec(`
|
|
CREATE TABLE categories (
|
|
id TEXT PRIMARY KEY, source_id INTEGER NOT NULL, category_id TEXT NOT NULL,
|
|
type TEXT NOT NULL, name TEXT NOT NULL, is_hidden INTEGER DEFAULT 0
|
|
);
|
|
CREATE INDEX idx_categories_source_type ON categories(source_id, type);
|
|
CREATE TABLE playlist_items (
|
|
id TEXT PRIMARY KEY, source_id INTEGER NOT NULL, item_id TEXT NOT NULL,
|
|
type TEXT NOT NULL, name TEXT NOT NULL, category_id TEXT, stream_url TEXT,
|
|
is_hidden INTEGER DEFAULT 0, data JSON
|
|
);
|
|
CREATE INDEX idx_items_source_type ON playlist_items(source_id, type);
|
|
CREATE INDEX idx_items_category ON playlist_items(source_id, category_id);
|
|
CREATE INDEX idx_live_catalog_order ON playlist_items(name COLLATE NOCASE, source_id, id) WHERE type='live';
|
|
CREATE TABLE favorites (
|
|
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, source_id INTEGER NOT NULL,
|
|
item_id TEXT NOT NULL, item_type TEXT NOT NULL
|
|
);
|
|
`);
|
|
|
|
const insertCategory = db.prepare(`INSERT INTO categories (id, source_id, category_id, type, name, is_hidden)
|
|
VALUES (?, 1, ?, 'live', ?, 0)`);
|
|
const insertItem = db.prepare(`INSERT INTO playlist_items
|
|
(id, source_id, item_id, type, name, category_id, stream_url, is_hidden, data)
|
|
VALUES (?, 1, ?, ?, ?, ?, 'http://fixture.invalid/stream', 0, ?)`);
|
|
const typicalData = JSON.stringify({ padding: 'x'.repeat(370) });
|
|
const largestData = JSON.stringify({ padding: 'x'.repeat(17870) });
|
|
db.transaction(() => {
|
|
for (let i = 0; i < EXPECTED_CATEGORIES; i++) {
|
|
const categoryId = `group-${i}`;
|
|
insertCategory.run(`1:${categoryId}`, categoryId,
|
|
i < 5 ? `News Group ${i}` : `Bouquet ${i}`);
|
|
}
|
|
for (let i = 0; i < EXPECTED_PLAYLIST; i++) {
|
|
const isLive = i < EXPECTED_LIVE;
|
|
const offset = isLive ? i : i - EXPECTED_LIVE;
|
|
const itemType = isLive ? 'live' : 'movie';
|
|
const itemId = `${itemType}-${offset}`;
|
|
const hasNews = isLive ? i < 549 : offset < 89;
|
|
const name = hasNews ? `News ${isLive ? 'Channel' : 'Movie'} ${offset}`
|
|
: `${isLive ? 'Channel' : 'Movie'} Catalog ${offset}`;
|
|
const categoryId = `group-${offset % EXPECTED_CATEGORIES}`;
|
|
insertItem.run(`1:${itemId}`, itemId, itemType, name, categoryId,
|
|
i === 0 ? largestData : typicalData);
|
|
}
|
|
})();
|
|
return { dir, db };
|
|
}
|
|
|
|
function summarizePlan(planRows) {
|
|
const tables = {};
|
|
const indexes = [];
|
|
let tempBtreeForOrder = false;
|
|
let categoryCompositeLookup = false;
|
|
for (const row of planRows) {
|
|
const detail = String(row.detail || '').toUpperCase();
|
|
if (detail.includes('USE TEMP B-TREE FOR ORDER BY')) tempBtreeForOrder = true;
|
|
const match = detail.match(/(?:SCAN|SEARCH) ([A-Z_]+)/);
|
|
if (!match) continue;
|
|
const alias = match[1].toLowerCase();
|
|
const indexMatch = detail.match(/USING (?:COVERING )?INDEX ([A-Z0-9_]+)/);
|
|
if (indexMatch) indexes.push(indexMatch[1].toLowerCase());
|
|
if (/SEARCH C USING INDEX IDX_CATEGORIES_SOURCE_TYPE_CATEGORY \(SOURCE_ID=\? AND TYPE=\? AND CATEGORY_ID=\?\)/.test(detail)) {
|
|
categoryCompositeLookup = true;
|
|
}
|
|
tables[alias] ||= { scan: 0, search: 0, indexLookup: 0, virtualLookup: 0 };
|
|
if (detail.startsWith('SCAN')) tables[alias].scan++;
|
|
else tables[alias].search++;
|
|
if (detail.includes('USING INDEX')) tables[alias].indexLookup++;
|
|
if (detail.includes('VIRTUAL TABLE INDEX')) tables[alias].virtualLookup++;
|
|
}
|
|
return { steps: planRows.length, tables, indexes, categoryCompositeLookup, tempBtreeForOrder };
|
|
}
|
|
|
|
function timedCatalog(db, q) {
|
|
const options = parseQuery({ q, limit: '200' }, 1);
|
|
let querySql;
|
|
let queryParams;
|
|
let sqlMs = 0;
|
|
const prepare = db.prepare.bind(db);
|
|
db.prepare = sql => {
|
|
const statement = prepare(sql);
|
|
if (!sql.includes('WITH live_sources AS')) return statement;
|
|
return { all: (...args) => {
|
|
querySql = sql;
|
|
queryParams = args[0];
|
|
const startedAt = performance.now();
|
|
try { return statement.all(...args); }
|
|
finally { sqlMs = performance.now() - startedAt; }
|
|
} };
|
|
};
|
|
const startedAt = performance.now();
|
|
const result = getLiveCatalog(db, [{ id: 1, type: 'm3u', enabled: true }], options);
|
|
const serviceMs = performance.now() - startedAt;
|
|
const plan = summarizePlan(prepare(`EXPLAIN QUERY PLAN ${querySql}`).all(queryParams));
|
|
return { serviceMs: +serviceMs.toFixed(1), sqlMs: +sqlMs.toFixed(1),
|
|
returned: result.items.length, hasMore: result.page.hasMore, plan };
|
|
}
|
|
|
|
test('38k live rows and 1,099 groups stay fast with a composite category lookup index', {
|
|
skip: RUN ? false : 'Opt-in local-shape performance reproduction',
|
|
timeout: 180000
|
|
}, t => {
|
|
const { dir, db } = createFixtureDatabase();
|
|
t.after(() => {
|
|
db.close();
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
ensureSearch(db);
|
|
const fixture = {
|
|
liveRows: db.prepare("SELECT COUNT(*) AS n FROM playlist_items WHERE type='live'").get().n,
|
|
playlistRows: db.prepare('SELECT COUNT(*) AS n FROM playlist_items').get().n,
|
|
categoryRows: db.prepare('SELECT COUNT(*) AS n FROM categories').get().n,
|
|
ftsPlaylistRows: db.prepare('SELECT COUNT(*) AS n FROM live_catalog_playlist_items_fts').get().n,
|
|
newsFtsCandidates: db.prepare('SELECT COUNT(*) AS n FROM live_catalog_playlist_items_fts WHERE name LIKE ?').get('%news%').n,
|
|
liveNewsCandidates: db.prepare(`SELECT COUNT(*) AS n FROM playlist_items p WHERE p.type='live'
|
|
AND p.rowid IN (SELECT rowid FROM live_catalog_playlist_items_fts WHERE name LIKE ?)`)
|
|
.get('%news%').n,
|
|
liveJson: db.prepare(`SELECT COUNT(*) AS n, SUM(length(CAST(data AS BLOB))) AS bytes,
|
|
AVG(length(CAST(data AS BLOB))) AS avgBytes, MAX(length(CAST(data AS BLOB))) AS maxBytes
|
|
FROM playlist_items WHERE type='live'`).get()
|
|
};
|
|
assert.deepEqual([fixture.liveRows, fixture.playlistRows, fixture.categoryRows, fixture.ftsPlaylistRows,
|
|
fixture.newsFtsCandidates, fixture.liveNewsCandidates],
|
|
[EXPECTED_LIVE, EXPECTED_PLAYLIST, EXPECTED_CATEGORIES, EXPECTED_PLAYLIST, 638, 549]);
|
|
|
|
const ftsStarted = performance.now();
|
|
const ftsCandidates = db.prepare('SELECT COUNT(*) AS n FROM live_catalog_playlist_items_fts WHERE name LIKE ?')
|
|
.get('%news%').n;
|
|
const ftsMs = performance.now() - ftsStarted;
|
|
const baseline = { noQuery: timedCatalog(db, ''), news: timedCatalog(db, 'news') };
|
|
for (const [query, measurement] of Object.entries(baseline)) {
|
|
assert.equal(measurement.plan.categoryCompositeLookup, false,
|
|
`baseline EXPLAIN QUERY PLAN for ${query} should not use the composite lookup index`);
|
|
}
|
|
|
|
// Diagnostic-only control: add an index to the throwaway fixture, never the local DB.
|
|
db.exec('CREATE INDEX idx_categories_source_type_category ON categories(source_id, type, category_id)');
|
|
const withLookupIndex = { noQuery: timedCatalog(db, ''), news: timedCatalog(db, 'news') };
|
|
for (const [query, measurement] of Object.entries(withLookupIndex)) {
|
|
assert.equal(measurement.plan.categoryCompositeLookup, true,
|
|
`EXPLAIN QUERY PLAN for ${query} must use idx_categories_source_type_category on source_id, type, category_id`);
|
|
}
|
|
console.log(`[live-search-shape-repro] ${JSON.stringify({
|
|
fixture: { ...fixture, liveJson: { rows: fixture.liveJson.n, bytes: fixture.liveJson.bytes,
|
|
avgBytes: +fixture.liveJson.avgBytes.toFixed(1), maxBytes: fixture.liveJson.maxBytes },
|
|
categoriesNameCandidates: db.prepare('SELECT COUNT(*) AS n FROM live_catalog_categories_fts WHERE name LIKE ?').get('%news%').n },
|
|
ftsCandidateMs: +ftsMs.toFixed(2), baseline, withLookupIndex
|
|
})}`);
|
|
|
|
for (const [query, measurement] of Object.entries(withLookupIndex)) {
|
|
assert.ok(measurement.serviceMs < INDEXED_LIMIT_MS,
|
|
`indexed catalogue route for ${query} took ${measurement.serviceMs}ms (limit ${INDEXED_LIMIT_MS}ms)`);
|
|
}
|
|
});
|