fix(live): bound live channel listing queries
Optimize the live channel page path so unfiltered catalog pages are counted and paged in SQLite before serialization and enrichment. Add the T42 CPU regression test covering a 50000-channel projected catalog. Refs #42 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -24,6 +24,8 @@ const CATALOG_PROJECTION_STATE_COLUMNS = {
|
|||||||
const LIVE_PROJECTION_SCHEMA_SQL = `
|
const LIVE_PROJECTION_SCHEMA_SQL = `
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_logical_channel ON playlist_items(logical_channel_id);
|
CREATE INDEX IF NOT EXISTS idx_items_logical_channel ON playlist_items(logical_channel_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_grouping_fingerprint ON playlist_items(grouping_fingerprint);
|
CREATE INDEX IF NOT EXISTS idx_items_grouping_fingerprint ON playlist_items(grouping_fingerprint);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_live_projection_page
|
||||||
|
ON playlist_items(type, logical_channel_id, language_code, source_id, variant_rank);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS logical_channels (
|
CREATE TABLE IF NOT EXISTS logical_channels (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@ -506,42 +506,92 @@ function listChannel(channel) {
|
|||||||
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null, dependencies = {}) {
|
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null, dependencies = {}) {
|
||||||
database ||= defaultDatabase();
|
database ||= defaultDatabase();
|
||||||
const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog;
|
const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog;
|
||||||
const params = [];
|
const bounds = pageBounds(page, limit);
|
||||||
let sourceFilter = '';
|
const sourceFilter = sourceId === null ? '' : 'AND p.source_id = ?';
|
||||||
if (sourceId !== null) {
|
const sourceParams = sourceId === null ? [] : [sourceId];
|
||||||
sourceFilter = 'AND p.source_id = ?';
|
const visibilityFilter = includeHidden
|
||||||
params.push(sourceId);
|
? ''
|
||||||
}
|
: 'AND COALESCE(p.is_hidden, 0) = 0 AND COALESCE(c.is_hidden, 0) = 0';
|
||||||
const rows = database.prepare(`
|
const languageCodes = [...new Set(languages.filter(Boolean))];
|
||||||
|
const languageFilter = languageCodes.length
|
||||||
|
? `AND COALESCE(p.language_code, 'und') IN (${languageCodes.map(() => '?').join(', ')})`
|
||||||
|
: '';
|
||||||
|
const filteredParams = [...sourceParams, ...languageCodes];
|
||||||
|
|
||||||
|
const availableLanguageCodes = database.prepare(`
|
||||||
|
SELECT DISTINCT COALESCE(p.language_code, 'und') AS language_code
|
||||||
|
FROM playlist_items p
|
||||||
|
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live'
|
||||||
|
AND c.category_id = p.category_id
|
||||||
|
WHERE p.type = 'live' ${sourceFilter} ${visibilityFilter}
|
||||||
|
`).all(...sourceParams).map(row => row.language_code);
|
||||||
|
const availableLanguages = availableLanguageCodes.map(code => ({
|
||||||
|
code,
|
||||||
|
short: code === 'und' ? 'UND' : code.toUpperCase(),
|
||||||
|
label: code === 'und' ? 'Unknown' : new Intl.DisplayNames(['en'], { type: 'language' }).of(code)
|
||||||
|
})).sort((left, right) => left.label.localeCompare(right.label));
|
||||||
|
|
||||||
|
const selectRows = extraWhere => database.prepare(`
|
||||||
SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
|
SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
|
||||||
l.display_name, l.preferred_variant_id
|
l.display_name, l.preferred_variant_id, c.name AS category_name,
|
||||||
|
COALESCE(c.is_hidden, 0) AS category_hidden
|
||||||
FROM playlist_items p
|
FROM playlist_items p
|
||||||
JOIN logical_channels l ON l.id = p.logical_channel_id
|
JOIN logical_channels l ON l.id = p.logical_channel_id
|
||||||
|
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live'
|
||||||
|
AND c.category_id = p.category_id
|
||||||
LEFT JOIN favorites f ON f.user_id = ? AND f.source_id = p.source_id
|
LEFT JOIN favorites f ON f.user_id = ? AND f.source_id = p.source_id
|
||||||
AND f.item_id = p.item_id AND f.item_type = 'channel'
|
AND f.item_id = p.item_id AND f.item_type = 'channel'
|
||||||
WHERE p.type = 'live' ${sourceFilter}
|
WHERE p.type = 'live' ${sourceFilter} ${visibilityFilter} ${languageFilter} ${extraWhere}
|
||||||
ORDER BY l.display_name, p.variant_rank
|
ORDER BY l.display_name, p.logical_channel_id,
|
||||||
`).all(userId ?? -1, ...params);
|
COALESCE(p.language_code, 'und'), p.variant_rank, p.id
|
||||||
const categoryRows = sourceId === null
|
`);
|
||||||
? database.prepare("SELECT source_id, category_id, name, is_hidden FROM categories WHERE type = 'live'").all()
|
|
||||||
: database.prepare("SELECT source_id, category_id, name, is_hidden FROM categories WHERE type = 'live' AND source_id = ?").all(sourceId);
|
let filtered;
|
||||||
const categories = new Map(categoryRows.map(category => [`${category.source_id}\0${category.category_id}`, category]));
|
let total;
|
||||||
for (const row of rows) {
|
if (String(q).trim()) {
|
||||||
const category = categories.get(`${row.source_id}\0${row.category_id}`);
|
// matchesSearch also examines variant JSON-derived fields, so retain its exact
|
||||||
row.category_name = category?.name || null;
|
// semantics for searches while still applying safe SQL filters first.
|
||||||
row.category_hidden = category?.is_hidden || 0;
|
const rows = selectRows('').all(userId ?? -1, ...filteredParams);
|
||||||
|
filtered = serializeChannels(rows, includeHidden).filter(channel => matchesSearch(channel, q));
|
||||||
|
total = filtered.length;
|
||||||
|
filtered = filtered.slice(bounds.offset, bounds.offset + bounds.limit);
|
||||||
|
} else {
|
||||||
|
const groupedSql = `
|
||||||
|
FROM playlist_items p
|
||||||
|
JOIN logical_channels l ON l.id = p.logical_channel_id
|
||||||
|
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live'
|
||||||
|
AND c.category_id = p.category_id
|
||||||
|
WHERE p.type = 'live' ${sourceFilter} ${visibilityFilter} ${languageFilter}
|
||||||
|
GROUP BY p.logical_channel_id, COALESCE(p.language_code, 'und')`;
|
||||||
|
total = database.prepare(`SELECT COUNT(*) AS total FROM (SELECT 1 ${groupedSql})`)
|
||||||
|
.get(...filteredParams).total;
|
||||||
|
const keys = database.prepare(`
|
||||||
|
SELECT p.logical_channel_id, COALESCE(p.language_code, 'und') AS language_code,
|
||||||
|
MIN(l.display_name) AS display_name
|
||||||
|
${groupedSql}
|
||||||
|
ORDER BY display_name, p.logical_channel_id, language_code
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(...filteredParams, bounds.limit, bounds.offset);
|
||||||
|
if (!keys.length) {
|
||||||
|
filtered = [];
|
||||||
|
} else {
|
||||||
|
const keyFilter = `AND (${keys.map(() =>
|
||||||
|
`(p.logical_channel_id = ? AND COALESCE(p.language_code, 'und') = ?)`
|
||||||
|
).join(' OR ')})`;
|
||||||
|
const keyParams = keys.flatMap(key => [key.logical_channel_id, key.language_code]);
|
||||||
|
filtered = serializeChannels(
|
||||||
|
selectRows(keyFilter).all(userId ?? -1, ...filteredParams, ...keyParams),
|
||||||
|
includeHidden
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const serialized = serializeChannels(rows, includeHidden);
|
|
||||||
const availableLanguages = [...new Map(serialized.map(channel => [channel.language.code, channel.language])).values()]
|
const persistedEpg = filtered.length ? new Map(database.prepare(`
|
||||||
.map(language => ({ code: language.code, short: language.short, label: language.label }))
|
|
||||||
.sort((left, right) => left.label.localeCompare(right.label));
|
|
||||||
const filtered = serialized.filter(channel =>
|
|
||||||
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q)
|
|
||||||
);
|
|
||||||
const persistedEpg = new Map(database.prepare(`
|
|
||||||
SELECT logical_channel_id, language_code, source_id, channel_id, match_confidence, data
|
SELECT logical_channel_id, language_code, source_id, channel_id, match_confidence, data
|
||||||
FROM live_epg_matches
|
FROM live_epg_matches
|
||||||
`).all().map(match => [`${match.logical_channel_id}\0${match.language_code}`, match]));
|
WHERE ${filtered.map(() => '(logical_channel_id = ? AND language_code = ?)').join(' OR ')}
|
||||||
|
`).all(...filtered.flatMap(channel => [channel.logicalChannelId, channel.language.code]))
|
||||||
|
.map(match => [`${match.logical_channel_id}\0${match.language_code}`, match])) : new Map();
|
||||||
for (const channel of filtered) {
|
for (const channel of filtered) {
|
||||||
const match = persistedEpg.get(`${channel.logicalChannelId}\0${channel.language.code}`);
|
const match = persistedEpg.get(`${channel.logicalChannelId}\0${channel.language.code}`);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
@ -556,13 +606,11 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
|
|||||||
language: channel.language.code
|
language: channel.language.code
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const bounds = pageBounds(page, limit);
|
|
||||||
const paged = filtered.slice(bounds.offset, bounds.offset + bounds.limit);
|
|
||||||
// Catalog loading must stay bounded: both enrichers perform synchronous SQLite work,
|
// Catalog loading must stay bounded: both enrichers perform synchronous SQLite work,
|
||||||
// and EPG matching is especially expensive against large XMLTV catalogs. Enrich only
|
// and EPG matching is especially expensive against large XMLTV catalogs. Enrich only
|
||||||
// a genuinely targeted, bounded search; base projection data remains usable otherwise.
|
// a genuinely targeted, bounded search; base projection data remains usable otherwise.
|
||||||
const shouldEnrich = Boolean(String(q).trim()) && filtered.length <= MAX_SYNCHRONOUS_ENRICHMENTS;
|
const shouldEnrich = Boolean(String(q).trim()) && total <= MAX_SYNCHRONOUS_ENRICHMENTS;
|
||||||
const enriched = !shouldEnrich ? paged : paged.map(channel => {
|
const enriched = !shouldEnrich ? filtered : filtered.map(channel => {
|
||||||
const match = standardCatalog.findMatch(database, channel);
|
const match = standardCatalog.findMatch(database, channel);
|
||||||
if (match) {
|
if (match) {
|
||||||
channel.standardName = match.name;
|
channel.standardName = match.name;
|
||||||
@ -573,7 +621,7 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
|
|||||||
return {
|
return {
|
||||||
channels: enriched.map(listChannel),
|
channels: enriched.map(listChannel),
|
||||||
availableLanguages,
|
availableLanguages,
|
||||||
page: pageMetadata(filtered.length, bounds)
|
page: pageMetadata(total, bounds)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
136
test/t42LiveListCpuRegression.test.js
Normal file
136
test/t42LiveListCpuRegression.test.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { performance } = require('node:perf_hooks');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const liveProjection = require('../server/services/liveProjectionService');
|
||||||
|
const {
|
||||||
|
ensureColumns,
|
||||||
|
LIVE_PROJECTION_COLUMNS,
|
||||||
|
LIVE_PROJECTION_SCHEMA_SQL
|
||||||
|
} = require('../server/db/liveProjectionSchema');
|
||||||
|
|
||||||
|
function createProjectionDb() {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.pragma('journal_mode = MEMORY');
|
||||||
|
db.pragma('synchronous = 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,
|
||||||
|
parent_id TEXT,
|
||||||
|
is_hidden INTEGER DEFAULT 0,
|
||||||
|
data JSON
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
parent_id TEXT,
|
||||||
|
stream_icon TEXT,
|
||||||
|
stream_url TEXT,
|
||||||
|
container_extension TEXT,
|
||||||
|
rating REAL,
|
||||||
|
year TEXT,
|
||||||
|
added_at TEXT,
|
||||||
|
is_hidden INTEGER DEFAULT 0,
|
||||||
|
is_favorite INTEGER DEFAULT 0,
|
||||||
|
data JSON
|
||||||
|
);
|
||||||
|
CREATE TABLE favorites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
item_type TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||||
|
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO categories (id, source_id, category_id, type, name)
|
||||||
|
VALUES ('cat:1', 1, 'general', 'live', 'General')
|
||||||
|
`).run();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedProjectedChannels(db, total) {
|
||||||
|
const logical = db.prepare(`
|
||||||
|
INSERT INTO logical_channels
|
||||||
|
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 1, 1, 1)
|
||||||
|
`);
|
||||||
|
const item = db.prepare(`
|
||||||
|
INSERT INTO playlist_items
|
||||||
|
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
|
||||||
|
logical_channel_id, grouping_fingerprint, grouping_input_hash, quality_label, quality_rank,
|
||||||
|
variant_rank, language_code, language_confidence, language_detector, language_input_hash)
|
||||||
|
VALUES (?, 1, ?, 'live', ?, 'general', '', ?, '{}', ?, ?, ?, 'HD', 2, 0, 'en', 1, 'algo', ?)
|
||||||
|
`);
|
||||||
|
const insertAll = db.transaction(() => {
|
||||||
|
for (let index = 0; index < total; index++) {
|
||||||
|
const id = `1:${index}`;
|
||||||
|
const logicalId = `logical:${index}`;
|
||||||
|
const name = `Channel ${String(index).padStart(6, '0')}`;
|
||||||
|
logical.run(logicalId, `channel ${index}`, name, id);
|
||||||
|
item.run(
|
||||||
|
id,
|
||||||
|
String(index),
|
||||||
|
name,
|
||||||
|
`https://streams.test/${index}.m3u8`,
|
||||||
|
logicalId,
|
||||||
|
`channel ${index}`,
|
||||||
|
`group-hash-${index}`,
|
||||||
|
`language-hash-${index}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
insertAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('/api/live/channels first page stays CPU-bounded for a large projected catalog', () => {
|
||||||
|
const db = createProjectionDb();
|
||||||
|
seedProjectedChannels(db, 50000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cpuBefore = process.cpuUsage();
|
||||||
|
const started = performance.now();
|
||||||
|
const result = liveProjection.getChannelPage({
|
||||||
|
includeHidden: false,
|
||||||
|
userId: 7,
|
||||||
|
page: 1,
|
||||||
|
limit: 100
|
||||||
|
}, db, {
|
||||||
|
iptvOrgCatalog: { findMatch: () => null }
|
||||||
|
});
|
||||||
|
const elapsedMs = performance.now() - started;
|
||||||
|
const cpu = process.cpuUsage(cpuBefore);
|
||||||
|
const metrics = {
|
||||||
|
rows: 50000,
|
||||||
|
returnedChannels: result.channels.length,
|
||||||
|
totalChannels: result.page.total,
|
||||||
|
elapsedMs: Math.round(elapsedMs),
|
||||||
|
cpuUserMs: Math.round(cpu.user / 1000),
|
||||||
|
cpuSystemMs: Math.round(cpu.system / 1000),
|
||||||
|
payloadBytes: Buffer.byteLength(JSON.stringify({
|
||||||
|
filters: { availableLanguages: result.availableLanguages },
|
||||||
|
channels: result.channels,
|
||||||
|
page: result.page
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
console.log('[T42 live list metrics]', JSON.stringify(metrics));
|
||||||
|
|
||||||
|
assert.equal(result.channels.length, 100);
|
||||||
|
assert.ok(
|
||||||
|
metrics.elapsedMs <= 500 && metrics.cpuUserMs <= 500,
|
||||||
|
`first live page should be bounded; measured ${JSON.stringify(metrics)}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user