perf(database): commit catalog writes in row-budgeted transactions (#1511)

* perf(database): commit catalog writes in row-budgeted transactions

Refreshing or deleting a large Xtream playlist spent most of its time in
"Removing cached content": every 100 rows were deleted in their own
transaction, so a 300k-row catalog cost ~3,000 commits, each flushing an
FTS5 segment, re-appending dirty index pages to the WAL and, about every
4 MB, running an fsync-ing auto-checkpoint. Measured on a 900k-row copy of
a real database the delete took 13 s where a single set-based statement
takes 3 s, with 2 GB of WAL traffic instead of 140 MB. The re-import wrote
its rows the same way.

Deletes now read content row counts per category from the covering
indexes, pack categories into groups of about 5,000 rows and issue one
set-based DELETE per group, then drop the categories (and, for playlist
removal, the user-data tables) with one scoped statement each. Inserts keep
100-row statements but commit fifty of them at a time. Cancellation still
lands between commits and progress still reports after each one; the
worker additionally throttles progress events to one per 100 ms with
summed increments, so a large operation no longer floods the renderer.

Same subset, same machine: 13.0 s -> 5.6 s for the delete, 16.7 s -> 8.6 s
for the insert; the fsync-bound share is larger on Windows and spinning
disks.

Closes #1292

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(database): pin the row budget and the worker-side progress flush

Review follow-up: the default 5,000-row budget and the 50-statement insert
commit were only exercised with explicit overrides or sub-budget inputs, and
nothing covered the worker controller flushing a coalesced progress report
before its terminal event. Both are now pinned, and the docs no longer claim
the insert path reports SQLite `changes` or binds 1,600 parameters per
statement (it binds the eleven columns a value supplies).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(e2e): keep the stress suites on 100-row commits via a test-only budget knob

The Xtream responsiveness and playlist-switcher suites slow the database
worker down with IPTVNATOR_DB_WORKER_BATCH_DELAY_MS so they can observe an
import mid-flight; the stress catalog is 1,920 rows per type, which at the
production budget of 5,000 rows per commit is a single commit per type and
too few progress events for their assertions. The new companion knob
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION restores 100-row commits for those
runs only; unset or invalid it leaves the default untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(database): scope refresh and cache-clear deletes to the captured category ids

The row-budgeted rewrite deleted a refreshed playlist's categories with a
playlist-wide predicate. The worker serves other requests between commits,
so a newer import of the same playlist could create categories in that
window and lose them to the older refresh, after which its content inserts
fail their foreign keys. Count and delete now use the ids the collection
step read, as the chunked code did; playlist removal keeps its playlist
scope because its final playlist-row delete cascades the same set.
deleteCategoriesWhere runs every filter through requireScopedFilter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-03 10:31:31 +02:00
1 parent 5e51c1ea5d
commit 82295356ed
18 files changed
+1624 -237

No files matched your search

@@ -0,0 +1,10 @@
---
type: perf
area: database
issues: [1292]
---
Refreshing or removing a large Xtream playlist is several times faster: the
"Removing cached content" stage and the re-import now commit around 5,000
rows at a time instead of 100, and progress updates arrive at most ten times a
second instead of once per batch.
+1 -1
View File
@@ -746,7 +746,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
**Workers** (`apps/electron-backend/src/app/workers/`): **Workers** (`apps/electron-backend/src/app/workers/`):
- EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts` - EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts`
- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`) - Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`). Catalog deletes and inserts commit in row-budgeted transactions of ~5,000 rows (`database/operations/catalog-deletion.ts`: per-category row counts → category groups → set-based `DELETE`s scoped to the captured category ids, never playlist-wide, since the worker interleaves requests between commits and a newer import's categories must survive an older refresh; never 100-row autocommit batches, which flush FTS5 segments and re-append index pages to the WAL on every commit, and never one giant transaction, which would starve the main-process and EPG-worker connections past their 5 s `busy_timeout`). Progress events are throttled to one per 100 ms per operation with summed `increment`s (`operation-progress-throttle.ts`); phase starts, totals reached and terminal events are never held back
- Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`) - Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`)
### Key Features ### Key Features
@@ -292,6 +292,9 @@ test.describe('Electron Playlist Switcher', () => {
const app = await launchElectronApp(dataDir, { const app = await launchElectronApp(dataDir, {
env: { env: {
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs, IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
// Keep the slowed-down import in 100-row commits so the switch
// below still happens mid-import (see xtream-responsiveness).
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION: '100',
}, },
}); });
@@ -16,6 +16,11 @@ const stressPortalName = 'Stress Xtream Portal';
const stressXtreamUsername = 'stress'; const stressXtreamUsername = 'stress';
const stressXtreamPassword = 'stress'; const stressXtreamPassword = 'stress';
const dbWorkerBatchDelayMs = '20'; const dbWorkerBatchDelayMs = '20';
// The stress catalog is 1,920 rows per type; at the production budget of
// 5,000 rows per commit that is one commit per type, too few checkpoints and
// progress events to observe the import mid-flight. Shrinking the budget
// keeps the batches this suite slows down and watches.
const dbWorkerRowsPerTransaction = '100';
function playlistRowByName(name: string) { function playlistRowByName(name: string) {
return `app-playlist-item:has-text("${name}")`; return `app-playlist-item:has-text("${name}")`;
@@ -43,6 +48,8 @@ test.describe('Electron Xtream Responsiveness', () => {
const app = await launchElectronApp(dataDir, { const app = await launchElectronApp(dataDir, {
env: { env: {
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs, IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION:
dbWorkerRowsPerTransaction,
}, },
}); });
@@ -121,6 +128,8 @@ test.describe('Electron Xtream Responsiveness', () => {
const app = await launchElectronApp(dataDir, { const app = await launchElectronApp(dataDir, {
env: { env: {
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs, IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION:
dbWorkerRowsPerTransaction,
}, },
}); });
@@ -257,6 +266,8 @@ test.describe('Electron Xtream Responsiveness', () => {
const app = await launchElectronApp(dataDir, { const app = await launchElectronApp(dataDir, {
env: { env: {
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs, IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION:
dbWorkerRowsPerTransaction,
}, },
}); });
@@ -0,0 +1,304 @@
import { mockDrizzle, mockDrizzleOrmModule } from './operations.test-helpers';
jest.mock('drizzle-orm', () => mockDrizzleOrmModule());
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import {
countContentRowsByCategory,
deleteCategoriesWhere,
deleteContentByCategoryGroups,
groupCategoriesByRowBudget,
requireScopedFilter,
resolveContentRowsPerTransaction,
sumCategoryRowCounts,
} from './catalog-deletion';
function createDeleteDb(changesForGroup: (group: number[]) => number) {
const groups: number[][] = [];
const tables: unknown[] = [];
const deleteRows = jest.fn((table: unknown) => {
tables.push(table);
return {
where: jest.fn((condition: { values: number[] }) => {
groups.push(condition.values);
return {
run: jest.fn(() => ({
changes: changesForGroup(condition.values),
})),
};
}),
};
});
const transaction = jest.fn((execute: (tx: unknown) => unknown) =>
execute({ delete: deleteRows })
);
return {
db: { transaction } as unknown as AppDatabase,
groups,
tables,
transaction,
};
}
describe('groupCategoriesByRowBudget', () => {
it('packs consecutive categories until the next one would exceed the budget', () => {
expect(
groupCategoriesByRowBudget(
[
{ categoryId: 1, rowCount: 3000 },
{ categoryId: 2, rowCount: 1500 },
{ categoryId: 3, rowCount: 600 },
{ categoryId: 4, rowCount: 400 },
],
5000
)
).toEqual([[1, 2], [3, 4]]);
});
it('gives a category larger than the budget a group of its own', () => {
expect(
groupCategoriesByRowBudget(
[
{ categoryId: 1, rowCount: 10 },
{ categoryId: 2, rowCount: 9000 },
{ categoryId: 3, rowCount: 10 },
],
5000
)
).toEqual([[1], [2], [3]]);
});
it('skips categories without content and returns nothing for none', () => {
expect(
groupCategoriesByRowBudget(
[
{ categoryId: 1, rowCount: 0 },
{ categoryId: 2, rowCount: 5 },
],
5000
)
).toEqual([[2]]);
expect(groupCategoriesByRowBudget([], 5000)).toEqual([]);
});
});
describe('resolveContentRowsPerTransaction', () => {
it('defaults to 5,000 rows unless the test knob names a positive integer', () => {
expect(resolveContentRowsPerTransaction(undefined)).toBe(5000);
expect(resolveContentRowsPerTransaction('')).toBe(5000);
expect(resolveContentRowsPerTransaction('0')).toBe(5000);
expect(resolveContentRowsPerTransaction('-100')).toBe(5000);
expect(resolveContentRowsPerTransaction('abc')).toBe(5000);
expect(resolveContentRowsPerTransaction('100')).toBe(100);
});
});
describe('requireScopedFilter', () => {
it('refuses the unbounded filter drizzle returns for an empty and()', () => {
expect(() => requireScopedFilter(undefined)).toThrow(
'Refusing an unscoped catalog delete'
);
});
it('passes a real filter through', () => {
const filter = mockDrizzle.eq('a', 'b') as never;
expect(requireScopedFilter(filter)).toBe(filter);
});
});
describe('countContentRowsByCategory', () => {
it('groups the joined count by category under the given filter', async () => {
const rows = [{ categoryId: 7, rowCount: 3 }];
const groupBy = jest.fn().mockResolvedValue(rows);
const where = jest.fn(() => ({ groupBy }));
const innerJoin = jest.fn(() => ({ where }));
const from = jest.fn(() => ({ innerJoin }));
const select = jest.fn(() => ({ from }));
const filter = mockDrizzle.eq(
schema.categories.playlistId,
'playlist-1'
) as never;
await expect(
countContentRowsByCategory(
{ select } as unknown as AppDatabase,
filter
)
).resolves.toBe(rows);
expect(from).toHaveBeenCalledWith(schema.content);
expect(innerJoin).toHaveBeenCalledWith(
schema.categories,
expect.objectContaining({ kind: 'eq' })
);
expect(where).toHaveBeenCalledWith(filter);
expect(groupBy).toHaveBeenCalledWith(schema.content.categoryId);
expect(sumCategoryRowCounts(rows)).toBe(3);
});
});
describe('deleteContentByCategoryGroups', () => {
const counts = [
{ categoryId: 1, rowCount: 3000 },
{ categoryId: 2, rowCount: 3000 },
{ categoryId: 3, rowCount: 10 },
];
const rowsIn = (group: number[]) =>
group.reduce(
(sum, id) =>
sum + (counts.find((c) => c.categoryId === id)?.rowCount ?? 0),
0
);
it('commits one budgeted category group per transaction with a checkpoint before and progress after', async () => {
const timeline: string[] = [];
const checkpoint = jest.fn(() => {
timeline.push('checkpoint');
});
const onProgress = jest.fn((progress: { current?: number }) => {
timeline.push(`progress:${progress.current}`);
});
const db = createDeleteDb(rowsIn);
const runTransaction = db.transaction.getMockImplementation();
db.transaction.mockImplementation((execute) => {
timeline.push('transaction');
return runTransaction?.(execute);
});
await expect(
deleteContentByCategoryGroups(db.db, counts, {
control: { checkpoint, onProgress },
phase: 'deleting-content',
rowsPerTransaction: 5000,
})
).resolves.toBe(6010);
expect(db.groups).toEqual([[1], [2, 3]]);
expect(db.tables).toEqual([schema.content, schema.content]);
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
schema.content.categoryId,
[1]
);
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
schema.content.categoryId,
[2, 3]
);
expect(onProgress.mock.calls.map(([value]) => value)).toEqual([
{
phase: 'deleting-content',
current: 3000,
total: 6010,
increment: 3000,
},
{
phase: 'deleting-content',
current: 6010,
total: 6010,
increment: 3010,
},
]);
expect(timeline).toEqual([
'checkpoint',
'transaction',
'progress:3000',
'checkpoint',
'checkpoint',
'transaction',
'progress:6010',
'checkpoint',
]);
});
it('stops at the next checkpoint when cancelled, keeping the committed group', async () => {
const db = createDeleteDb(rowsIn);
const cancellation = new Error('cancelled');
cancellation.name = 'AbortError';
let checkpoints = 0;
await expect(
deleteContentByCategoryGroups(db.db, counts, {
control: {
checkpoint: () => {
checkpoints += 1;
if (checkpoints === 2) {
throw cancellation;
}
},
},
phase: 'deleting-content',
rowsPerTransaction: 5000,
})
).rejects.toBe(cancellation);
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(db.groups).toEqual([[1]]);
});
it('defaults to a 5,000-row budget per transaction', async () => {
const budgetCounts = [
{ categoryId: 1, rowCount: 3000 },
{ categoryId: 2, rowCount: 2000 },
{ categoryId: 3, rowCount: 1 },
];
const db = createDeleteDb((group) =>
group.reduce(
(sum, id) =>
sum +
(budgetCounts.find((c) => c.categoryId === id)?.rowCount ??
0),
0
)
);
await expect(
deleteContentByCategoryGroups(db.db, budgetCounts, {
phase: 'deleting-content',
})
).resolves.toBe(5001);
// 3000 + 2000 fill the budget exactly; the next row starts a group.
expect(db.groups).toEqual([[1, 2], [3]]);
});
it('does nothing for categories without content', async () => {
const db = createDeleteDb(rowsIn);
const onProgress = jest.fn();
await expect(
deleteContentByCategoryGroups(db.db, [], {
control: { onProgress },
phase: 'deleting-content',
})
).resolves.toBe(0);
expect(db.transaction).not.toHaveBeenCalled();
expect(onProgress).not.toHaveBeenCalled();
});
});
describe('deleteCategoriesWhere', () => {
it('deletes the filtered categories in one transaction and reports the count', async () => {
const where = jest.fn(() => ({ run: () => ({ changes: 4 }) }));
const deleteRows = jest.fn(() => ({ where }));
const transaction = jest.fn((execute: (tx: unknown) => unknown) =>
execute({ delete: deleteRows })
);
const filter = mockDrizzle.eq(
schema.categories.playlistId,
'playlist-1'
) as never;
await expect(
deleteCategoriesWhere(
{ transaction } as unknown as AppDatabase,
filter
)
).resolves.toBe(4);
expect(transaction).toHaveBeenCalledTimes(1);
expect(deleteRows).toHaveBeenCalledWith(schema.categories);
expect(where).toHaveBeenCalledWith(filter);
});
});
@@ -0,0 +1,193 @@
import { eq, inArray, sql, type SQL } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import {
checkpointOperation,
type OperationControl,
reportOperationProgress,
} from './operation-control';
/**
* Upper bound on the content rows one committed transaction removes or
* inserts.
*
* Catalog writes used to commit every 100 rows (#1292). Each commit flushes
* the FTS5 trigram pending buffer into a new segment, appends the dirty pages
* of every `content` index to the WAL again, and roughly every 4 MB of WAL
* runs an fsync-ing auto-checkpoint. On a 900k-row database that turned a
* 3 s set-based delete of one 300k-row playlist into 12–15 s and wrote 2 GB
* of WAL where one transaction writes 140 MB; the insert side behaved the
* same way. One giant transaction is not the answer either: the worker only
* serves other requests between awaits, cancellation is cooperative between
* commits, and the main-process and EPG-worker connections give up after
* their 5 s `busy_timeout` while a write transaction holds the lock. Around
* 5,000 rows keeps a commit near 100 ms on a laptop SSD.
*
* `IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION` overrides the budget. It is a
* test-only knob, the companion of `IPTVNATOR_DB_WORKER_BATCH_DELAY_MS`: the
* responsiveness E2E suites slow the worker down and need enough commits in
* a few-thousand-row mock catalog to observe progress mid-import.
*/
export const DEFAULT_CONTENT_ROWS_PER_TRANSACTION = 5000;
/** The budget behind `CONTENT_ROWS_PER_TRANSACTION`, parsed from one raw env value. */
export function resolveContentRowsPerTransaction(
rawValue: string | undefined
): number {
const parsed = Number.parseInt(rawValue ?? '', 10);
return Number.isInteger(parsed) && parsed > 0
? parsed
: DEFAULT_CONTENT_ROWS_PER_TRANSACTION;
}
export const CONTENT_ROWS_PER_TRANSACTION = resolveContentRowsPerTransaction(
process.env['IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION']
);
/** How many content rows reference one category. */
export interface CategoryRowCount {
readonly categoryId: number;
readonly rowCount: number;
}
/**
* Rejects a filter that would drop every row of a table.
*
* Drizzle's `and()` returns `undefined` for an empty condition list, and
* `.where(undefined)` is a full-table delete. The scoped deletes below never
* intend that, so an unbounded filter is a bug worth failing on.
*/
export function requireScopedFilter(filter: SQL | undefined): SQL {
if (!filter) {
throw new Error('Refusing an unscoped catalog delete');
}
return filter;
}
/**
* Content row counts per category for the categories matching
* `categoryFilter`, read from two covering indexes without touching a row.
*/
export async function countContentRowsByCategory(
db: AppDatabase,
categoryFilter: SQL
): Promise<CategoryRowCount[]> {
return db
.select({
categoryId: schema.content.categoryId,
rowCount: sql<number>`count(*)`,
})
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(categoryFilter)
.groupBy(schema.content.categoryId);
}
export function sumCategoryRowCounts(
counts: readonly CategoryRowCount[]
): number {
return counts.reduce((total, count) => total + count.rowCount, 0);
}
/**
* Packs categories into groups whose combined row count stays within
* `rowBudget`, preserving input order. A category larger than the budget
* forms a group of its own and is deleted in one statement: bounding it
* further would need id-ranged sub-deletes, and the largest real categories
* seen so far (~45k rows) still commit in well under a second. Categories
* without content are skipped — there is nothing to delete under them.
*/
export function groupCategoriesByRowBudget(
counts: readonly CategoryRowCount[],
rowBudget: number
): number[][] {
const groups: number[][] = [];
let group: number[] = [];
let groupRows = 0;
for (const { categoryId, rowCount } of counts) {
if (rowCount <= 0) {
continue;
}
if (group.length > 0 && groupRows + rowCount > rowBudget) {
groups.push(group);
group = [];
groupRows = 0;
}
group.push(categoryId);
groupRows += rowCount;
}
if (group.length > 0) {
groups.push(group);
}
return groups;
}
export interface ContentDeletionOptions {
readonly control?: OperationControl;
/** Progress phase reported after every committed group. */
readonly phase: string;
readonly rowsPerTransaction?: number;
}
/**
* Deletes the content under the counted categories, one budgeted group per
* transaction, with a cooperative checkpoint before each commit and a
* progress report after it. Returns the number of rows SQLite removed.
*/
export async function deleteContentByCategoryGroups(
db: AppDatabase,
counts: readonly CategoryRowCount[],
options: ContentDeletionOptions
): Promise<number> {
const total = sumCategoryRowCounts(counts);
const groups = groupCategoriesByRowBudget(
counts,
options.rowsPerTransaction ?? CONTENT_ROWS_PER_TRANSACTION
);
let deleted = 0;
for (const group of groups) {
await checkpointOperation(options.control);
const changes = await db.transaction(
(tx) =>
tx
.delete(schema.content)
.where(inArray(schema.content.categoryId, group))
.run().changes
);
deleted += changes;
await reportOperationProgress(options.control, {
phase: options.phase,
current: deleted,
total,
increment: changes,
});
}
return deleted;
}
/**
* Deletes every category matching `categoryFilter` in one transaction and
* returns the number of rows removed. Content still referencing one of them
* goes with it through `ON DELETE CASCADE`. Callers scope the filter to the
* category ids they captured earlier (or, for playlist removal, to the
* playlist whose row is about to cascade anyway): the worker serves other
* requests between commits, so a playlist-wide predicate in a refresh could
* erase categories a newer import of the same playlist just created.
*/
export async function deleteCategoriesWhere(
db: AppDatabase,
categoryFilter: SQL | undefined
): Promise<number> {
const filter = requireScopedFilter(categoryFilter);
return db.transaction(
(tx) => tx.delete(schema.categories).where(filter).run().changes
);
}
@@ -107,17 +107,15 @@ describe('content import performance phases', () => {
) )
).resolves.toEqual({ success: true, count: 205 }); ).resolves.toEqual({ success: true, count: 205 });
expect(harness.transaction).toHaveBeenCalledTimes(3); // 205 rows fit one row-budgeted commit made of three 100-row
expect(harness.transactionResults).toEqual([ // statements.
undefined, expect(harness.transaction).toHaveBeenCalledTimes(1);
undefined, expect(harness.transactionResults).toEqual([undefined]);
undefined,
]);
expect( expect(
harness.values.mock.calls.map(([chunk]) => chunk.length) harness.values.mock.calls.map(([chunk]) => chunk.length)
).toEqual([100, 100, 5]); ).toEqual([100, 100, 5]);
expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([ expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([
100, 200, 205, 205,
]); ]);
const writeStart = timeline.indexOf( const writeStart = timeline.indexOf(
'sqlite.content.write-transactions:start' 'sqlite.content.write-transactions:start'
@@ -164,6 +162,38 @@ describe('content import performance phases', () => {
]); ]);
}); });
it('commits 5,000 rows per transaction as fifty 100-row statements', async () => {
const timeline: string[] = [];
const harness = createContentDb(5100, timeline);
const checkpoint = jest.fn();
const onProgress = jest.fn();
await expect(
saveContent(harness.db, 'playlist-1', harness.streams, 'live', {
checkpoint,
onProgress,
})
).resolves.toEqual({ success: true, count: 5100 });
expect(harness.transaction).toHaveBeenCalledTimes(2);
expect(harness.values).toHaveBeenCalledTimes(51);
expect(checkpoint).toHaveBeenCalledTimes(4);
expect(onProgress.mock.calls.map(([value]) => value)).toEqual([
{
phase: 'saving-content',
current: 5000,
total: 5100,
increment: 5000,
},
{
phase: 'saving-content',
current: 5100,
total: 5100,
increment: 100,
},
]);
});
it('closes the aggregate write phase on cancellation without changing the error', async () => { it('closes the aggregate write phase on cancellation without changing the error', async () => {
const timeline: string[] = []; const timeline: string[] = [];
const recording = createRecordingOperationPhaseCapture(); const recording = createRecordingOperationPhaseCapture();
@@ -195,9 +225,14 @@ describe('content import performance phases', () => {
}); });
}); });
it('captures cache deletion as one pair across content and category chunks', async () => { it('captures cache deletion as one pair across content groups and categories', async () => {
const categoryRows = Array.from({ length: 150 }, (_, id) => ({ id })); const categoryRows = Array.from({ length: 150 }, (_, id) => ({ id }));
const contentRows = Array.from({ length: 205 }, (_, id) => ({ id })); // 205 content rows counted over three of the categories.
const contentRowCounts = [
{ categoryId: 0, rowCount: 100 },
{ categoryId: 1, rowCount: 100 },
{ categoryId: 2, rowCount: 5 },
];
const select = jest const select = jest
.fn() .fn()
.mockReturnValueOnce({ .mockReturnValueOnce({
@@ -207,10 +242,16 @@ describe('content import performance phases', () => {
}) })
.mockReturnValueOnce({ .mockReturnValueOnce({
from: jest.fn(() => ({ from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(contentRows), innerJoin: jest.fn(() => ({
where: jest.fn(() => ({
groupBy: jest
.fn()
.mockResolvedValue(contentRowCounts),
})),
})),
})), })),
}); });
const run = jest.fn(); const run = jest.fn(() => ({ changes: 1 }));
const deleteRows = jest.fn(() => ({ const deleteRows = jest.fn(() => ({
where: jest.fn(() => ({ run })), where: jest.fn(() => ({ run })),
})); }));
@@ -227,7 +268,8 @@ describe('content import performance phases', () => {
recording.capture recording.capture
); );
expect(transaction).toHaveBeenCalledTimes(5); // One row-budgeted content group, then the categories.
expect(transaction).toHaveBeenCalledTimes(2);
expect(recording.events).toEqual([ expect(recording.events).toEqual([
{ {
boundary: 'start', boundary: 'start',
@@ -15,6 +15,10 @@ import {
XtreamGlobalSearchResult, XtreamGlobalSearchResult,
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types'; import type { AppDatabase } from '../database.types';
import {
countContentRowsByCategory,
sumCategoryRowCounts,
} from './catalog-deletion';
import { import {
buildCompoundFtsMatchQuery, buildCompoundFtsMatchQuery,
buildCompoundLikePatterns, buildCompoundLikePatterns,
@@ -997,18 +1001,25 @@ export async function clearXtreamImportCache(
: { success: true }; : { success: true };
} }
const contentRows = await db // Count and delete stay scoped to the captured ids, not to the
.select({ id: schema.content.id }) // playlist/type predicate, so a category a concurrent import creates
.from(schema.content) // between the read and the delete survives.
.where(inArray(schema.content.categoryId, categoryIds)); const contentRowCounts = await countContentRowsByCategory(
db,
inArray(schema.categories.id, categoryIds)
);
return capturePhase return capturePhase
? capturePhase.captureAsync( ? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
() => deleteXtreamCacheRows(db, contentRows, categoryIds), () => deleteXtreamCacheRows(db, contentRowCounts, categoryIds),
() => ({ itemCount: contentRows.length + categoryIds.length }) () => ({
itemCount:
sumCategoryRowCounts(contentRowCounts) +
categoryIds.length,
})
) )
: deleteXtreamCacheRows(db, contentRows, categoryIds); : deleteXtreamCacheRows(db, contentRowCounts, categoryIds);
} }
export async function getContentByXtreamId( export async function getContentByXtreamId(
@@ -1,3 +1,4 @@
import * as schema from '@iptvnator/shared/database/schema';
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types'; import type { AppDatabase } from '../database.types';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
@@ -16,25 +17,72 @@ function createSelectStep(
return { from }; return { from };
} }
/** The grouped per-category content count query. */
function createCountStep(
rows: readonly unknown[],
label: string,
timeline: string[]
) {
const groupBy = jest.fn(() => {
timeline.push(label);
return Promise.resolve(rows);
});
const where = jest.fn(() => ({ groupBy }));
const innerJoin = jest.fn(() => ({ where }));
const from = jest.fn(() => ({ innerJoin }));
return { from };
}
const ROWS_PER_TABLE = new Map<unknown, number>([
[schema.favorites, 2],
[schema.recentlyViewed, 1],
[schema.playbackPositions, 0],
[schema.content, 205],
[schema.categories, 2],
]);
function createDeleteHarness() { function createDeleteHarness() {
const timeline: string[] = []; const timeline: string[] = [];
const selections = [ const select = jest
['select:favorites', [{ id: 1 }, { id: 2 }]], .fn()
['select:recently-viewed', [{ id: 3 }]], .mockReturnValueOnce(
['select:playback-positions', []], createSelectStep([{ count: 2 }], 'select:favorites', timeline)
['select:categories', [{ id: 10 }, { id: 11 }]], )
[ .mockReturnValueOnce(
'select:content', createSelectStep(
Array.from({ length: 205 }, (_, id) => ({ id: id + 100 })), [{ count: 1 }],
], 'select:recently-viewed',
] as const; timeline
const select = jest.fn(); )
for (const [label, rows] of selections) { )
select.mockReturnValueOnce(createSelectStep(rows, label, timeline)); .mockReturnValueOnce(
} createSelectStep(
const transactionRun = jest.fn(); [{ count: 0 }],
const transactionDelete = jest.fn(() => ({ 'select:playback-positions',
where: jest.fn(() => ({ run: transactionRun })), timeline
)
)
.mockReturnValueOnce(
createSelectStep(
[{ id: 10 }, { id: 11 }],
'select:categories',
timeline
)
)
.mockReturnValueOnce(
createCountStep(
[
{ categoryId: 10, rowCount: 120 },
{ categoryId: 11, rowCount: 85 },
],
'select:content',
timeline
)
);
const transactionDelete = jest.fn((table: unknown) => ({
where: jest.fn(() => ({
run: jest.fn(() => ({ changes: ROWS_PER_TABLE.get(table) ?? 0 })),
})),
})); }));
const transaction = jest.fn((execute: (tx: unknown) => unknown) => { const transaction = jest.fn((execute: (tx: unknown) => unknown) => {
timeline.push('transaction'); timeline.push('transaction');
@@ -79,10 +127,20 @@ describe('playlist delete performance phases', () => {
) )
).resolves.toEqual({ success: true }); ).resolves.toEqual({ success: true });
expect(harness.transaction).toHaveBeenCalledTimes(6); // Favorites, recently viewed, one row-budgeted content group and the
// Six committed chunks plus the final playlist row each keep both // categories commit once each; the empty playback-position stage is
// skipped.
expect(harness.transaction).toHaveBeenCalledTimes(4);
// Four committed stages plus the final playlist row each keep both
// the pre-write and post-progress cooperative checkpoints. // the pre-write and post-progress cooperative checkpoints.
expect(checkpoint).toHaveBeenCalledTimes(14); expect(checkpoint).toHaveBeenCalledTimes(10);
expect(onProgress.mock.calls.map(([value]) => value.phase)).toEqual([
'deleting-favorites',
'deleting-recently-viewed',
'deleting-content',
'deleting-categories',
'deleting-playlist',
]);
expect(harness.timeline.slice(1, 6)).toEqual([ expect(harness.timeline.slice(1, 6)).toEqual([
'select:favorites', 'select:favorites',
'select:recently-viewed', 'select:recently-viewed',
@@ -1,4 +1,4 @@
import { eq, inArray } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema'; import * as schema from '@iptvnator/shared/database/schema';
import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces'; import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces';
import { import {
@@ -10,9 +10,15 @@ import {
type PerformancePhaseMetadata, type PerformancePhaseMetadata,
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types'; import type { AppDatabase } from '../database.types';
import {
type CategoryRowCount,
countContentRowsByCategory,
deleteCategoriesWhere,
deleteContentByCategoryGroups,
sumCategoryRowCounts,
} from './catalog-deletion';
import { import {
checkpointOperation, checkpointOperation,
chunkValues,
type OperationControl, type OperationControl,
reportOperationProgress, reportOperationProgress,
} from './operation-control'; } from './operation-control';
@@ -26,8 +32,6 @@ const PLAYLIST_TYPES = {
M3U_URL: 'm3u-url', M3U_URL: 'm3u-url',
} as const; } as const;
const DEFAULT_BATCH_SIZE = 100;
export interface AppPlaylistUpsertPhaseCapture { export interface AppPlaylistUpsertPhaseCapture {
captureAsync: <TResult>( captureAsync: <TResult>(
phase: AppPlaylistUpsertPerformancePhase, phase: AppPlaylistUpsertPerformancePhase,
@@ -590,30 +594,37 @@ export async function updatePlaylist(
interface PlaylistDeletionCollection { interface PlaylistDeletionCollection {
readonly categoryIds: number[]; readonly categoryIds: number[];
readonly contentRows: Array<{ id: number }>; /** Content rows per category, the unit the delete is batched by. */
readonly favoriteRows: Array<{ id: number }>; readonly contentRowCounts: CategoryRowCount[];
readonly playbackPositionRows: Array<{ id: number }>; readonly favoriteCount: number;
readonly recentlyViewedRows: Array<{ id: number }>; readonly playbackPositionCount: number;
readonly recentlyViewedCount: number;
}
async function countPlaylistRows(
db: AppDatabase,
table:
| typeof schema.favorites
| typeof schema.playbackPositions
| typeof schema.recentlyViewed,
playlistId: string
): Promise<number> {
const rows = await db
.select({ count: sql<number>`count(*)` })
.from(table)
.where(eq(table.playlistId, playlistId));
return rows[0]?.count ?? 0;
} }
async function collectPlaylistDeletionRows( async function collectPlaylistDeletionRows(
db: AppDatabase, db: AppDatabase,
playlistId: string playlistId: string
): Promise<PlaylistDeletionCollection> { ): Promise<PlaylistDeletionCollection> {
const [favoriteRows, recentlyViewedRows, playbackPositionRows] = const [favoriteCount, recentlyViewedCount, playbackPositionCount] =
await Promise.all([ await Promise.all([
db countPlaylistRows(db, schema.favorites, playlistId),
.select({ id: schema.favorites.id }) countPlaylistRows(db, schema.recentlyViewed, playlistId),
.from(schema.favorites) countPlaylistRows(db, schema.playbackPositions, playlistId),
.where(eq(schema.favorites.playlistId, playlistId)),
db
.select({ id: schema.recentlyViewed.id })
.from(schema.recentlyViewed)
.where(eq(schema.recentlyViewed.playlistId, playlistId)),
db
.select({ id: schema.playbackPositions.id })
.from(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId)),
]); ]);
const categoryRows = await db const categoryRows = await db
@@ -621,20 +632,20 @@ async function collectPlaylistDeletionRows(
.from(schema.categories) .from(schema.categories)
.where(eq(schema.categories.playlistId, playlistId)); .where(eq(schema.categories.playlistId, playlistId));
const categoryIds = categoryRows.map((category) => category.id); const categoryIds = categoryRows.map((category) => category.id);
const contentRows = const contentRowCounts =
categoryIds.length > 0 categoryIds.length > 0
? await db ? await countContentRowsByCategory(
.select({ id: schema.content.id }) db,
.from(schema.content) eq(schema.categories.playlistId, playlistId)
.where(inArray(schema.content.categoryId, categoryIds)) )
: []; : [];
return { return {
categoryIds, categoryIds,
contentRows, contentRowCounts,
favoriteRows, favoriteCount,
playbackPositionRows, playbackPositionCount,
recentlyViewedRows, recentlyViewedCount,
}; };
} }
@@ -642,68 +653,84 @@ function countPlaylistDeletionRows(
collection: PlaylistDeletionCollection collection: PlaylistDeletionCollection
): number { ): number {
return ( return (
collection.favoriteRows.length + collection.favoriteCount +
collection.recentlyViewedRows.length + collection.recentlyViewedCount +
collection.playbackPositionRows.length + collection.playbackPositionCount +
collection.contentRows.length + sumCategoryRowCounts(collection.contentRowCounts) +
collection.categoryIds.length collection.categoryIds.length
); );
} }
/**
* Removes the playlist's rows table by table so progress and cancellation
* keep their stage granularity, then the playlist row itself. User-data
* tables go in one statement each (they are playlist-indexed and small next
* to the catalog); content goes in row-budgeted category groups; categories
* go in one statement. A stage with nothing counted is skipped — the final
* playlist delete cascades anything that appeared in between.
*/
async function deleteCollectedPlaylistRows( async function deleteCollectedPlaylistRows(
db: AppDatabase, db: AppDatabase,
playlistId: string, playlistId: string,
collection: PlaylistDeletionCollection, collection: PlaylistDeletionCollection,
control?: OperationControl control?: OperationControl
): Promise<number> { ): Promise<number> {
for (const [phase, ids, column, table] of [ const userDataStages = [
[ {
'deleting-favorites', phase: 'deleting-favorites',
collection.favoriteRows.map((row) => row.id), expected: collection.favoriteCount,
schema.favorites.id, table: schema.favorites,
schema.favorites, },
], {
[ phase: 'deleting-recently-viewed',
'deleting-recently-viewed', expected: collection.recentlyViewedCount,
collection.recentlyViewedRows.map((row) => row.id), table: schema.recentlyViewed,
schema.recentlyViewed.id, },
schema.recentlyViewed, {
], phase: 'deleting-playback-positions',
[ expected: collection.playbackPositionCount,
'deleting-playback-positions', table: schema.playbackPositions,
collection.playbackPositionRows.map((row) => row.id), },
schema.playbackPositions.id, ] as const;
schema.playbackPositions,
],
[
'deleting-content',
collection.contentRows.map((row) => row.id),
schema.content.id,
schema.content,
],
[
'deleting-categories',
collection.categoryIds,
schema.categories.id,
schema.categories,
],
] as const) {
let current = 0;
const total = ids.length;
for (const chunk of chunkValues(ids, DEFAULT_BATCH_SIZE)) { for (const stage of userDataStages) {
await checkpointOperation(control); if (stage.expected === 0) {
await db.transaction((tx) => { continue;
tx.delete(table).where(inArray(column, chunk)).run();
});
current += chunk.length;
await reportOperationProgress(control, {
phase,
current,
total,
increment: chunk.length,
});
} }
await checkpointOperation(control);
const changes = await db.transaction(
(tx) =>
tx
.delete(stage.table)
.where(eq(stage.table.playlistId, playlistId))
.run().changes
);
await reportOperationProgress(control, {
phase: stage.phase,
current: changes,
total: stage.expected,
increment: changes,
});
}
await deleteContentByCategoryGroups(db, collection.contentRowCounts, {
control,
phase: 'deleting-content',
});
const totalCategories = collection.categoryIds.length;
if (totalCategories > 0) {
await checkpointOperation(control);
const changes = await deleteCategoriesWhere(
db,
eq(schema.categories.playlistId, playlistId)
);
await reportOperationProgress(control, {
phase: 'deleting-categories',
current: changes,
total: totalCategories,
increment: changes,
});
} }
await checkpointOperation(control); await checkpointOperation(control);
@@ -1,6 +1,12 @@
import { inArray } from 'drizzle-orm'; import { inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema'; import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types'; import type { AppDatabase } from '../database.types';
import {
type CategoryRowCount,
CONTENT_ROWS_PER_TRANSACTION,
deleteCategoriesWhere,
deleteContentByCategoryGroups,
} from './catalog-deletion';
import { scoreSearchTextMatch } from './content-search.util'; import { scoreSearchTextMatch } from './content-search.util';
import { import {
checkpointOperation, checkpointOperation,
@@ -9,6 +15,18 @@ import {
reportOperationProgress, reportOperationProgress,
} from './operation-control'; } from './operation-control';
/**
* Rows per multi-row INSERT. Drizzle binds one parameter per column an
* `XtreamContentValue` supplies (eleven; the rest are emitted as `default`),
* so a statement carries 1,100 parameters, well under SQLite's 32,766 limit.
* A single statement for a whole 5,000-row commit would exceed it.
*/
const INSERT_ROWS_PER_STATEMENT = 100;
const INSERT_STATEMENTS_PER_TRANSACTION = Math.max(
1,
Math.floor(CONTENT_ROWS_PER_TRANSACTION / INSERT_ROWS_PER_STATEMENT)
);
export type XtreamContentValue = { export type XtreamContentValue = {
categoryId: number; categoryId: number;
title: string; title: string;
@@ -48,58 +66,63 @@ export async function writeXtreamContentValues(
control?: OperationControl control?: OperationControl
): Promise<{ success: boolean; count: number }> { ): Promise<{ success: boolean; count: number }> {
const total = values.length; const total = values.length;
const chunkSize = 100;
let totalInserted = 0; let totalInserted = 0;
for (let index = 0; index < values.length; index += chunkSize) { // One commit per CONTENT_ROWS_PER_TRANSACTION rows, made of
// statement-sized chunks: see catalog-deletion.ts for why a commit every
// 100 rows is the expensive part of a large import.
for (const statements of chunkValues(
chunkValues(values, INSERT_ROWS_PER_STATEMENT),
INSERT_STATEMENTS_PER_TRANSACTION
)) {
await checkpointOperation(control); await checkpointOperation(control);
const chunk = values.slice(index, index + chunkSize);
await db.transaction((tx) => { await db.transaction((tx) => {
tx.insert(schema.content) for (const chunk of statements) {
.values(chunk) tx.insert(schema.content)
.onConflictDoNothing({ .values(chunk)
target: [ .onConflictDoNothing({
schema.content.categoryId, target: [
schema.content.type, schema.content.categoryId,
schema.content.xtreamId, schema.content.type,
], schema.content.xtreamId,
}) ],
.run(); })
.run();
}
}); });
totalInserted += chunk.length; const inserted = statements.reduce(
(count, chunk) => count + chunk.length,
0
);
totalInserted += inserted;
await reportOperationProgress(control, { await reportOperationProgress(control, {
phase: 'saving-content', phase: 'saving-content',
current: totalInserted, current: totalInserted,
total, total,
increment: chunk.length, increment: inserted,
}); });
} }
return { success: true, count: totalInserted }; return { success: true, count: totalInserted };
} }
/**
* Drops one content type's cached catalog: content in row-budgeted category
* groups, then exactly the captured categories in one statement.
*/
export async function deleteXtreamCacheRows( export async function deleteXtreamCacheRows(
db: AppDatabase, db: AppDatabase,
contentRows: Array<{ id: number }>, contentRowCounts: readonly CategoryRowCount[],
categoryIds: number[] categoryIds: readonly number[]
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
for (const chunk of chunkValues( await deleteContentByCategoryGroups(db, contentRowCounts, {
contentRows.map((row) => row.id), phase: 'deleting-content',
100 });
)) { if (categoryIds.length > 0) {
await db.transaction((tx) => { await deleteCategoriesWhere(
tx.delete(schema.content) db,
.where(inArray(schema.content.id, chunk)) inArray(schema.categories.id, [...categoryIds])
.run(); );
});
}
for (const chunk of chunkValues(categoryIds, 100)) {
await db.transaction((tx) => {
tx.delete(schema.categories)
.where(inArray(schema.categories.id, chunk))
.run();
});
} }
return { success: true }; return { success: true };
@@ -1,8 +1,17 @@
import type { SQL } from 'drizzle-orm';
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
import * as schema from '@iptvnator/shared/database/schema';
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types'; import type { AppDatabase } from '../database.types';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
import { deleteXtreamContent } from './xtream.operations'; import { deleteXtreamContent } from './xtream.operations';
/** Renders a drizzle filter the way the driver would, for asserting its scope. */
function renderFilter(filter: SQL): { sql: string; params: unknown[] } {
const query = new SQLiteSyncDialect().sqlToQuery(filter);
return { sql: query.sql, params: query.params };
}
function createSelectStep( function createSelectStep(
rows: readonly unknown[], rows: readonly unknown[],
label: string, label: string,
@@ -17,6 +26,26 @@ function createSelectStep(
return { from }; return { from };
} }
/** The grouped per-category content count query. */
function createCountStep(
rows: readonly unknown[],
label: string,
timeline: string[],
filters: SQL[]
) {
const groupBy = jest.fn(() => {
timeline.push(label);
return Promise.resolve(rows);
});
const where = jest.fn((filter: SQL) => {
filters.push(filter);
return { groupBy };
});
const innerJoin = jest.fn(() => ({ where }));
const from = jest.fn(() => ({ innerJoin }));
return { from };
}
function createDeleteHarness() { function createDeleteHarness() {
const timeline: string[] = []; const timeline: string[] = [];
const categories = [ const categories = [
@@ -38,7 +67,13 @@ function createDeleteHarness() {
xtreamId: 202, xtreamId: 202,
}, },
]; ];
const content = Array.from({ length: 205 }, (_, id) => ({ id: id + 1 })); // 205 content rows spread over the two categories.
const contentRowCounts = [
{ categoryId: 11, rowCount: 120 },
{ categoryId: 12, rowCount: 85 },
];
const countFilters: SQL[] = [];
const deleteFilters: Array<{ table: unknown; filter: SQL }> = [];
const select = jest const select = jest
.fn() .fn()
.mockReturnValueOnce( .mockReturnValueOnce(
@@ -51,11 +86,22 @@ function createDeleteHarness() {
createSelectStep(recentlyViewed, 'select:recently-viewed', timeline) createSelectStep(recentlyViewed, 'select:recently-viewed', timeline)
) )
.mockReturnValueOnce( .mockReturnValueOnce(
createSelectStep(content, 'select:content', timeline) createCountStep(
contentRowCounts,
'select:content',
timeline,
countFilters
)
); );
const run = jest.fn(); const deleteRows = jest.fn((table: unknown) => ({
const deleteRows = jest.fn(() => ({ where: jest.fn((filter: SQL) => {
where: jest.fn(() => ({ run })), deleteFilters.push({ table, filter });
return {
run: jest.fn(() => ({
changes: table === schema.content ? 205 : 2,
})),
};
}),
})); }));
const transaction = jest.fn((execute: (tx: unknown) => unknown) => { const transaction = jest.fn((execute: (tx: unknown) => unknown) => {
timeline.push('transaction'); timeline.push('transaction');
@@ -63,8 +109,9 @@ function createDeleteHarness() {
}); });
return { return {
content, countFilters,
db: { select, transaction } as unknown as AppDatabase, db: { select, transaction } as unknown as AppDatabase,
deleteFilters,
timeline, timeline,
transaction, transaction,
}; };
@@ -79,9 +126,16 @@ describe('Xtream delete performance phases', () => {
const checkpoint = jest.fn(() => { const checkpoint = jest.fn(() => {
harness.timeline.push('checkpoint'); harness.timeline.push('checkpoint');
}); });
const onProgress = jest.fn((progress: { current?: number }) => { const onProgress = jest.fn(
harness.timeline.push(`progress:${progress.current}`); (progress: {
}); phase: string;
current?: number;
total?: number;
increment?: number;
}) => {
harness.timeline.push(`progress:${progress.current}`);
}
);
await expect( await expect(
deleteXtreamContent( deleteXtreamContent(
@@ -110,12 +164,25 @@ describe('Xtream delete performance phases', () => {
success: true, success: true,
}); });
expect(harness.transaction).toHaveBeenCalledTimes(4); // 205 rows fit one row-budgeted category group, then the categories
// Existing progress reporting performs a second cooperative // go in a single statement.
// checkpoint after each committed batch. expect(harness.transaction).toHaveBeenCalledTimes(2);
expect(checkpoint).toHaveBeenCalledTimes(8); // Progress reporting performs a second cooperative checkpoint after
expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([ // each committed batch.
100, 200, 205, 2, expect(checkpoint).toHaveBeenCalledTimes(4);
expect(onProgress.mock.calls.map(([value]) => value)).toEqual([
{
phase: 'deleting-content',
current: 205,
total: 205,
increment: 205,
},
{
phase: 'deleting-categories',
current: 2,
total: 2,
increment: 2,
},
]); ]);
expect(harness.timeline.slice(1, 5)).toEqual([ expect(harness.timeline.slice(1, 5)).toEqual([
'select:categories', 'select:categories',
@@ -123,6 +190,29 @@ describe('Xtream delete performance phases', () => {
'select:recently-viewed', 'select:recently-viewed',
'select:content', 'select:content',
]); ]);
// The count and both deletes stay scoped to the category ids the
// collection read, never to the playlist: a newer import of the same
// playlist may create categories between the read and the delete.
expect(harness.countFilters.map(renderFilter)).toEqual([
{ sql: '"categories"."id" in (?, ?)', params: [11, 12] },
]);
expect(
harness.deleteFilters.map(({ table, filter }) => ({
table: table === schema.content ? 'content' : 'categories',
...renderFilter(filter),
}))
).toEqual([
{
table: 'content',
sql: '"content"."category_id" in (?, ?)',
params: [11, 12],
},
{
table: 'categories',
sql: '"categories"."id" in (?, ?)',
params: [11, 12],
},
]);
const writeStart = harness.timeline.indexOf( const writeStart = harness.timeline.indexOf(
'sqlite.xtream-delete.write-transactions:start' 'sqlite.xtream-delete.write-transactions:start'
); );
@@ -7,6 +7,13 @@ import {
type XtreamBackupRecentlyViewedItem, type XtreamBackupRecentlyViewedItem,
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types'; import type { AppDatabase } from '../database.types';
import {
type CategoryRowCount,
countContentRowsByCategory,
deleteCategoriesWhere,
deleteContentByCategoryGroups,
sumCategoryRowCounts,
} from './catalog-deletion';
import { import {
checkpointOperation, checkpointOperation,
chunkValues, chunkValues,
@@ -15,6 +22,7 @@ import {
} from './operation-control'; } from './operation-control';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
/** Favorites and recently-viewed rows restored per transaction. */
const DEFAULT_BATCH_SIZE = 100; const DEFAULT_BATCH_SIZE = 100;
type ContentIdentity = { type ContentIdentity = {
@@ -32,7 +40,8 @@ function toContentIdentityKey(
interface XtreamDeletionCollection { interface XtreamDeletionCollection {
readonly categoryIds: number[]; readonly categoryIds: number[];
readonly contentRows: Array<{ id: number }>; /** Content rows per category, the unit the delete is batched by. */
readonly contentRowCounts: CategoryRowCount[];
readonly favorites: XtreamBackupFavoriteItem[]; readonly favorites: XtreamBackupFavoriteItem[];
readonly hiddenCategories: XtreamBackupHiddenCategory[]; readonly hiddenCategories: XtreamBackupHiddenCategory[];
readonly recentlyViewed: XtreamBackupRecentlyViewedItem[]; readonly recentlyViewed: XtreamBackupRecentlyViewedItem[];
@@ -62,7 +71,7 @@ async function collectXtreamDeletionRows(
let favorites: XtreamBackupFavoriteItem[] = []; let favorites: XtreamBackupFavoriteItem[] = [];
let recentlyViewed: XtreamBackupRecentlyViewedItem[] = []; let recentlyViewed: XtreamBackupRecentlyViewedItem[] = [];
let contentRows: Array<{ id: number }> = []; let contentRowCounts: CategoryRowCount[] = [];
if (categoryIds.length > 0) { if (categoryIds.length > 0) {
const favoritedContent = await db const favoritedContent = await db
@@ -115,71 +124,55 @@ async function collectXtreamDeletionRows(
viewedAt: item.viewedAt || new Date().toISOString(), viewedAt: item.viewedAt || new Date().toISOString(),
})); }));
contentRows = await db contentRowCounts = await countContentRowsByCategory(
.select({ id: schema.content.id }) db,
.from(schema.content) inArray(schema.categories.id, categoryIds)
.where(inArray(schema.content.categoryId, categoryIds)); );
} }
return { return {
categoryIds, categoryIds,
contentRows, contentRowCounts,
favorites, favorites,
hiddenCategories, hiddenCategories,
recentlyViewed, recentlyViewed,
}; };
} }
/**
* Drops the collected catalog: content in row-budgeted category groups, then
* the captured categories in one statement. Both stay scoped to the ids the
* collection step read, never to the whole playlist: the worker serves other
* requests between commits, so a newer import of the same playlist may have
* created categories this refresh must not erase. Returns the number of
* deletion candidates (content rows plus categories) for phase metadata.
*/
async function deleteCollectedXtreamRows( async function deleteCollectedXtreamRows(
db: AppDatabase, db: AppDatabase,
collection: XtreamDeletionCollection, collection: XtreamDeletionCollection,
control?: OperationControl control?: OperationControl
): Promise<number> { ): Promise<number> {
let deletedContent = 0; await deleteContentByCategoryGroups(db, collection.contentRowCounts, {
const totalContent = collection.contentRows.length; control,
phase: 'deleting-content',
});
for (const chunk of chunkValues(
collection.contentRows.map((content) => content.id),
DEFAULT_BATCH_SIZE
)) {
await checkpointOperation(control);
await db.transaction((tx) => {
tx.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
deletedContent += chunk.length;
await reportOperationProgress(control, {
phase: 'deleting-content',
current: deletedContent,
total: totalContent,
increment: chunk.length,
});
}
let deletedCategories = 0;
const totalCategories = collection.categoryIds.length; const totalCategories = collection.categoryIds.length;
if (totalCategories > 0) {
for (const chunk of chunkValues(
collection.categoryIds,
DEFAULT_BATCH_SIZE
)) {
await checkpointOperation(control); await checkpointOperation(control);
await db.transaction((tx) => { const deletedCategories = await deleteCategoriesWhere(
tx.delete(schema.categories) db,
.where(inArray(schema.categories.id, chunk)) inArray(schema.categories.id, collection.categoryIds)
.run(); );
});
deletedCategories += chunk.length;
await reportOperationProgress(control, { await reportOperationProgress(control, {
phase: 'deleting-categories', phase: 'deleting-categories',
current: deletedCategories, current: deletedCategories,
total: totalCategories, total: totalCategories,
increment: chunk.length, increment: deletedCategories,
}); });
} }
return totalContent + totalCategories; return sumCategoryRowCounts(collection.contentRowCounts) + totalCategories;
} }
export async function deleteXtreamContent( export async function deleteXtreamContent(
@@ -199,7 +192,8 @@ export async function deleteXtreamContent(
() => collectXtreamDeletionRows(db, playlistId), () => collectXtreamDeletionRows(db, playlistId),
(result) => ({ (result) => ({
itemCount: itemCount:
result.contentRows.length + result.categoryIds.length, sumCategoryRowCounts(result.contentRowCounts) +
result.categoryIds.length,
}) })
) )
: await collectXtreamDeletionRows(db, playlistId); : await collectXtreamDeletionRows(db, playlistId);
@@ -0,0 +1,236 @@
import { MessageChannel, type MessagePort } from 'node:worker_threads';
import type {
DbOperationEvent,
DbWorkerIncomingMessage,
DbWorkerMessage,
DbWorkerResponseMessage,
} from './database-worker.types';
/**
* Pins the worker-side wiring of `operation-progress-throttle.ts`: reports
* arriving inside the throttle interval are coalesced, and whatever is still
* pending is emitted before the terminal event, so a consumer summing
* `increment` never ends up short of the rows that actually landed.
*/
const BATCH_DELAY_ENV = 'IPTVNATOR_DB_WORKER_BATCH_DELAY_MS';
const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
const originalBatchDelay = process.env[BATCH_DELAY_ENV];
const originalWorkerProfiling = process.env[WORKER_PROFILING_ENV];
type ProgressControl = {
checkpoint: () => Promise<void>;
onProgress: (progress: {
phase: string;
current: number;
total: number;
increment: number;
}) => Promise<void>;
};
function restoreEnvironment(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
describe('database worker progress throttle wiring', () => {
let workerPort: MessagePort | null = null;
let clientPort: MessagePort | null = null;
afterEach(() => {
workerPort?.close();
clientPort?.close();
workerPort = null;
clientPort = null;
restoreEnvironment(BATCH_DELAY_ENV, originalBatchDelay);
restoreEnvironment(WORKER_PROFILING_ENV, originalWorkerProfiling);
jest.restoreAllMocks();
jest.resetModules();
});
/**
* Boots the worker against a `saveContent` stand-in that drives the
* operation control however the test wants, and returns every message the
* worker posts plus its final response.
*/
async function runSaveContent(
drive: (control: ProgressControl) => Promise<void>
): Promise<{
events: DbOperationEvent[];
response: DbWorkerResponseMessage;
}> {
delete process.env[BATCH_DELAY_ENV];
delete process.env[WORKER_PROFILING_ENV];
const channel = new MessageChannel();
workerPort = channel.port1;
clientPort = channel.port2;
const events: DbOperationEvent[] = [];
let settleResponse!: (response: DbWorkerResponseMessage) => void;
const responsePromise = new Promise<DbWorkerResponseMessage>(
(resolve) => {
settleResponse = resolve;
}
);
clientPort.on('message', (message: DbWorkerMessage) => {
if (message.type === 'event') {
events.push(message.event);
}
if (message.type === 'response') {
settleResponse(message);
}
});
jest.doMock('worker_threads', () => ({
...jest.requireActual('worker_threads'),
parentPort: workerPort,
}));
jest.doMock('./database.worker-connection', () => ({
closeWorkerDatabase: jest.fn(),
getWorkerDatabase: jest.fn().mockResolvedValue({}),
}));
jest.doMock('../database/operations/content.operations', () => ({
saveContent: jest.fn(
async (
_db: unknown,
_playlistId: string,
_streams: unknown[],
_type: string,
control: ProgressControl
) => {
await drive(control);
return { count: 3, success: true };
}
),
}));
jest.doMock('./worker-performance-capture', () => ({
armWorkerPerformanceCapture: jest.fn(),
executeWithWorkerPerformanceCapture: jest.fn(
async (_capture: unknown, execute: () => Promise<unknown>) => {
try {
return {
error: null,
performance: undefined,
result: await execute(),
success: true,
};
} catch (error) {
return {
error,
performance: undefined,
result: undefined,
success: false,
};
}
}
),
registerDatabaseWorkerPerformanceCapture: jest.fn(),
releaseDatabaseWorkerPerformanceCapture: jest.fn(),
stampWorkerPerformanceResponsePostedEpoch: jest.fn(
(_capture: unknown, performance: unknown) => performance
),
startWorkerPerformanceCapture: jest.fn(() => null),
}));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
await import('./database.worker');
const request: DbWorkerIncomingMessage = {
type: 'request',
operation: 'DB_SAVE_CONTENT',
payload: {
operationId: 'operation-throttle',
playlistId: 'playlist-1',
streams: [{ stream_id: 1 }],
type: 'live',
},
requestId: 'request-throttle',
};
clientPort.postMessage(request);
return { events, response: await responsePromise };
}
const report = (current: number) => ({
phase: 'saving-content',
current,
total: 10,
increment: 1,
});
it('coalesces reports inside the interval and flushes the rest before completed', async () => {
const { events, response } = await runSaveContent(async (control) => {
// Three back-to-back reports land inside one 100 ms window and
// none of them reaches the total, so only the first may pass on
// its own; the other two must ride on the flush.
await control.onProgress(report(1));
await control.onProgress(report(2));
await control.onProgress(report(3));
});
expect(response).toEqual(
expect.objectContaining({
requestId: 'request-throttle',
success: true,
type: 'response',
})
);
const progress = events.filter((event) => event.status === 'progress');
expect(
progress.map(({ current, increment }) => ({ current, increment }))
).toEqual([
{ current: 1, increment: 1 },
{ current: 3, increment: 2 },
]);
expect(
progress.reduce((sum, event) => sum + (event.increment ?? 0), 0)
).toBe(3);
expect(events.map((event) => event.status)).toEqual([
'started',
'progress',
'progress',
'completed',
]);
// The terminal event inherits the flushed `current`; its `total` is
// the saved count the save-content case supplies itself.
expect(events.at(-1)).toEqual(
expect.objectContaining({
current: 3,
operationId: 'operation-throttle',
status: 'completed',
})
);
});
it('flushes the pending report before a cancelled event too', async () => {
const { events, response } = await runSaveContent(async (control) => {
await control.onProgress(report(1));
await control.onProgress(report(2));
const abort = new Error('cancelled between commits');
abort.name = 'AbortError';
throw abort;
});
expect(response).toEqual(
expect.objectContaining({
error: expect.objectContaining({ name: 'AbortError' }),
success: false,
})
);
expect(
events.map(({ status, current, increment }) => ({
status,
current,
increment,
}))
).toEqual([
{ status: 'started', current: 0, increment: undefined },
{ status: 'progress', current: 1, increment: 1 },
{ status: 'progress', current: 2, increment: 1 },
{ status: 'cancelled', current: 2, increment: undefined },
]);
});
});
@@ -125,12 +125,22 @@ import {
isDatabaseWorkerPostGcHeapRequest, isDatabaseWorkerPostGcHeapRequest,
} from './database-worker-post-gc-heap'; } from './database-worker-post-gc-heap';
import { publishDatabaseWorkerCancelReceipt } from './database-worker-cancel-receipt'; import { publishDatabaseWorkerCancelReceipt } from './database-worker-cancel-receipt';
import {
createProgressEventThrottle,
type ProgressEventThrottle,
} from './operation-progress-throttle';
const loggerLabel = '[DB Worker]'; const loggerLabel = '[DB Worker]';
const batchDelayMs = Number.parseInt( const batchDelayMs = Number.parseInt(
process.env['IPTVNATOR_DB_WORKER_BATCH_DELAY_MS'] ?? '0', process.env['IPTVNATOR_DB_WORKER_BATCH_DELAY_MS'] ?? '0',
10 10
); );
/**
* Shortest gap between two progress events of one operation. Phase starts,
* phase completions and terminal events are never held back; see
* `operation-progress-throttle.ts`.
*/
const progressEventMinIntervalMs = 100;
type ActiveOperationState = { type ActiveOperationState = {
cancelled: boolean; cancelled: boolean;
@@ -240,6 +250,9 @@ function createOperationController(
} }
let lastEvent: Partial<DbOperationEvent> = {}; let lastEvent: Partial<DbOperationEvent> = {};
const progressThrottle = createProgressEventThrottle({
minIntervalMs: progressEventMinIntervalMs,
});
const send = ( const send = (
status: DbOperationEvent['status'], status: DbOperationEvent['status'],
@@ -277,23 +290,34 @@ function createOperationController(
} }
}; };
const sendProgress = (
updates: ReturnType<ProgressEventThrottle['push']>
): void => {
for (const update of updates) {
send('progress', update);
}
};
return { return {
control: { control: {
checkpoint, checkpoint,
onProgress: async (progress) => { onProgress: async (progress) => {
send('progress', progress); sendProgress(progressThrottle.push(progress));
}, },
}, },
emitStarted: (event) => { emitStarted: (event) => {
send('started', event); send('started', event);
}, },
emitCompleted: (event) => { emitCompleted: (event) => {
sendProgress(progressThrottle.flush());
send('completed', event); send('completed', event);
}, },
emitCancelled: (event) => { emitCancelled: (event) => {
sendProgress(progressThrottle.flush());
send('cancelled', event); send('cancelled', event);
}, },
emitError: (error, event) => { emitError: (error, event) => {
sendProgress(progressThrottle.flush());
send('error', { send('error', {
...event, ...event,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@@ -0,0 +1,178 @@
import { createProgressEventThrottle } from './operation-progress-throttle';
function createClock(start = 1_000) {
let now = start;
return {
now: () => now,
advance: (ms: number) => {
now += ms;
},
};
}
describe('createProgressEventThrottle', () => {
it('emits the first report of a phase immediately', () => {
const clock = createClock();
const throttle = createProgressEventThrottle({
minIntervalMs: 100,
now: clock.now,
});
expect(
throttle.push({
phase: 'deleting-content',
current: 10,
total: 1000,
increment: 10,
})
).toEqual([
{
phase: 'deleting-content',
current: 10,
total: 1000,
increment: 10,
},
]);
});
it('coalesces reports inside the interval and sums their increments', () => {
const clock = createClock();
const throttle = createProgressEventThrottle({
minIntervalMs: 100,
now: clock.now,
});
throttle.push({
phase: 'deleting-content',
current: 10,
total: 1000,
increment: 10,
});
clock.advance(20);
expect(
throttle.push({
phase: 'deleting-content',
current: 20,
total: 1000,
increment: 10,
})
).toEqual([]);
clock.advance(20);
expect(
throttle.push({
phase: 'deleting-content',
current: 30,
total: 1000,
increment: 10,
})
).toEqual([]);
clock.advance(60);
expect(
throttle.push({
phase: 'deleting-content',
current: 40,
total: 1000,
increment: 10,
})
).toEqual([
{
phase: 'deleting-content',
current: 40,
total: 1000,
increment: 30,
},
]);
});
it('never holds back a report that reaches its total', () => {
const clock = createClock();
const throttle = createProgressEventThrottle({
minIntervalMs: 100,
now: clock.now,
});
throttle.push({ phase: 'saving-content', current: 100, total: 205 });
clock.advance(1);
expect(
throttle.push({ phase: 'saving-content', current: 205, total: 205 })
).toEqual([{ phase: 'saving-content', current: 205, total: 205 }]);
});
it('flushes the pending report of a phase before starting the next one', () => {
const clock = createClock();
const throttle = createProgressEventThrottle({
minIntervalMs: 100,
now: clock.now,
});
throttle.push({
phase: 'deleting-content',
current: 10,
total: 1000,
increment: 10,
});
clock.advance(1);
throttle.push({
phase: 'deleting-content',
current: 20,
total: 1000,
increment: 10,
});
clock.advance(1);
expect(
throttle.push({
phase: 'deleting-categories',
current: 1,
total: 3,
increment: 1,
})
).toEqual([
{
phase: 'deleting-content',
current: 20,
total: 1000,
increment: 10,
},
{
phase: 'deleting-categories',
current: 1,
total: 3,
increment: 1,
},
]);
});
it('hands back the pending report on flush, once', () => {
const clock = createClock();
const throttle = createProgressEventThrottle({
minIntervalMs: 100,
now: clock.now,
});
throttle.push({ phase: 'saving-content', current: 1, total: 9 });
clock.advance(1);
throttle.push({ phase: 'saving-content', current: 2, total: 9 });
expect(throttle.flush()).toEqual([
{ phase: 'saving-content', current: 2, total: 9 },
]);
expect(throttle.flush()).toEqual([]);
});
it('keeps increment undefined when no coalesced report carried one', () => {
const clock = createClock();
const throttle = createProgressEventThrottle({
minIntervalMs: 100,
now: clock.now,
});
throttle.push({ phase: 'saving-content', current: 1, total: 9 });
clock.advance(1);
throttle.push({ phase: 'saving-content', current: 2, total: 9 });
clock.advance(1);
throttle.push({ phase: 'saving-content', current: 3, total: 9 });
expect(throttle.flush()).toEqual([
{ phase: 'saving-content', current: 3, total: 9 },
]);
});
});
@@ -0,0 +1,115 @@
/**
* Coalesces the progress reports of one tracked database operation.
*
* Every report used to become a worker message, a main-process forward and a
* renderer signal write. A 300k-row catalog delete produced thousands of them
* in a few seconds, each one re-rendering the busy overlay (#1292). The
* throttle lets one through every `minIntervalMs` and folds the rest into the
* next emitted event, while keeping the transitions the UI must not miss:
*
* - the first report, and every report that starts a new phase, go out at
* once — a pending report from the previous phase is emitted first, so a
* phase never ends with its last numbers unseen;
* - a report that reaches its total goes out at once;
* - `flush()` hands back whatever is still pending, for the caller to emit
* before a terminal event.
*
* Coalescing keeps the latest `phase`/`current`/`total` and sums `increment`,
* so a consumer adding increments together still arrives at the same count.
*/
export interface ThrottledProgressUpdate {
readonly phase: string;
readonly current?: number;
readonly total?: number;
readonly increment?: number;
}
export interface ProgressEventThrottle {
/** Progress events to emit now, in order. Empty when coalesced. */
push(progress: ThrottledProgressUpdate): ThrottledProgressUpdate[];
/** The coalesced event still waiting, if any. Clears it. */
flush(): ThrottledProgressUpdate[];
}
export interface ProgressEventThrottleOptions {
readonly minIntervalMs: number;
/** Clock, injectable for tests. Defaults to `Date.now`. */
readonly now?: () => number;
}
function mergeProgress(
pending: ThrottledProgressUpdate | null,
next: ThrottledProgressUpdate
): ThrottledProgressUpdate {
if (!pending) {
return next;
}
const increment =
pending.increment === undefined && next.increment === undefined
? undefined
: (pending.increment ?? 0) + (next.increment ?? 0);
return {
phase: next.phase,
current: next.current ?? pending.current,
total: next.total ?? pending.total,
increment,
};
}
function reachedTotal(progress: ThrottledProgressUpdate): boolean {
return (
progress.total !== undefined &&
progress.current !== undefined &&
progress.current >= progress.total
);
}
export function createProgressEventThrottle(
options: ProgressEventThrottleOptions
): ProgressEventThrottle {
const now = options.now ?? Date.now;
let pending: ThrottledProgressUpdate | null = null;
let lastEmittedAt = Number.NEGATIVE_INFINITY;
let lastEmittedPhase: string | undefined;
const emit = (
progress: ThrottledProgressUpdate,
at: number
): ThrottledProgressUpdate => {
pending = null;
lastEmittedAt = at;
lastEmittedPhase = progress.phase;
return progress;
};
return {
push(progress) {
const events: ThrottledProgressUpdate[] = [];
const at = now();
if (pending && pending.phase !== progress.phase) {
events.push(emit(pending, at));
}
const merged = mergeProgress(pending, progress);
const startsPhase = merged.phase !== lastEmittedPhase;
const intervalElapsed = at - lastEmittedAt >= options.minIntervalMs;
if (startsPhase || reachedTotal(merged) || intervalElapsed) {
events.push(emit(merged, at));
} else {
pending = merged;
}
return events;
},
flush() {
if (!pending) {
return [];
}
return [emit(pending, now())];
},
};
}
+92 -24
View File
@@ -232,35 +232,79 @@ operations:
Read and query phases cover the exact awaited Drizzle query. Normalization Read and query phases cover the exact awaited Drizzle query. Normalization
phases cover the existing synchronous transforms. The content-write phase is phases cover the existing synchronous transforms. The content-write phase is
one aggregate pair around every existing 100-row transaction, cancellation one aggregate pair around every row-budgeted transaction, cancellation
checkpoint, and progress callback; it is not an exact measurement of SQLite checkpoint, and progress callback; it is not an exact measurement of SQLite
commit time. Cache clear similarly uses one aggregate pair around all existing commit time. Cache clear similarly uses one aggregate pair around the content
content and category chunk loops and transactions, including their JavaScript group transactions and the single category statement, including their
and autocommit overhead. A zero-category cache clear still emits one pair with JavaScript overhead. A zero-category cache clear still emits one pair with
`itemCount: 0` and performs no extra SQL. `itemCount: 0` and performs no extra SQL.
The Xtream-delete collection span includes its existing ordered category, The Xtream-delete collection span includes its ordered category, favorite,
favorite, recently-viewed, and content-ID work. Its `itemCount` deliberately recently-viewed, and per-category content-count work. Its `itemCount`
counts only content and category deletion candidates; favorite, deliberately counts only content and category deletion candidates (the summed
recently-viewed, and hidden-category user data is timed but is not added to per-category counts plus the category rows); favorite, recently-viewed, and
that count. The matching write count uses the same deletion-candidate hidden-category user data is timed but is not added to that count. The
definition. Playlist-delete collection counts every collected favorite, matching write count uses the same deletion-candidate definition.
recently-viewed, playback-position, content, and category ID. Download rows are Playlist-delete collection counts the favorite, recently-viewed, and
intentionally excluded: they own local offline files independently of the playback-position rows, the summed content counts, and the category rows.
source playlist and survive source deletion, with provider handoff disabled Download rows are intentionally excluded: they own local offline files
while that source is absent. The write count adds the final playlist row. Both independently of the source playlist and survive source deletion, with
write spans include every existing cooperative checkpoint, 100-row provider handoff disabled while that source is absent. The write count adds
transaction, progress callback, and, for playlist deletion, the final the final playlist row. Both write spans include every cooperative checkpoint,
playlist-row autocommit; they are not exact SQLite commit-time measurements. committed transaction, progress callback, and, for playlist deletion, the
final playlist-row autocommit; they are not exact SQLite commit-time
measurements.
Successful end markers carry only row/item counts. Error or cancellation still Successful end markers carry only row/item counts. Error or cancellation still
closes the active phase without metadata and preserves the original error. closes the active phase without metadata and preserves the original error.
Disabled profiling passes no adapter into the operations, so it performs no Disabled profiling passes no adapter into the operations, so it performs no
phase-event or metadata-callback allocation. Worker concurrency, SQL, chunk phase-event or metadata-callback allocation. Worker concurrency, SQL,
sizes, transaction boundaries, progress ordering, and cancellation checkpoints transaction boundaries, progress ordering, and cancellation checkpoints are the
are unchanged. `DB_GLOBAL_SEARCH` is deliberately not instrumented: it same with and without profiling. `DB_GLOBAL_SEARCH` is deliberately not
interleaves queries and ranking across sources, so these single-query phases instrumented: it interleaves queries and ranking across sources, so these
would be misleading. single-query phases would be misleading.
### Catalog write batching
Catalog deletes and inserts commit in row-budgeted transactions rather than
per 100 rows (#1292, `catalog-deletion.ts`). Every commit flushes the FTS5
trigram pending buffer into a new segment, re-appends the dirty pages of each
`content` index to the WAL, and about every 4 MB of WAL runs an fsync-ing
auto-checkpoint, so a 300k-row playlist committed in 100-row batches cost
3,000 commits and roughly 2 GB of WAL traffic where one transaction writes
140 MB. One giant transaction is not the alternative: the worker serves other
requests only between awaits, cancellation is cooperative between commits, and
the main-process and EPG-worker connections give up after their 5 s
`busy_timeout` while a write transaction holds the lock.
- `CONTENT_ROWS_PER_TRANSACTION` (5,000) is the budget for both directions.
- Deletes never materialize row ids in JavaScript. The worker reads content
row counts per category from the two covering indexes
(`countContentRowsByCategory`), packs consecutive categories into groups
within the budget (`groupCategoriesByRowBudget`; a category larger than the
budget forms its own group and is deleted whole — the largest real ones,
around 45k rows, still commit in well under a second), and issues one
`DELETE FROM content WHERE category_id IN (...)` per group. Categories are
then removed with one statement over the ids captured by the collection
step — never a playlist-wide predicate: the worker serves other requests
between commits, so a newer import of the same playlist may have created
categories an older refresh must not erase. Playlist removal is the one
place that scopes by playlist id, for favorites, recently-viewed,
playback-position and category rows alike, since its final playlist-row
delete cascades the same set. `deleteCategoriesWhere` runs its filter
through `requireScopedFilter`, which refuses the `undefined` an empty
`and()` yields, since `.where(undefined)` would be a full-table delete.
- Inserts keep 100-row `INSERT` statements (Drizzle binds the eleven columns
an `XtreamContentValue` supplies, 1,100 parameters per statement; a whole
5,000-row commit in one statement would pass SQLite's 32,766 limit) and
wrap fifty of them in one transaction.
- For deletes, progress `current` is the sum of the `changes` SQLite reports
and `total` is the summed pre-count. For inserts, `current` counts the rows
handed to `INSERT ... ON CONFLICT DO NOTHING` and `total` is the number of
normalized values, so a conflict-skipped duplicate still counts as
progress — it is a progress figure, not a row count. Either way a
checkpoint runs before every commit, so a cancel lands between commits
exactly as before.
Formal initial-import comparison also requires both request-scoped captures in Formal initial-import comparison also requires both request-scoped captures in
every measured run to have coherent event-loop delay, event-loop utilization, every measured run to have coherent event-loop delay, event-loop utilization,
@@ -356,6 +400,16 @@ cancel request does not interrupt it.
The event is forwarded to the renderer as `DB_OPERATION_EVENT`. The event is forwarded to the renderer as `DB_OPERATION_EVENT`.
Progress events are throttled in the worker
(`operation-progress-throttle.ts`): at most one `progress` event per
operation every 100 ms, with the rest coalesced into the next emitted one —
latest `phase`/`current`/`total`, summed `increment`, so a consumer adding
increments still reaches the same count. The first report of a phase, a
report that reaches its `total`, and the pending report of a phase that is
being left are never held back, and a pending report is flushed before the
terminal `completed`, `cancelled`, or `error` event. Consumers must therefore
not assume one event per committed transaction.
### Cancellation contract ### Cancellation contract
Long-running Xtream and playlist operations now support best-effort Long-running Xtream and playlist operations now support best-effort
@@ -373,8 +427,10 @@ If a worker operation is canceled:
2. the request rejects with an `AbortError` 2. the request rejects with an `AbortError`
3. the UI clears its busy state without treating the operation as success 3. the UI clears its busy state without treating the operation as success
Cancellation is cooperative and chunk-based. Already committed SQLite batches Cancellation is cooperative and lands between commits: a checkpoint runs
stay committed. For an operation's busy lifecycle, only the terminal before every row-budgeted transaction (see "Catalog write batching"), and
already committed SQLite batches stay committed. For an operation's busy
lifecycle, only the terminal
`completed`, `error`, or `cancelled` event settles UI state. The UI may set a `completed`, `error`, or `cancelled` event settles UI state. The UI may set a
separate cancel-requested flag immediately so the cancel action cannot be separate cancel-requested flag immediately so the cancel action cannot be
clicked twice while it waits for the authoritative terminal event. clicked twice while it waits for the authoritative terminal event.
@@ -877,6 +933,18 @@ value of `0`, every cancellable batch checkpoint still yields one event-loop
turn without adding a timer delay. That yield lets the worker receive a queued turn without adding a timer delay. That yield lets the worker receive a queued
cancel message before starting the next batch. cancel message before starting the next batch.
Its companion, also test-only, shrinks the catalog write budget so a
few-thousand-row mock catalog still produces several commits — and therefore
several checkpoints and progress events — to observe mid-import:
```bash
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION=100
```
Unset, or set to anything but a positive integer, it leaves the default
`CONTENT_ROWS_PER_TRANSACTION` of 5,000 in place (see "Catalog write
batching"). Neither knob belongs in a production launch.
### Useful verification commands ### Useful verification commands
```bash ```bash