mirror of
https://github.com/lux-tel/session-desktop.git
synced 2026-10-30 20:33:24 +00:00
212 lines
7.0 KiB
TypeScript
212 lines
7.0 KiB
TypeScript
import { type Database } from '@signalapp/sqlcipher';
|
|
import { app, BrowserWindow } from 'electron';
|
|
import { DURATION } from '../session/constants';
|
|
|
|
const category = '[dbVacuumManager]';
|
|
|
|
const logDebug = process.env.DB_VACUUM_MANAGER_DEBUG === '1';
|
|
|
|
function logWithPrefix(
|
|
level: keyof Pick<typeof console, 'log' | 'info' | 'warn' | 'error' | 'debug'>,
|
|
message: string,
|
|
...args: Array<any>
|
|
) {
|
|
if (level === 'debug' && !logDebug) {
|
|
return;
|
|
}
|
|
console[level](`${category} ${message}`, ...args);
|
|
}
|
|
|
|
/**
|
|
* Vacuuming the database used to be done on app start, but it was really slow for large databases (10s+).
|
|
* We decided to make use of the incremental vacuum instead.
|
|
* This class is responsible for triggering the incremental vacuum.
|
|
* Any database calls will be queued up and executed when the vacuum is done, so we need to keep the vacuuming as
|
|
* quick as possible to be the least annoying for the user.
|
|
*
|
|
* We will usually run the incremental vacuum when the app is not focused so the user doesn't see it.
|
|
* But if the app is focused for a long time, we will force an incremental vacuum to run (if needed).
|
|
* This will impact the user experience, but we do need the vacuum to happen for users that keep the app open and focused.
|
|
*
|
|
* In all cases, when an incremental vacuum is needed, it will process chunks and allow time for other operations to happen between two chunks.
|
|
* So even if the app is focused and the user is actively using it, it should have a minimal impact.
|
|
*
|
|
*/
|
|
export class DBVacuumManager {
|
|
private db: Database;
|
|
|
|
/**
|
|
* The count of pages to process at a time.
|
|
*/
|
|
private pagesPerChunk = 500;
|
|
/**
|
|
* The minimum number of free pages for the vacuum to run.
|
|
*/
|
|
private minPagesForVacuum = 500;
|
|
|
|
/** We vacuum `pagesPerChunk` at a time, every `vacuumIntervalMs` */
|
|
private vacuumIntervalMs = 1 * DURATION.SECONDS;
|
|
|
|
private blurredAtTimeoutId: NodeJS.Timeout | null = null;
|
|
private periodicVacuumIntervalId: NodeJS.Timeout | null = null;
|
|
/**
|
|
* If we are actively vacuuming, this is the interval id for the vacuum that will tick every `vacuumIntervalMs`
|
|
* and process `pagesPerChunk` at a time.
|
|
*/
|
|
private vacuumIntervalId: NodeJS.Timeout | null = null;
|
|
|
|
/**
|
|
* For users that keep the app open and focused, we will force an incremental vacuum every `periodicVacuumMs`.
|
|
*/
|
|
private periodicVacuumMs = 30 * DURATION.MINUTES;
|
|
|
|
/**
|
|
* If the app is blurred for more than `blurredTimeoutMs`, we will trigger an incremental vacuum.
|
|
*/
|
|
private blurredTimeoutMs = 5 * DURATION.SECONDS;
|
|
|
|
/**
|
|
* When the app is focused, depending on what triggered the current vacuum we need to stop it or not.
|
|
* If what started the vacuum
|
|
* - is `user-activity`, we will stop the current vacuum.
|
|
* - is `periodic`, we will not cancel.
|
|
*/
|
|
private vacuumTriggerReason: 'periodic' | 'user-activity' | null = null;
|
|
|
|
constructor(db: Database) {
|
|
this.db = db;
|
|
this.schedulePeriodicVacuum();
|
|
this.monitorWindowStates();
|
|
}
|
|
|
|
private monitorWindowStates() {
|
|
app.on('browser-window-blur', () => {
|
|
if (this.blurredAtTimeoutId) {
|
|
clearTimeout(this.blurredAtTimeoutId);
|
|
this.blurredAtTimeoutId = null;
|
|
}
|
|
logWithPrefix(
|
|
'debug',
|
|
`App blurred at ${new Date().toISOString()}. Blurred timeout is ${this.blurredTimeoutMs}ms...`
|
|
);
|
|
|
|
this.blurredAtTimeoutId = setTimeout(() => {
|
|
// If the window gets focused again, we will cancel `this.blurredAtTimeoutId`.
|
|
// So, when this callback is called, it means the window is still blurred.
|
|
if (this.hasEnoughPagesToVacuum()) {
|
|
logWithPrefix(
|
|
'debug',
|
|
`App still blurred and needs vacuum at ${new Date().toISOString()}... Starting vacuum`
|
|
);
|
|
|
|
this.startVacuum('user-activity');
|
|
} else {
|
|
logWithPrefix(
|
|
'debug',
|
|
`No need for vacuum at ${new Date().toISOString()} (${this.getPagesToVacuumCount()}/${this.minPagesForVacuum})`
|
|
);
|
|
}
|
|
this.blurredAtTimeoutId = null;
|
|
}, this.blurredTimeoutMs);
|
|
});
|
|
|
|
app.on('browser-window-focus', () => {
|
|
if (this.blurredAtTimeoutId) {
|
|
clearTimeout(this.blurredAtTimeoutId);
|
|
this.blurredAtTimeoutId = null;
|
|
}
|
|
logWithPrefix('debug', `App focused at ${new Date().toISOString()}`);
|
|
});
|
|
}
|
|
|
|
private schedulePeriodicVacuum() {
|
|
this.periodicVacuumIntervalId = setInterval(() => {
|
|
if (this.hasEnoughPagesToVacuum()) {
|
|
logWithPrefix('debug', 'Periodic vacuum check - starting cleanup');
|
|
this.startVacuum('periodic');
|
|
} else {
|
|
logWithPrefix(
|
|
'debug',
|
|
`Periodic vacuum check - no need for cleanup (${this.getPagesToVacuumCount()}/${this.minPagesForVacuum})`
|
|
);
|
|
}
|
|
}, this.periodicVacuumMs);
|
|
}
|
|
|
|
private hasEnoughPagesToVacuum() {
|
|
return this.getPagesToVacuumCount() >= this.minPagesForVacuum;
|
|
}
|
|
|
|
private getPagesToVacuumCount() {
|
|
return this.db.pragma('freelist_count', { simple: true }) as number;
|
|
}
|
|
|
|
private hasFocusedWindow(): boolean {
|
|
return BrowserWindow.getAllWindows().some(win => win.isFocused());
|
|
}
|
|
|
|
private startVacuum(vacuumTriggerReason: 'periodic' | 'user-activity') {
|
|
if (this.isVacuuming()) {
|
|
return;
|
|
}
|
|
|
|
let pagesVacuumed = 0;
|
|
this.vacuumTriggerReason = vacuumTriggerReason;
|
|
this.vacuumIntervalId = setInterval(() => {
|
|
try {
|
|
if (this.vacuumTriggerReason === 'user-activity' && this.hasFocusedWindow()) {
|
|
logWithPrefix('info', 'User became active - pausing vacuum');
|
|
this.stopVacuum();
|
|
return;
|
|
}
|
|
|
|
const pagesToVacuumBefore = this.getPagesToVacuumCount();
|
|
if (pagesToVacuumBefore === 0) {
|
|
logWithPrefix('debug', 'vacuum is done: no free pages left');
|
|
this.stopVacuum();
|
|
return;
|
|
}
|
|
|
|
const countToVacuum = Math.min(this.pagesPerChunk, pagesToVacuumBefore);
|
|
const start = Date.now();
|
|
this.db.pragma(`incremental_vacuum(${countToVacuum})`);
|
|
logWithPrefix('info', `incremental_vacuum(${countToVacuum}) took ${Date.now() - start}ms`);
|
|
pagesVacuumed += countToVacuum;
|
|
|
|
const pagesToVacuumAfter = this.getPagesToVacuumCount();
|
|
|
|
logWithPrefix(
|
|
'info',
|
|
`Vacuumed ${pagesVacuumed} total pages (${pagesToVacuumAfter} remaining)`
|
|
);
|
|
} catch (error) {
|
|
logWithPrefix('error', 'Vacuum error: ', error.message);
|
|
this.stopVacuum();
|
|
}
|
|
}, this.vacuumIntervalMs);
|
|
}
|
|
|
|
private isVacuuming() {
|
|
return this.vacuumIntervalId !== null;
|
|
}
|
|
|
|
private stopVacuum() {
|
|
if (this.vacuumIntervalId) {
|
|
clearInterval(this.vacuumIntervalId);
|
|
this.vacuumIntervalId = null;
|
|
}
|
|
this.vacuumTriggerReason = null;
|
|
}
|
|
|
|
public cleanup() {
|
|
if (this.periodicVacuumIntervalId) {
|
|
clearInterval(this.periodicVacuumIntervalId);
|
|
}
|
|
if (this.blurredAtTimeoutId) {
|
|
clearTimeout(this.blurredAtTimeoutId);
|
|
}
|
|
|
|
this.stopVacuum();
|
|
}
|
|
}
|