perf(epg): let the guide window predicate use the programme time index

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 14:23:02 +02:00
1 parent c28555e516
commit 465c5dc5d1
3 files changed
+198 -22

No files matched your search

@@ -104,6 +104,34 @@ function coverageSelect(rows: unknown[], whereCalls: unknown[]) {
const FROM = Date.UTC(2026, 8, 6, 0, 0, 0);
const TO = Date.UTC(2026, 8, 7, 0, 0, 0);
const FROM_ISO = '2026-09-06T00:00:00.000Z';
const TO_ISO = '2026-09-07T00:00:00.000Z';
/** `FROM_ISO`/`TO_ISO` widened by the 48 h prefilter slack. */
const FROM_SLACK_ISO = '2026-09-04T00:00:00.000Z';
const TO_SLACK_ISO = '2026-09-09T00:00:00.000Z';
/**
* Renders the real Drizzle predicate (never a hand-written restatement of its
* logic) so both its SQL shape and its behaviour can be asserted.
*/
function renderQuery(
epgIds: string[],
sourceUrls: string[] = []
): { sql: string; params: unknown[] } {
const window = normalizeGuideWindow({
channelIds: epgIds,
fromMs: Date.parse(FROM_ISO),
toMs: Date.parse(TO_ISO),
sourceUrls,
});
if (!window) {
throw new Error('expected a valid window');
}
return new SQLiteSyncDialect().sqlToQuery(
guideWindowCondition(epgIds, window)
);
}
describe('normalizeGuideWindow', () => {
it('rejects an empty or inverted window', () => {
expect(
@@ -158,6 +186,56 @@ describe('normalizeGuideWindow', () => {
});
expect(window?.sourceUrls).toHaveLength(50);
});
it('widens the index prefilter bounds to 48 h outside the exact window', () => {
const window = normalizeGuideWindow({
channelIds: ['a'],
fromMs: FROM,
toMs: TO,
});
expect(window?.fromSlackIso).toBe(FROM_SLACK_ISO);
expect(window?.toSlackIso).toBe(TO_SLACK_ISO);
expect(Date.parse(window?.fromSlackIso ?? '')).toBe(
FROM - 48 * 60 * 60 * 1000
);
expect(Date.parse(window?.toSlackIso ?? '')).toBe(
TO + 48 * 60 * 60 * 1000
);
});
it('clamps a slack bound that would overflow the serializable range', () => {
const window = normalizeGuideWindow({
channelIds: ['a'],
fromMs: -8.64e15,
toMs: 8.64e15,
});
// `toISOString()` throws past this range, so the widened bounds are
// clamped instead of widened. Such a window excludes every row through
// the exact predicate anyway.
expect(window?.fromSlackIso).toBe(new Date(-8.64e15).toISOString());
expect(window?.toSlackIso).toBe(new Date(8.64e15).toISOString());
});
});
describe('guideWindowCondition rendered SQL', () => {
it('pairs index-usable plain comparisons with the exact datetime test', () => {
const { sql, params } = renderQuery(['a', 'b']);
// Plain comparisons on the bare indexed columns: what lets the planner
// bound its `(channel_id, start, stop)` range scan.
expect(sql).toContain('"epg_programs"."start" < ?');
expect(sql).toContain('"epg_programs"."stop" > ?');
// The exact overlap test stays — the prefilter only narrows the scan.
expect(sql).toContain('datetime("epg_programs"."start") < datetime(?)');
expect(sql).toContain('datetime("epg_programs"."stop") > datetime(?)');
expect(params).toEqual([
'a',
'b',
TO_SLACK_ISO,
FROM_SLACK_ISO,
TO_ISO,
FROM_ISO,
]);
});
});
/** `true` when the CLI answers `sqlite3 -version`. Present on macOS and CI. */
@@ -187,33 +265,18 @@ function inlineParams(sqlText: string, params: unknown[]): string {
const describeWithSqlite = hasSqlite() ? describe : describe.skip;
describeWithSqlite('guideWindowCondition rendered against SQLite', () => {
const FROM_ISO = '2026-09-06T00:00:00.000Z';
const TO_ISO = '2026-09-07T00:00:00.000Z';
/**
* Renders the real Drizzle predicate to plain SQL (never a hand-written
* restatement of its logic) so it can be proven against a real SQLite
* engine.
*/
/** The real predicate as literal SQL, ready to prove against the engine. */
function renderPredicate(
epgIds: string[],
sourceUrls: string[] = []
): string {
const window = normalizeGuideWindow({
channelIds: epgIds,
fromMs: Date.parse(FROM_ISO),
toMs: Date.parse(TO_ISO),
sourceUrls,
});
if (!window) {
throw new Error('expected a valid window');
}
const { sql, params } = new SQLiteSyncDialect().sqlToQuery(
guideWindowCondition(epgIds, window)
);
const { sql, params } = renderQuery(epgIds, sourceUrls);
return inlineParams(sql, params);
}
const CREATE_TABLE =
'CREATE TABLE epg_programs (channel_id TEXT, start TEXT, stop TEXT, source_url TEXT);';
function matchingChannelIds(
predicate: string,
rows: Array<
@@ -234,7 +297,7 @@ describeWithSqlite('guideWindowCondition rendered against SQLite', () => {
)
.join(', ');
const script = [
'CREATE TABLE epg_programs (channel_id TEXT, start TEXT, stop TEXT, source_url TEXT);',
CREATE_TABLE,
`INSERT INTO epg_programs (channel_id, start, stop, source_url) VALUES ${values};`,
`SELECT channel_id FROM epg_programs WHERE ${predicate} ORDER BY channel_id;`,
].join('\n');
@@ -308,6 +371,54 @@ describeWithSqlite('guideWindowCondition rendered against SQLite', () => {
]);
expect(result).toEqual(['empty-row', 'null-row', 'u1-row']);
});
it('keeps and drops the right rows around the 48 h prefilter slack', () => {
const predicate = renderPredicate(['far-east', 'far-future', 'junk']);
const result = matchingChannelIds(predicate, [
// Stored with the largest real offset (+14:00). In UTC this is
// [2026-09-06T23:00:00Z, 2026-09-07T00:30:00Z), which overlaps —
// but its wall-clock prefix ("07T13:00") lies 13 h past `to`, so
// an un-slackened plain `start < to` compare would drop it. The
// 48 h of slack is what keeps the prefilter a superset.
[
'far-east',
'2026-09-07T13:00:00+14:00',
'2026-09-07T14:30:00+14:00',
null,
],
// Starts three days past `to`: outside the slack AND outside the
// exact window, so both halves of the predicate reject it.
[
'far-future',
'2026-09-10T00:00:00.000Z',
'2026-09-10T01:00:00.000Z',
null,
],
// XMLTV wire format, never normalized on import: `datetime()`
// yields NULL, so the exact predicate excludes it regardless of
// what the prefilter's string compare says.
['junk', '20260906180000 +0300', '20260906190000 +0300', null],
]);
expect(result).toEqual(['far-east']);
});
it('lets the planner use the programme time-range index', () => {
const script = [
CREATE_TABLE,
'CREATE INDEX idx_epg_programs_time_range ON epg_programs(channel_id, start, stop);',
`EXPLAIN QUERY PLAN SELECT channel_id FROM epg_programs WHERE ${renderPredicate(
['a', 'b']
)};`,
].join('\n');
const plan = execFileSync('sqlite3', [':memory:', script], {
encoding: 'utf8',
});
// Without the plain-string prefilter the planner can only constrain
// `channel_id=?` and then runs `datetime()` over the channel's whole
// retained history; `start<?` is the part that bounds that scan.
expect(plan).toContain('idx_epg_programs_time_range');
expect(plan).toContain('channel_id=? AND start<?');
});
});
describe('EpgGuideQueryService', () => {
@@ -1,4 +1,14 @@
import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
import {
and,
eq,
gt,
inArray,
isNull,
lt,
or,
sql,
type SQL,
} from 'drizzle-orm';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
@@ -34,6 +44,17 @@ type ChannelResolver = Pick<EpgQueryService, 'getChannelMetadata'>;
* Overlap test in SQLite `datetime()` so provider-local offsets in the
* stored ISO strings compare correctly against the UTC window bounds.
*
* `datetime()` around the indexed `start`/`stop` columns is opaque to the
* planner, which would use `idx_epg_programs_time_range` for `channel_id`
* only and then run the function over the channel's ENTIRE retained history —
* on a multi-week XMLTV database a 2000-key coverage probe stalls on that. So
* the exact test is paired with plain string comparisons against the
* slack-widened bounds (`toSlackIso`/`fromSlackIso`), which the planner CAN
* turn into a `channel_id=? AND start<?` range scan. The slack makes that
* prefilter a strict superset of the exact result, so it narrows the scan
* without ever changing the answer — see `GUIDE_WINDOW_SLACK_MS` in
* `epg-guide-window.util.ts` for why 48 h is enough.
*
* Unlike `EpgQueryService`, which runs a scoped query and then, on an empty
* result, a second unscoped legacy query, this predicate is folded into a
* SINGLE query: when `sourceUrls` is non-empty, the `WHERE` clause accepts
@@ -55,6 +76,8 @@ export function guideWindowCondition(
): SQL {
const overlap = and(
inArray(schema.epgPrograms.channelId, epgIds),
lt(schema.epgPrograms.start, window.toSlackIso),
gt(schema.epgPrograms.stop, window.fromSlackIso),
sql`datetime(${schema.epgPrograms.start}) < datetime(${window.toIso})`,
sql`datetime(${schema.epgPrograms.stop}) > datetime(${window.fromIso})`
) as SQL;
@@ -14,6 +14,23 @@ const MAX_SOURCE_URLS_PER_REQUEST = 50;
/** Largest |ms| `Date` can serialize; a corrupt payload must not throw in SQL. */
const MAX_SERIALIZABLE_MS = 8.64e15;
/**
* Slack applied to the index-usable plain-string bounds
* (`fromSlackIso`/`toSlackIso`) that `guideWindowCondition` puts beside its
* exact `datetime()` overlap test.
*
* Stored `start`/`stop` are ISO-8601 strings, so a lexicographic compare
* orders them by their local wall-clock prefix rather than by the instant
* they denote. A provider offset shifts the true instant by at most 14 h, and
* a same-day `'YYYY-MM-DD HH:MM'` vs `'…THH:MM'` format difference by less
* than 24 h (a space sorts before 'T'), so 48 h of slack keeps the
* prefilter a strict superset of the exact result — while cutting the
* per-channel scan from the channel's whole retained history down to about
* four days. Rows whose strings `datetime()` cannot parse fail the exact
* predicate anyway, so the prefilter can never change the answer.
*/
const GUIDE_WINDOW_SLACK_MS = 48 * 60 * 60 * 1000;
export interface EpgGuideWindowRequest {
channelIds: string[];
/** Provider-clock instants (the renderer removes the display offset). */
@@ -26,6 +43,14 @@ export interface NormalizedGuideWindow {
channelIds: string[];
fromIso: string;
toIso: string;
/**
* The same window widened by `GUIDE_WINDOW_SLACK_MS` on both sides, for
* the plain-string prefilter that lets SQLite bound its
* `(channel_id, start, stop)` index scan. Never a substitute for the
* exact `datetime()` comparison against `fromIso`/`toIso`.
*/
fromSlackIso: string;
toSlackIso: string;
sourceUrls: string[];
}
@@ -37,6 +62,21 @@ function isUsableInstant(value: unknown): value is number {
);
}
/**
* Serializes a widened bound. `toISOString()` throws past
* `MAX_SERIALIZABLE_MS`, so the slack is clamped there. A bound that lands in
* the extended `+YYYYYY-…` year form is one `datetime()` cannot parse, so the
* exact predicate already excludes every row for such a window and the
* narrower prefilter stays harmless.
*/
function slackIso(ms: number): string {
const clamped = Math.min(
Math.max(ms, -MAX_SERIALIZABLE_MS),
MAX_SERIALIZABLE_MS
);
return new Date(clamped).toISOString();
}
/** Trims, drops blanks, de-duplicates (first-occurrence order) and caps. */
export function uniqueTrimmedStrings(values: unknown, max: number): string[] {
if (!Array.isArray(values)) {
@@ -77,6 +117,8 @@ export function normalizeGuideWindow(
channelIds,
fromIso: new Date(request.fromMs).toISOString(),
toIso: new Date(request.toMs).toISOString(),
fromSlackIso: slackIso(request.fromMs - GUIDE_WINDOW_SLACK_MS),
toSlackIso: slackIso(request.toMs + GUIDE_WINDOW_SLACK_MS),
sourceUrls,
};
}