Tune SQLite caches and maintain planner statistics
This commit is contained in:
@@ -34,3 +34,5 @@ PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
|
|||||||
EC_PERF_LOG=false
|
EC_PERF_LOG=false
|
||||||
# Log statements taking at least this many milliseconds; 0 disables logging.
|
# Log statements taking at least this many milliseconds; 0 disables logging.
|
||||||
EC_SLOW_SQL_MS=0
|
EC_SLOW_SQL_MS=0
|
||||||
|
# For a local SQLite disk, set to 256 for a 256 MiB memory map. Leave 0 for network mounts.
|
||||||
|
EC_SQLITE_MMAP_MB=0
|
||||||
|
|||||||
@@ -190,6 +190,10 @@ after database changes, capture these timings under the same fleet load and run
|
|||||||
`npm run diagnostics:database -- /data/cherry.db` inside the controller to report
|
`npm run diagnostics:database -- /data/cherry.db` inside the controller to report
|
||||||
the largest tables/indexes, database and WAL sizes, and free pages. This command
|
the largest tables/indexes, database and WAL sizes, and free pages. This command
|
||||||
opens an existing database read-only and does not run app migrations.
|
opens an existing database read-only and does not run app migrations.
|
||||||
|
The controller uses a 128 MiB page cache and each reader worker uses 32 MiB.
|
||||||
|
For a local data disk, set `EC_SQLITE_MMAP_MB=256` to enable memory-mapped reads.
|
||||||
|
Query statistics are optimized at startup and hourly; WAL reuse is capped at
|
||||||
|
64 MiB after checkpoints (active transactions can temporarily grow it larger).
|
||||||
|
|
||||||
Local controller
|
Local controller
|
||||||
metadata defaults to `./data/cherry.db`; use a separate test database when
|
metadata defaults to `./data/cherry.db`; use a separate test database when
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
export function tuneConnection(handle: Database.Database, reader = false): void {
|
||||||
|
handle.pragma(`cache_size = -${reader ? 32768 : 131072}`);
|
||||||
|
handle.pragma('temp_store = MEMORY');
|
||||||
|
// mmap is opt-in because the controller data directory can be a network mount.
|
||||||
|
const mmapMiB = Number(process.env.EC_SQLITE_MMAP_MB ?? 0);
|
||||||
|
if (Number.isSafeInteger(mmapMiB) && mmapMiB >= 0 && mmapMiB <= 32768) {
|
||||||
|
handle.pragma(`mmap_size = ${mmapMiB * 1024 * 1024}`);
|
||||||
|
}
|
||||||
|
if (!reader) handle.pragma('journal_size_limit = 67108864');
|
||||||
|
}
|
||||||
@@ -12,9 +12,11 @@ import { migrateServerHistory } from './server-history.js';
|
|||||||
import { migrateNativeInstances } from '../backends/schema.js';
|
import { migrateNativeInstances } from '../backends/schema.js';
|
||||||
import { installPerformanceIndexes } from './performance-indexes.js';
|
import { installPerformanceIndexes } from './performance-indexes.js';
|
||||||
import { migrateVersionLayout } from './version-layout.js';
|
import { migrateVersionLayout } from './version-layout.js';
|
||||||
|
import { tuneConnection } from './connection.js';
|
||||||
import { timeStatement } from './slow-statements.js';
|
import { timeStatement } from './slow-statements.js';
|
||||||
|
|
||||||
let _db: Database.Database | null = null;
|
let _db: Database.Database | null = null;
|
||||||
|
let optimizeTimer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
// Desired shape of the `servers` table. Every column here is added to the
|
// Desired shape of the `servers` table. Every column here is added to the
|
||||||
// table on boot if missing — introspection via PRAGMA table_info handles
|
// table on boot if missing — introspection via PRAGMA table_info handles
|
||||||
@@ -316,6 +318,7 @@ export function db(): Database.Database {
|
|||||||
try {
|
try {
|
||||||
handle.pragma('journal_mode = WAL');
|
handle.pragma('journal_mode = WAL');
|
||||||
handle.pragma('foreign_keys = ON');
|
handle.pragma('foreign_keys = ON');
|
||||||
|
tuneConnection(handle);
|
||||||
dropLegacyBetaTables(handle);
|
dropLegacyBetaTables(handle);
|
||||||
handle.exec(schemaSql);
|
handle.exec(schemaSql);
|
||||||
migrateProvisioningActions(handle);
|
migrateProvisioningActions(handle);
|
||||||
@@ -409,6 +412,7 @@ export function db(): Database.Database {
|
|||||||
ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`);
|
ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`);
|
||||||
installPerformanceIndexes(handle);
|
installPerformanceIndexes(handle);
|
||||||
installReportRevisions(handle);
|
installReportRevisions(handle);
|
||||||
|
handle.pragma('optimize = 0x10002');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handle.close();
|
handle.close();
|
||||||
throw err;
|
throw err;
|
||||||
@@ -431,10 +435,17 @@ export function db(): Database.Database {
|
|||||||
}) as typeof handle.prepare;
|
}) as typeof handle.prepare;
|
||||||
|
|
||||||
_db = handle;
|
_db = handle;
|
||||||
|
optimizeTimer = setInterval(() => {
|
||||||
|
try { handle.pragma('optimize'); }
|
||||||
|
catch (error) { console.error('[database] optimize failed:', error); }
|
||||||
|
}, 60 * 60_000);
|
||||||
|
optimizeTimer.unref();
|
||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeDb(): void {
|
export function closeDb(): void {
|
||||||
|
clearInterval(optimizeTimer);
|
||||||
|
optimizeTimer = undefined;
|
||||||
if (_db) {
|
if (_db) {
|
||||||
_db.close();
|
_db.close();
|
||||||
_db = null;
|
_db = null;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { workerData } from 'node:worker_threads';
|
import { workerData } from 'node:worker_threads';
|
||||||
|
import { tuneConnection } from '../db/connection.js';
|
||||||
|
|
||||||
const handle = new Database(workerData.dbPath, { readonly: true, fileMustExist: true });
|
const handle = new Database(workerData.dbPath, { readonly: true, fileMustExist: true });
|
||||||
|
tuneConnection(handle, true);
|
||||||
export function db() { return handle; }
|
export function db() { return handle; }
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ beforeEach(async () => {
|
|||||||
afterEach(async () => { await fixture.cleanup(); });
|
afterEach(async () => { await fixture.cleanup(); });
|
||||||
|
|
||||||
describe('prepared statement caching', () => {
|
describe('prepared statement caching', () => {
|
||||||
|
it('tunes the controller connection and stops periodic optimization when closed', async () => {
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
|
||||||
|
try {
|
||||||
|
const handle = db();
|
||||||
|
expect(handle.pragma('cache_size', { simple: true })).toBe(-131072);
|
||||||
|
expect(handle.pragma('temp_store', { simple: true })).toBe(2);
|
||||||
|
expect(handle.pragma('journal_size_limit', { simple: true })).toBe(67108864);
|
||||||
|
const pragma = vi.spyOn(handle, 'pragma');
|
||||||
|
vi.advanceTimersByTime(60 * 60_000);
|
||||||
|
expect(pragma).toHaveBeenCalledWith('optimize');
|
||||||
|
pragma.mockClear();
|
||||||
|
closeDb();
|
||||||
|
vi.advanceTimersByTime(60 * 60_000);
|
||||||
|
expect(pragma).not.toHaveBeenCalled();
|
||||||
|
} finally { vi.useRealTimers(); }
|
||||||
|
});
|
||||||
it('reuses statements with fresh parameters and remains usable after rollback', async () => {
|
it('reuses statements with fresh parameters and remains usable after rollback', async () => {
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const insertSql = 'INSERT INTO settings (key, value) VALUES (?, ?)';
|
const insertSql = 'INSERT INTO settings (key, value) VALUES (?, ?)';
|
||||||
|
|||||||
Reference in New Issue
Block a user