Files
NodecastV2/test/live-catalog-search-shape-repro.test.js
Blomios 19849304a3 fix(live): index category lookups for catalog search (#49)
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>
2026-10-22 23:23:46 +02:00

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)`);
}
});