mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-28 10:35:59 +01:00
b4d6423b3f
Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
203 lines
5.6 KiB
TypeScript
203 lines
5.6 KiB
TypeScript
// Copyright 2026 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
import type { LoggerType } from '../types/Logging.std.ts';
|
|
import type { ReadableDB, WritableDB } from './Interface.std.ts';
|
|
import * as Errors from '../types/errors.std.ts';
|
|
import { sql } from './util.std.ts';
|
|
|
|
/**
|
|
* The default automatic checkpointing behavior of sqlite only checkpoints every 1000 pages
|
|
* and on database close. Which means that changes can sit in the log for potentially a long time.
|
|
*
|
|
* To make sure that the WAL is flushed soon after every commit, we call `sqlite3_wal_hook()`
|
|
* (which replaces the automatic checkpointing behavior) with our own callback.
|
|
*
|
|
* We will still run a checkpoint every 1000 pages using TRUNCATE instead of PASSIVE.
|
|
*
|
|
* But we will also run a checkpoint after every commit, throttled to every 30 seconds.
|
|
*
|
|
* We also setup TEMP trigger's AFTER DELETE on every table, which reschedules
|
|
* the next checkpoint to every 5 seconds.
|
|
*/
|
|
export namespace WalCheckpoints {
|
|
const PAGE_THRESHOLD = 1000;
|
|
const THROTTLE_MS_AFTER_COMMIT = 30_000; // 30s
|
|
const THROTTLE_MS_AFTER_DELETE = 5_000; // 5s
|
|
|
|
let onCheckpointNeeded: ((reason: string) => void) | null = null;
|
|
|
|
let lastRunAt = 0;
|
|
let pendingRunWhenIdle = false;
|
|
let hasDeletesSinceLastRun = false;
|
|
let scheduledTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
export function setOnCheckpointNeeded(
|
|
callback: (reason: string) => void
|
|
): void {
|
|
onCheckpointNeeded = callback;
|
|
}
|
|
|
|
/** @testexport */
|
|
export function _reset(): void {
|
|
onCheckpointNeeded = null;
|
|
if (scheduledTimer != null) {
|
|
clearTimeout(scheduledTimer);
|
|
scheduledTimer = null;
|
|
}
|
|
lastRunAt = 0;
|
|
pendingRunWhenIdle = false;
|
|
hasDeletesSinceLastRun = false;
|
|
}
|
|
|
|
function run(
|
|
db: WritableDB,
|
|
logger: LoggerType,
|
|
attempts: number,
|
|
reason: string,
|
|
callback: () => void
|
|
) {
|
|
try {
|
|
db.pragma('wal_checkpoint(TRUNCATE)');
|
|
callback();
|
|
} catch (error) {
|
|
if (error.code !== 'SQLITE_LOCKED') {
|
|
logger.error(
|
|
`WalCheckpoints.run: Unexpected error (attempts: ${attempts}, reason: ${reason})`,
|
|
Errors.toLogFormat(error)
|
|
);
|
|
return;
|
|
}
|
|
|
|
// TODO: Are there any errors that we shouldn't retry?
|
|
logger.warn(
|
|
`WalCheckpoints.run: Database is locked, retrying (attempts: ${attempts}, reason: ${reason})`,
|
|
Errors.toLogFormat(error)
|
|
);
|
|
|
|
// TODO: This should probably try again faster with backoff or something
|
|
setTimeout(() => {
|
|
run(db, logger, attempts + 1, reason, callback);
|
|
}, 1000);
|
|
}
|
|
}
|
|
|
|
export function runImmediately(
|
|
db: WritableDB,
|
|
logger: LoggerType,
|
|
reason: string
|
|
): void {
|
|
if (scheduledTimer != null) {
|
|
clearTimeout(scheduledTimer);
|
|
scheduledTimer = null;
|
|
}
|
|
|
|
run(db, logger, 0, reason, () => {
|
|
lastRunAt = Date.now();
|
|
pendingRunWhenIdle = false;
|
|
hasDeletesSinceLastRun = false;
|
|
});
|
|
}
|
|
|
|
function runWhenIdle(logger: LoggerType, reason: string): void {
|
|
if (onCheckpointNeeded == null) {
|
|
logger.error(
|
|
'WalCheckpoints.runWhenIdle: setOnCheckpointNeeded has not been called'
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pendingRunWhenIdle) {
|
|
return;
|
|
}
|
|
pendingRunWhenIdle = true;
|
|
onCheckpointNeeded(reason);
|
|
}
|
|
|
|
/** @testexport */
|
|
export function _scheduleRun(
|
|
event: 'commit' | 'delete',
|
|
logger: LoggerType
|
|
): void {
|
|
if (pendingRunWhenIdle) {
|
|
return;
|
|
}
|
|
|
|
const prevScheduledForDelete = hasDeletesSinceLastRun;
|
|
const needScheduledForDelete = event === 'delete';
|
|
|
|
if (event === 'delete') {
|
|
hasDeletesSinceLastRun = true;
|
|
}
|
|
|
|
const elapsedMs = Date.now() - lastRunAt;
|
|
const throttleMs = hasDeletesSinceLastRun
|
|
? THROTTLE_MS_AFTER_DELETE
|
|
: THROTTLE_MS_AFTER_COMMIT;
|
|
|
|
if (elapsedMs >= throttleMs) {
|
|
if (scheduledTimer != null) {
|
|
clearTimeout(scheduledTimer);
|
|
scheduledTimer = null;
|
|
}
|
|
runWhenIdle(logger, event);
|
|
return;
|
|
}
|
|
|
|
if (scheduledTimer != null) {
|
|
if (prevScheduledForDelete || !needScheduledForDelete) {
|
|
return;
|
|
}
|
|
clearTimeout(scheduledTimer);
|
|
}
|
|
|
|
scheduledTimer = setTimeout(() => {
|
|
scheduledTimer = null;
|
|
runWhenIdle(logger, event);
|
|
}, throttleMs - elapsedMs);
|
|
}
|
|
|
|
export function setupCommitHook(db: WritableDB, logger: LoggerType): void {
|
|
db.setWalHook((_dbName, pageCount) => {
|
|
if (pageCount >= PAGE_THRESHOLD) {
|
|
// TODO: Should we run `PRAGMA wal_checkpoint(PASSIVE)` here like automatic checkpoints do?
|
|
// We could still call runWhenIdle() to get a TRUNCATE?
|
|
runWhenIdle(logger, 'page-threshold');
|
|
} else {
|
|
_scheduleRun('commit', logger);
|
|
}
|
|
});
|
|
}
|
|
|
|
function getAllTableNames(db: ReadableDB): ReadonlyArray<string> {
|
|
const [query, params] = sql`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type = 'table'
|
|
AND name NOT LIKE 'sqlite_%'
|
|
AND name NOT LIKE 'messages_fts_%'
|
|
AND sql NOT LIKE 'CREATE VIRTUAL TABLE%'
|
|
`;
|
|
return db.prepare(query, { pluck: true }).all(params);
|
|
}
|
|
|
|
export function setupDeleteTriggers(
|
|
db: WritableDB,
|
|
logger: LoggerType
|
|
): void {
|
|
db.createFunction('_wal_checkpoint_on_delete', () => {
|
|
_scheduleRun('delete', logger);
|
|
});
|
|
|
|
const tableNames = getAllTableNames(db);
|
|
|
|
for (const tableName of tableNames) {
|
|
db.exec(`
|
|
CREATE TEMP TRIGGER IF NOT EXISTS _wal_checkpoint_${tableName}_after_delete
|
|
AFTER DELETE ON "${tableName}"
|
|
BEGIN
|
|
SELECT _wal_checkpoint_on_delete();
|
|
END;
|
|
`);
|
|
}
|
|
}
|
|
}
|