Keep startup database rebuilds out of memory

This commit is contained in:
2026-10-06 15:18:13 -04:00
parent 8f8897b86b
commit 6999a33aef
12 changed files with 184 additions and 15 deletions
+2
View File
@@ -24,6 +24,7 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json COPY --from=build /app/package.json ./package.json
COPY --from=build /app/src/lib/server/db/schema.sql ./build/schema.sql COPY --from=build /app/src/lib/server/db/schema.sql ./build/schema.sql
COPY scripts/reset-admin.mjs ./scripts/reset-admin.mjs COPY scripts/reset-admin.mjs ./scripts/reset-admin.mjs
COPY scripts/database-diagnostics.mjs ./scripts/database-diagnostics.mjs
COPY src/lib/server/auth/passwords.js ./src/lib/server/auth/passwords.js COPY src/lib/server/auth/passwords.js ./src/lib/server/auth/passwords.js
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
@@ -32,6 +33,7 @@ ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV BODY_SIZE_LIMIT=Infinity ENV BODY_SIZE_LIMIT=Infinity
ENV SQLITE_TMPDIR=/data/.sqlite-tmp
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"] ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
+9
View File
@@ -198,6 +198,15 @@ after database changes, capture these timings under the same fleet load and run
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. The controller uses a 128 MiB page cache and each reader worker uses 32 MiB.
Main-database migrations use a 16 MiB cache with memory mapping disabled; SQLite
temporary storage stays disk-backed during migrations and normal operation.
The container uses `/data/.sqlite-tmp` for SQLite scratch files so a memory-backed
`/tmp` cannot turn a database rebuild into an allocation of the whole database.
Override `SQLITE_TMPDIR` only with another writable, disk-backed directory.
Startup logs identify migration phases, elapsed time and resident memory. A
completed migration is retained across restarts; SQLite rolls back interrupted
transactions and startup retries those migrations. The one-time vacuum still needs disk space for its temporary
copy. Do not remove `cherry.db-wal` to recover from an interrupted upgrade.
For a local data disk, set `EC_SQLITE_MMAP_MB=256` to enable memory-mapped reads. 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 Query statistics are optimized at startup and hourly; WAL reuse is capped at
64 MiB after checkpoints (active transactions can temporarily grow it larger). 64 MiB after checkpoints (active transactions can temporarily grow it larger).
+3
View File
@@ -93,6 +93,9 @@ available longer. Previously saved retention choices are preserved on upgrade.
The database upgrade normalizes retention clocks to UTC and enables incremental The database upgrade normalizes retention clocks to UTC and enables incremental
vacuum. An existing database without a vacuum page map is rebuilt once at startup; vacuum. An existing database without a vacuum page map is rebuilt once at startup;
allow time for this rebuild and free disk space up to twice the database size. allow time for this rebuild and free disk space up to twice the database size.
The rebuild uses disk-backed temporary storage and a 16 MiB page cache instead
of holding a temporary copy of the database in RAM. Container scratch files use
`/data/.sqlite-tmp`; `[database]` startup logs identify the current migration.
Normal cleanup releases unused file blobs in bounded batches and incrementally Normal cleanup releases unused file blobs in bounded batches and incrementally
returns free pages to disk after larger prunes. returns free pages to disk after larger prunes.
+4
View File
@@ -1,5 +1,9 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
if [ -n "${SQLITE_TMPDIR:-}" ]; then
mkdir -p "$SQLITE_TMPDIR"
fi
echo "[entrypoint] starting node server on ${HOST:-0.0.0.0}:${PORT:-3000}" echo "[entrypoint] starting node server on ${HOST:-0.0.0.0}:${PORT:-3000}"
exec node build/index.js exec node build/index.js
+25 -1
View File
@@ -2,7 +2,9 @@ import type Database from 'better-sqlite3';
export function tuneConnection(handle: Database.Database, reader = false): void { export function tuneConnection(handle: Database.Database, reader = false): void {
handle.pragma(`cache_size = -${reader ? 32768 : 131072}`); handle.pragma(`cache_size = -${reader ? 32768 : 131072}`);
handle.pragma('temp_store = MEMORY'); // MEMORY also puts VACUUM's transient database in RAM. Disk-backed temps
// keep migrations and large sorts from growing with the entire database.
handle.pragma('temp_store = FILE');
// mmap is opt-in because the controller data directory can be a network mount. // mmap is opt-in because the controller data directory can be a network mount.
const mmapMiB = Number(process.env.EC_SQLITE_MMAP_MB ?? 0); const mmapMiB = Number(process.env.EC_SQLITE_MMAP_MB ?? 0);
if (Number.isSafeInteger(mmapMiB) && mmapMiB >= 0 && mmapMiB <= 32768) { if (Number.isSafeInteger(mmapMiB) && mmapMiB >= 0 && mmapMiB <= 32768) {
@@ -10,3 +12,25 @@ export function tuneConnection(handle: Database.Database, reader = false): void
} }
if (!reader) handle.pragma('journal_size_limit = 67108864'); if (!reader) handle.pragma('journal_size_limit = 67108864');
} }
/** Startup needs two page caches during VACUUM, plus buffers for legacy files. */
export function tuneMigrationConnection(handle: Database.Database): void {
handle.pragma('cache_size = -16384');
handle.pragma('temp_store = FILE');
handle.pragma('mmap_size = 0');
handle.pragma('journal_size_limit = 67108864');
}
export function migrationStep<T>(name: string, work: () => T): T {
const started = Date.now();
console.info(`[database] migration starting: ${name}`);
try {
const result = work();
console.info(`[database] migration finished: ${name}`, { elapsedMs: Date.now() - started,
rssMiB: Math.round(process.memoryUsage().rss / 1048576) });
return result;
} catch (error) {
console.error(`[database] migration failed: ${name}`, error);
throw error;
}
}
+5 -4
View File
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { db } from './index.js'; import { db } from './index.js';
import { migrationStep } from './connection.js';
export function ensureFileBlobSchema(handle: Database.Database): void { export function ensureFileBlobSchema(handle: Database.Database): void {
handle.exec(`CREATE TABLE IF NOT EXISTS file_blobs(sha256 TEXT PRIMARY KEY,size INTEGER NOT NULL,content BLOB NOT NULL); handle.exec(`CREATE TABLE IF NOT EXISTS file_blobs(sha256 TEXT PRIMARY KEY,size INTEGER NOT NULL,content BLOB NOT NULL);
@@ -84,18 +85,18 @@ export function* migrationRows<T>(handle: Database.Database, sql: string): Gener
export function migrateFileBlobs(handle: Database.Database): void { export function migrateFileBlobs(handle: Database.Database): void {
for (const [table, column] of blobColumns) { for (const [table, column] of blobColumns) {
if (!(handle.pragma(`table_info(${table})`) as { name: string }[]).some(item => item.name === column)) continue; if (!(handle.pragma(`table_info(${table})`) as { name: string }[]).some(item => item.name === column)) continue;
handle.transaction(() => { migrationStep(`shared files: ${table}.${column}`, () => handle.transaction(() => {
handle.exec(`ALTER TABLE ${table} ADD COLUMN ${column}_hash TEXT REFERENCES file_blobs(sha256)`); handle.exec(`ALTER TABLE ${table} ADD COLUMN ${column}_hash TEXT REFERENCES file_blobs(sha256)`);
const update = handle.prepare(`UPDATE ${table} SET ${column}_hash=? WHERE rowid=?`); const update = handle.prepare(`UPDATE ${table} SET ${column}_hash=? WHERE rowid=?`);
for (const row of migrationRows<{ content: Buffer | null }>(handle, `SELECT rowid AS rid,${column} AS content FROM ${table} WHERE rowid>? ORDER BY rowid LIMIT 1`)) { for (const row of migrationRows<{ content: Buffer | null }>(handle, `SELECT rowid AS rid,${column} AS content FROM ${table} WHERE rowid>? ORDER BY rowid LIMIT 1`)) {
update.run(putFileBlob(row.content, handle), row.rid); update.run(putFileBlob(row.content, handle), row.rid);
} }
handle.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`); handle.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
})(); })());
} }
for (const [table, column] of blobColumns) handle.exec(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_hash ON ${table}(${column}_hash)`); for (const [table, column] of blobColumns) handle.exec(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_hash ON ${table}(${column}_hash)`);
if (!handle.prepare("SELECT 1 FROM settings WHERE key='recipe_blobs_v1'").get()) { if (!handle.prepare("SELECT 1 FROM settings WHERE key='recipe_blobs_v1'").get()) {
handle.transaction(() => { migrationStep('shared files: launch recipes', () => handle.transaction(() => {
for (const row of migrationRows<{ deployment_id: string; recipe: string }>(handle, for (const row of migrationRows<{ deployment_id: string; recipe: string }>(handle,
'SELECT rowid AS rid,deployment_id,recipe FROM server_launch_recipes WHERE rowid>? ORDER BY rowid LIMIT 1')) { 'SELECT rowid AS rid,deployment_id,recipe FROM server_launch_recipes WHERE rowid>? ORDER BY rowid LIMIT 1')) {
const packed = packFileJson(JSON.parse(row.recipe), handle); const packed = packFileJson(JSON.parse(row.recipe), handle);
@@ -103,7 +104,7 @@ export function migrateFileBlobs(handle: Database.Database): void {
referenceBlobs('recipe', row.deployment_id, packed.hashes, handle); referenceBlobs('recipe', row.deployment_id, packed.hashes, handle);
} }
handle.exec("INSERT INTO settings(key,value) VALUES('recipe_blobs_v1','1')"); handle.exec("INSERT INTO settings(key,value) VALUES('recipe_blobs_v1','1')");
})(); })());
} }
handle.exec(`CREATE TRIGGER IF NOT EXISTS release_recipe_blobs AFTER DELETE ON server_launch_recipes BEGIN handle.exec(`CREATE TRIGGER IF NOT EXISTS release_recipe_blobs AFTER DELETE ON server_launch_recipes BEGIN
DELETE FROM file_blob_references WHERE owner_type='recipe' AND owner_id=OLD.deployment_id; END;`); DELETE FROM file_blob_references WHERE owner_type='recipe' AND owner_id=OLD.deployment_id; END;`);
+12 -7
View File
@@ -11,10 +11,9 @@ import { initializeFleetSettings } from './fleet-settings.js';
import { migrateServerHistory } from './server-history.js'; 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 { migrateFileBlobs } from './file-blobs.js'; import { migrateFileBlobs } from './file-blobs.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'; import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { tuneConnection } from './connection.js'; import { migrationStep, tuneConnection, tuneMigrationConnection } 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;
@@ -318,10 +317,11 @@ export function db(): Database.Database {
// singleton once migrations succeed. A half-migrated handle that's cached // singleton once migrations succeed. A half-migrated handle that's cached
// silently breaks every subsequent query without a chance to retry. // silently breaks every subsequent query without a chance to retry.
try { try {
console.info('[database] startup migrations starting');
handle.pragma('journal_mode = WAL'); handle.pragma('journal_mode = WAL');
handle.pragma('auto_vacuum = INCREMENTAL'); handle.pragma('auto_vacuum = INCREMENTAL');
handle.pragma('foreign_keys = ON'); handle.pragma('foreign_keys = ON');
tuneConnection(handle); tuneMigrationConnection(handle);
dropLegacyBetaTables(handle); dropLegacyBetaTables(handle);
handle.exec(schemaSql); handle.exec(schemaSql);
migrateProvisioningActions(handle); migrateProvisioningActions(handle);
@@ -394,8 +394,9 @@ export function db(): Database.Database {
]); ]);
backfillLeaderboard(handle); backfillLeaderboard(handle);
initializeFleetSettings(handle); initializeFleetSettings(handle);
migrateNativeInstances(handle); migrationStep('native instance schema and file plans', () => migrateNativeInstances(handle));
migrateVersionLayout(handle); // File-blob migration removes the legacy content column altogether.
// Reordering it first would unnecessarily copy every artifact twice.
migrateFileBlobs(handle); migrateFileBlobs(handle);
// Lifecycle history is retained indefinitely. Admission and status reads // Lifecycle history is retained indefinitely. Admission and status reads
// must narrow by server or active state instead of scanning that history. // must narrow by server or active state instead of scanning that history.
@@ -416,16 +417,20 @@ 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);
retentionClocks(handle, { migrationStep('retention clocks and indexes', () => retentionClocks(handle, {
deployments: ['started_at', 'finished_at'], server_deployments: ['started_at', 'finished_at'], deployments: ['started_at', 'finished_at'], server_deployments: ['started_at', 'finished_at'],
maintenance_jobs: ['requested_at', 'finished_at'], watchdog_incidents: ['first_seen_at', 'finished_at'], maintenance_jobs: ['requested_at', 'finished_at'], watchdog_incidents: ['first_seen_at', 'finished_at'],
leaderboard_matches: ['started_at', 'last_received_at', 'ended_at'], leaderboard_matches: ['started_at', 'last_received_at', 'ended_at'],
betas: ['opened_at', 'closed_at'], script_versions: ['uploaded_at'], retention_controls: ['resolved_at'] betas: ['opened_at', 'closed_at'], script_versions: ['uploaded_at'], retention_controls: ['resolved_at']
}); }));
// Existing databases require one rebuild to gain the incremental-vacuum page map. // Existing databases require one rebuild to gain the incremental-vacuum page map.
enableIncrementalVacuum(handle); enableIncrementalVacuum(handle);
handle.pragma('optimize = 0x10002'); handle.pragma('optimize = 0x10002');
handle.pragma('shrink_memory');
tuneConnection(handle);
console.info('[database] startup migrations complete');
} catch (err) { } catch (err) {
console.error('[database] startup migrations failed', err);
handle.close(); handle.close();
throw err; throw err;
} }
+19 -1
View File
@@ -1,4 +1,5 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { migrationStep, tuneMigrationConnection } from './connection.js';
/** Normalize age-policy clocks once, then compare indexed ISO UTC strings directly. */ /** Normalize age-policy clocks once, then compare indexed ISO UTC strings directly. */
export function retentionClocks(handle: Database.Database, clocks: Record<string, string[]>): void { export function retentionClocks(handle: Database.Database, clocks: Record<string, string[]>): void {
@@ -31,7 +32,24 @@ export function enableIncrementalVacuum(handle: Database.Database): void {
AND o.rowid<=e.operation_floor ORDER BY o.rowid DESC LIMIT 1) FROM ec_host_enable_requests e`); AND o.rowid<=e.operation_floor ORDER BY o.rowid DESC LIMIT 1) FROM ec_host_enable_requests e`);
} }
handle.pragma('auto_vacuum = INCREMENTAL'); handle.pragma('auto_vacuum = INCREMENTAL');
handle.exec('VACUUM'); // Guard this operation itself as well as startup: future callers must
// never inherit MEMORY temp storage or an oversized rebuild cache.
const previous = {
temp: handle.pragma('temp_store', { simple: true }),
cache: handle.pragma('cache_size', { simple: true }),
mmap: handle.pragma('mmap_size', { simple: true }),
journal: handle.pragma('journal_size_limit', { simple: true })
};
try {
tuneMigrationConnection(handle);
migrationStep('incremental vacuum page map (disk-backed rebuild)', () => handle.exec('VACUUM'));
} finally {
handle.pragma('shrink_memory');
handle.pragma(`temp_store = ${previous.temp}`);
handle.pragma(`cache_size = ${previous.cache}`);
if (previous.mmap !== undefined) handle.pragma(`mmap_size = ${previous.mmap}`);
handle.pragma(`journal_size_limit = ${previous.journal}`);
}
} }
if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='database_vacuum_boundaries'").get()) { if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='database_vacuum_boundaries'").get()) {
handle.transaction(() => { handle.transaction(() => {
+6 -1
View File
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import { ensureFileBlobSchema, migrationRows, packFileJson, referenceBlobs } from '../db/file-blobs.js'; import { ensureFileBlobSchema, migrationRows, packFileJson, referenceBlobs } from '../db/file-blobs.js';
import { retentionClocks } from '../db/retention-clocks.js'; import { retentionClocks } from '../db/retention-clocks.js';
import { installHistoryCounts } from './history-counts.js'; import { installHistoryCounts } from './history-counts.js';
import { migrationStep } from '../db/connection.js';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { successfulObservationSql, routineObservationSql, operationTargetSql, importantOperationSql } from './observations.js'; import { successfulObservationSql, routineObservationSql, operationTargetSql, importantOperationSql } from './observations.js';
@@ -10,6 +11,11 @@ export function ensureHostSchema(handle: Database.Database): void {
if (initialized.has(handle)) return; if (initialized.has(handle)) return;
// Background readers open only after controller migrations have completed. // Background readers open only after controller migrations have completed.
if (handle.readonly) { initialized.add(handle); return; } if (handle.readonly) { initialized.add(handle); return; }
migrationStep('host schema and saved command files', () => migrateHostSchema(handle));
initialized.add(handle);
}
function migrateHostSchema(handle: Database.Database): void {
ensureFileBlobSchema(handle); ensureFileBlobSchema(handle);
handle.exec(` handle.exec(`
CREATE TABLE IF NOT EXISTS ec_hosts ( CREATE TABLE IF NOT EXISTS ec_hosts (
@@ -189,5 +195,4 @@ export function ensureHostSchema(handle: Database.Database): void {
DELETE FROM file_blob_references WHERE owner_type IN ('operation','operation-result') AND owner_id=NEW.id; END;`); DELETE FROM file_blob_references WHERE owner_type IN ('operation','operation-result') AND owner_id=NEW.id; END;`);
retentionClocks(handle, { ec_host_operations: ['created_at', 'updated_at'] }); retentionClocks(handle, { ec_host_operations: ['created_at', 'updated_at'] });
installHistoryCounts(handle); installHistoryCounts(handle);
initialized.add(handle);
} }
+23
View File
@@ -0,0 +1,23 @@
import Database from 'better-sqlite3';
import { enableIncrementalVacuum } from '../../src/lib/server/db/retention-clocks.js';
// Run in a separate process so native SQLite memory, not just V8's heap, is measured.
const handle = new Database(process.argv[2]);
try {
handle.pragma('journal_mode = WAL');
handle.pragma('cache_size = -16384');
handle.exec('CREATE TABLE records(id INTEGER PRIMARY KEY, content BLOB)');
const insert = handle.prepare('INSERT INTO records(content) VALUES(zeroblob(1048576))');
handle.transaction(() => { for (let i = 0; i < 256; i++) insert.run(); })();
handle.pragma('wal_checkpoint(TRUNCATE)');
handle.pragma('shrink_memory');
// Reproduce the dangerous caller settings. The rebuild must override these.
handle.pragma('temp_store = MEMORY');
handle.pragma('cache_size = -131072');
const beforeKiB = process.resourceUsage().maxRSS;
enableIncrementalVacuum(handle);
console.log(JSON.stringify({ beforeKiB, peakKiB: process.resourceUsage().maxRSS,
vacuum: handle.pragma('auto_vacuum', { simple: true }),
integrity: handle.pragma('quick_check', { simple: true }),
rows: handle.prepare('SELECT COUNT(*) n FROM records').get() }));
} finally { handle.close(); }
+1 -1
View File
@@ -15,7 +15,7 @@ describe('prepared statement caching', () => {
try { try {
const handle = db(); const handle = db();
expect(handle.pragma('cache_size', { simple: true })).toBe(-131072); expect(handle.pragma('cache_size', { simple: true })).toBe(-131072);
expect(handle.pragma('temp_store', { simple: true })).toBe(2); expect(handle.pragma('temp_store', { simple: true })).toBe(1);
expect(handle.pragma('journal_size_limit', { simple: true })).toBe(67108864); expect(handle.pragma('journal_size_limit', { simple: true })).toBe(67108864);
const pragma = vi.spyOn(handle, 'pragma'); const pragma = vi.spyOn(handle, 'pragma');
vi.advanceTimersByTime(60 * 60_000); vi.advanceTimersByTime(60 * 60_000);
+75
View File
@@ -0,0 +1,75 @@
import Database from 'better-sqlite3';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { enableIncrementalVacuum } from '../../src/lib/server/db/retention-clocks.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); await fixture.cleanup(); });
it('keeps a 256 MiB database rebuild below a 160 MiB increase in resident memory', async () => {
const helper = fileURLToPath(new URL('../helpers/vacuum-memory.ts', import.meta.url));
const { stdout } = await promisify(execFile)(process.execPath, ['--import', 'tsx', helper, fixture.dbPath], { timeout: 60000 });
const result = JSON.parse(stdout.trim().split('\n').at(-1)!);
expect(result).toMatchObject({ vacuum: 2, integrity: 'ok', rows: { n: 256 } });
expect(result.peakKiB - result.beforeKiB).toBeLessThan(160 * 1024);
}, 65000);
it.each(['before', 'after'])('recovers a rebuild interrupted %s copying without losing host preparation boundaries', mode => {
let handle = new Database(fixture.dbPath);
try {
handle.exec(`CREATE TABLE ec_host_operations(id TEXT,host_id TEXT);
CREATE TABLE ec_host_enable_requests(host_id TEXT,operation_floor INTEGER);
INSERT INTO ec_host_operations(rowid,id,host_id) VALUES(10,'before','host'),(30,'after','host');
INSERT INTO ec_host_enable_requests VALUES('host',20)`);
handle.pragma('temp_store = MEMORY'); handle.pragma('cache_size = -131072');
const exec = handle.exec.bind(handle);
const mock = vi.spyOn(handle, 'exec').mockImplementation(sql => {
if (sql === 'VACUUM') {
expect(handle.pragma('temp_store', { simple: true })).toBe(1);
expect(handle.pragma('cache_size', { simple: true })).toBe(-16384);
if (mode === 'after') exec(sql);
throw new Error('simulated interruption');
}
return exec(sql);
});
expect(() => enableIncrementalVacuum(handle)).toThrow('simulated interruption');
mock.mockRestore();
expect(handle.pragma('temp_store', { simple: true })).toBe(2);
expect(handle.pragma('cache_size', { simple: true })).toBe(-131072);
handle.close(); handle = new Database(fixture.dbPath);
enableIncrementalVacuum(handle);
expect(handle.pragma('auto_vacuum', { simple: true })).toBe(2);
expect(handle.prepare(`SELECT o.id FROM ec_host_operations o JOIN ec_host_enable_requests e ON e.host_id=o.host_id
WHERE o.rowid>e.operation_floor`).all()).toEqual([{ id: 'after' }]);
expect(handle.prepare("SELECT 1 FROM sqlite_master WHERE name='database_vacuum_boundaries'").get()).toBeUndefined();
expect(handle.pragma('quick_check', { simple: true })).toBe('ok');
} finally { handle.close(); }
});
it('uses migration settings before startup writes and restores runtime tuning only after the rebuild', async () => {
const legacy = new Database(fixture.dbPath);
legacy.exec('CREATE TABLE legacy(id INTEGER PRIMARY KEY, content BLOB); INSERT INTO legacy(content) VALUES(zeroblob(1024))');
legacy.close();
vi.stubEnv('EC_SQLITE_MMAP_MB', '256');
const exec = Database.prototype.exec;
let rebuilt = false, migrated = false;
const mock = vi.spyOn(Database.prototype, 'exec').mockImplementation(function(this: Database.Database, sql) {
if (sql === 'VACUUM' || sql.includes('CREATE TABLE IF NOT EXISTS file_blobs')) {
expect(this.pragma('temp_store', { simple: true })).toBe(1);
expect(this.pragma('cache_size', { simple: true })).toBe(-16384);
expect(this.pragma('mmap_size', { simple: true })).toBe(0);
if (sql === 'VACUUM') rebuilt = true; else migrated = true;
}
return exec.call(this, sql);
});
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db(); mock.mockRestore();
expect(rebuilt && migrated).toBe(true);
expect(handle.pragma('temp_store', { simple: true })).toBe(1);
expect(handle.pragma('cache_size', { simple: true })).toBe(-131072);
expect(handle.pragma('mmap_size', { simple: true })).toBe(256 * 1024 * 1024);
});