diff --git a/.env.example b/.env.example index f6f06a9..5385762 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,5 @@ PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest EC_PERF_LOG=false # Log statements taking at least this many milliseconds; 0 disables logging. 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 diff --git a/README.md b/README.md index 91a059c..3dd1b87 100644 --- a/README.md +++ b/README.md @@ -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 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. +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 metadata defaults to `./data/cherry.db`; use a separate test database when diff --git a/src/lib/server/db/connection.ts b/src/lib/server/db/connection.ts new file mode 100644 index 0000000..24d8c1b --- /dev/null +++ b/src/lib/server/db/connection.ts @@ -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'); +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index c6e768f..fcb10b6 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -12,9 +12,11 @@ import { migrateServerHistory } from './server-history.js'; import { migrateNativeInstances } from '../backends/schema.js'; import { installPerformanceIndexes } from './performance-indexes.js'; import { migrateVersionLayout } from './version-layout.js'; +import { tuneConnection } from './connection.js'; import { timeStatement } from './slow-statements.js'; let _db: Database.Database | null = null; +let optimizeTimer: NodeJS.Timeout | undefined; // Desired shape of the `servers` table. Every column here is added to the // table on boot if missing — introspection via PRAGMA table_info handles @@ -316,6 +318,7 @@ export function db(): Database.Database { try { handle.pragma('journal_mode = WAL'); handle.pragma('foreign_keys = ON'); + tuneConnection(handle); dropLegacyBetaTables(handle); handle.exec(schemaSql); migrateProvisioningActions(handle); @@ -409,6 +412,7 @@ export function db(): Database.Database { ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`); installPerformanceIndexes(handle); installReportRevisions(handle); + handle.pragma('optimize = 0x10002'); } catch (err) { handle.close(); throw err; @@ -431,10 +435,17 @@ export function db(): Database.Database { }) as typeof handle.prepare; _db = handle; + optimizeTimer = setInterval(() => { + try { handle.pragma('optimize'); } + catch (error) { console.error('[database] optimize failed:', error); } + }, 60 * 60_000); + optimizeTimer.unref(); return _db; } export function closeDb(): void { + clearInterval(optimizeTimer); + optimizeTimer = undefined; if (_db) { _db.close(); _db = null; diff --git a/src/lib/server/reports/database.ts b/src/lib/server/reports/database.ts index b99e65a..734ae1c 100644 --- a/src/lib/server/reports/database.ts +++ b/src/lib/server/reports/database.ts @@ -1,5 +1,7 @@ import Database from 'better-sqlite3'; import { workerData } from 'node:worker_threads'; +import { tuneConnection } from '../db/connection.js'; const handle = new Database(workerData.dbPath, { readonly: true, fileMustExist: true }); +tuneConnection(handle, true); export function db() { return handle; } diff --git a/tests/unit/db-statements.test.ts b/tests/unit/db-statements.test.ts index 0dc0193..e377f75 100644 --- a/tests/unit/db-statements.test.ts +++ b/tests/unit/db-statements.test.ts @@ -9,6 +9,23 @@ beforeEach(async () => { afterEach(async () => { await fixture.cleanup(); }); 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 () => { const { db } = await import('../../src/lib/server/db/index.js'); const insertSql = 'INSERT INTO settings (key, value) VALUES (?, ?)';