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>
137 lines
4.8 KiB
JavaScript
137 lines
4.8 KiB
JavaScript
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();
|
|
}
|
|
});
|