Nightly DB snapshots to the 1TB storage box (live DB stays local)
The 'external drive' turned out to be a CIFS-mounted Hetzner storage box — SQLite cannot run on a network mount (WAL shared memory and locking break), and the live DB is only ~7MB anyway. So: the hot database stays on the local volume, and a nightly VACUUM INTO snapshot (transactionally consistent, a plain sequential file write — perfect for CIFS) lands in /data/archive, which compose binds to the storage box via HOST_ARCHIVE_DIR. 14-day retention, plus a snapshot at boot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,12 +20,14 @@ services:
|
|||||||
# opt-in unofficial sources (usually blocked from a datacenter IP; ESPN is primary)
|
# opt-in unofficial sources (usually blocked from a datacenter IP; ESPN is primary)
|
||||||
- ENABLE_SOFASCORE=${ENABLE_SOFASCORE:-false}
|
- ENABLE_SOFASCORE=${ENABLE_SOFASCORE:-false}
|
||||||
- ENABLE_FOTMOB=${ENABLE_FOTMOB:-false}
|
- ENABLE_FOTMOB=${ENABLE_FOTMOB:-false}
|
||||||
|
- ARCHIVE_DIR=/data/archive
|
||||||
volumes:
|
volumes:
|
||||||
# HOST_DATA_DIR (in .env) may point at a host path — e.g. the external
|
# The LIVE SQLite must stay on a real local filesystem (WAL + locking do
|
||||||
# 1TB drive — so the growing SQLite DB stays off the small root disk.
|
# not survive network mounts). HOST_DATA_DIR can override if ever needed.
|
||||||
# An absolute path = bind mount; unset = the original named volume.
|
|
||||||
# The container runs as node (uid 1000): chown 1000:1000 the host dir.
|
|
||||||
- ${HOST_DATA_DIR:-cup26-data}:/data
|
- ${HOST_DATA_DIR:-cup26-data}:/data
|
||||||
|
# Nightly VACUUM INTO snapshots land here — point HOST_ARCHIVE_DIR at the
|
||||||
|
# 1TB storage box (CIFS is fine for sequential snapshot files).
|
||||||
|
- ${HOST_ARCHIVE_DIR:-cup26-archive}:/data/archive
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
mem_limit: 384m
|
mem_limit: 384m
|
||||||
@@ -43,6 +45,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
cup26-data:
|
cup26-data:
|
||||||
|
cup26-archive:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
|
|||||||
+24
-1
@@ -1,5 +1,5 @@
|
|||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync, readdirSync, rmSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
// Persistence via Node's built-in SQLite (no native deps). One file on a Docker
|
// Persistence via Node's built-in SQLite (no native deps). One file on a Docker
|
||||||
@@ -135,6 +135,29 @@ export function cacheSet(key: string, value: string, ttl: number): void {
|
|||||||
.run(key, value, Date.now(), ttl);
|
.run(key, value, Date.now(), ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nightly consistent snapshot via VACUUM INTO (transaction-safe, works on any
|
||||||
|
* filesystem — the archive dir may be a CIFS-mounted storage box, where the
|
||||||
|
* LIVE database could never run). Keeps the newest `keep` dated copies. */
|
||||||
|
export function backupDb(keep = 14): string | null {
|
||||||
|
const dir = process.env.ARCHIVE_DIR ?? path.join(DATA_DIR, 'archive');
|
||||||
|
try {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
const dest = path.join(dir, `cup26-${stamp}.db`);
|
||||||
|
rmSync(dest, { force: true }); // VACUUM INTO refuses to overwrite
|
||||||
|
db().exec(`VACUUM INTO '${dest.replaceAll("'", "''")}'`);
|
||||||
|
const files = readdirSync(dir).filter((f) => /^cup26-\d{4}-\d{2}-\d{2}\.db$/.test(f)).sort();
|
||||||
|
for (const f of files.slice(0, Math.max(0, files.length - keep))) {
|
||||||
|
rmSync(path.join(dir, f), { force: true });
|
||||||
|
}
|
||||||
|
console.log(`[db] snapshot → ${dest}`);
|
||||||
|
return dest;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[db] backup failed:', e instanceof Error ? e.message : e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Bound DB growth: drop cache entries stale for >7 days (the breaker only ever
|
/** Bound DB growth: drop cache entries stale for >7 days (the breaker only ever
|
||||||
* serves recent staleness) and ingest-log rows older than 14 days. Returns the
|
* serves recent staleness) and ingest-log rows older than 14 days. Returns the
|
||||||
* number of rows removed. Runs daily from the scheduler. */
|
* number of rows removed. Runs daily from the scheduler. */
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { fetchEspnScoreboard, fetchEspnSummary } from './espn';
|
|||||||
import { fetchEspnOdds } from './espnOdds';
|
import { fetchEspnOdds } from './espnOdds';
|
||||||
import { fetchFootballData } from '../footballData';
|
import { fetchFootballData } from '../footballData';
|
||||||
import { fetchSofascore } from '../sofascore';
|
import { fetchSofascore } from '../sofascore';
|
||||||
import { setSourceMap, getSourceMap, setMatchExt, recordOdds, logIngest, prune } from '../db/db';
|
import { setSourceMap, getSourceMap, setMatchExt, recordOdds, logIngest, prune, backupDb } from '../db/db';
|
||||||
import type { Fixture } from '../../../src/lib/types';
|
import type { Fixture } from '../../../src/lib/types';
|
||||||
|
|
||||||
// The ingestion orchestrator. Replaces the old live loop: ESPN is the primary
|
// The ingestion orchestrator. Replaces the old live loop: ESPN is the primary
|
||||||
@@ -161,13 +161,17 @@ export function startScheduler(
|
|||||||
oddsTimer = setTimeout(async () => { await oddsTick().catch(() => {}); scheduleOdds(); }, ODDS_MS);
|
oddsTimer = setTimeout(async () => { await oddsTick().catch(() => {}); scheduleOdds(); }, ODDS_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Daily prune keeps the faster polling from growing kv_cache/ingest_log forever.
|
// Daily housekeeping: prune keeps the faster polling from growing
|
||||||
const pruneTimer = setInterval(() => {
|
// kv_cache/ingest_log forever; backupDb snapshots to the archive dir
|
||||||
|
// (the storage box in prod) so history survives anything.
|
||||||
|
const housekeeping = (): void => {
|
||||||
try {
|
try {
|
||||||
const n = prune();
|
const n = prune();
|
||||||
if (n) console.log(`[db] pruned ${n} stale cache/log rows`);
|
if (n) console.log(`[db] pruned ${n} stale cache/log rows`);
|
||||||
} catch { /* non-fatal */ }
|
} catch { /* non-fatal */ }
|
||||||
}, 24 * 60 * 60 * 1000);
|
backupDb();
|
||||||
|
};
|
||||||
|
const pruneTimer = setInterval(housekeeping, 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const scheduleLive = (): void => {
|
const scheduleLive = (): void => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
@@ -189,6 +193,7 @@ export function startScheduler(
|
|||||||
scheduleLive();
|
scheduleLive();
|
||||||
scheduleEnrich();
|
scheduleEnrich();
|
||||||
scheduleOdds();
|
scheduleOdds();
|
||||||
|
backupDb(); // boot snapshot — the daily timer covers the rest
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user