fix: state persistence diagnostics + fallback write

- loadState: logs DATA_DIR path, writability check, lists files, shows
  radio_favorites count on load
- saveState: read-back verification after atomic write, fallback to
  direct write if rename fails
- /api/health: shows state diagnostics (file exists, file size, keys,
  favorites count in memory vs disk, lastSaveOk)
- Helps diagnose why favorites are not persisting across deploys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 20:22:07 +01:00
parent fcf4ba86ce
commit 55f311c612
2 changed files with 80 additions and 6 deletions

View file

@ -7,19 +7,45 @@ const backupFile = stateFile + '.bak';
const tmpFile = stateFile + '.tmp'; const tmpFile = stateFile + '.tmp';
let state: Record<string, any> = {}; let state: Record<string, any> = {};
let lastSaveOk = false;
export function loadState(): void { export function loadState(): void {
console.log(`[State] DATA_DIR=${DATA_DIR}, stateFile=${stateFile}`);
// Check if DATA_DIR exists and is writable
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
const testFile = path.join(DATA_DIR, '.write-test');
fs.writeFileSync(testFile, 'ok');
fs.unlinkSync(testFile);
console.log(`[State] DATA_DIR is writable`);
} catch (e) {
console.error(`[State] ⚠ DATA_DIR is NOT writable:`, e);
}
// List existing files in DATA_DIR for diagnostics
try {
const files = fs.readdirSync(DATA_DIR);
console.log(`[State] Files in DATA_DIR: ${files.join(', ') || '(empty)'}`);
} catch (e) {
console.error(`[State] Cannot list DATA_DIR:`, e);
}
// Try main file first, fall back to backup if corrupted // Try main file first, fall back to backup if corrupted
for (const file of [stateFile, backupFile]) { for (const file of [stateFile, backupFile]) {
try { try {
if (fs.existsSync(file)) { if (fs.existsSync(file)) {
state = JSON.parse(fs.readFileSync(file, 'utf-8')); const raw = fs.readFileSync(file, 'utf-8');
state = JSON.parse(raw);
const keys = Object.keys(state); const keys = Object.keys(state);
console.log(`[State] Loaded from ${path.basename(file)} (${keys.length} keys: ${keys.join(', ')})`); const favCount = Array.isArray(state.radio_favorites) ? state.radio_favorites.length : 0;
console.log(`[State] ✓ Loaded from ${path.basename(file)} (${keys.length} keys: ${keys.join(', ')})`);
console.log(`[State] radio_favorites: ${favCount} entries`);
// Create backup on successful load from main file // Create backup on successful load from main file
if (file === stateFile) { if (file === stateFile) {
try { fs.copyFileSync(stateFile, backupFile); } catch {} try { fs.copyFileSync(stateFile, backupFile); } catch {}
} }
lastSaveOk = true;
return; return;
} }
} catch (e) { } catch (e) {
@ -32,12 +58,29 @@ export function loadState(): void {
export function saveState(): void { export function saveState(): void {
try { try {
fs.mkdirSync(path.dirname(stateFile), { recursive: true }); fs.mkdirSync(path.dirname(stateFile), { recursive: true });
const json = JSON.stringify(state, null, 2);
// Atomic write: write to tmp file, then rename // Atomic write: write to tmp file, then rename
// rename() is atomic on POSIX filesystems, prevents corruption on crash fs.writeFileSync(tmpFile, json);
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2));
fs.renameSync(tmpFile, stateFile); fs.renameSync(tmpFile, stateFile);
// Verify write by reading back
const verify = fs.readFileSync(stateFile, 'utf-8');
if (verify.length !== json.length) {
console.error(`[State] ⚠ Write verification FAILED: wrote ${json.length} bytes, read back ${verify.length}`);
lastSaveOk = false;
} else {
lastSaveOk = true;
}
} catch (e) { } catch (e) {
console.error('[State] Failed to save:', e); console.error('[State] ✗ Failed to save:', e);
lastSaveOk = false;
// Fallback: try direct write if rename failed
try {
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
console.log('[State] Fallback direct write succeeded');
lastSaveOk = true;
} catch (e2) {
console.error('[State] ✗ Fallback write also failed:', e2);
}
} }
} }
@ -53,3 +96,33 @@ export function setState(key: string, value: any): void {
export function getFullState(): Record<string, any> { export function getFullState(): Record<string, any> {
return { ...state }; return { ...state };
} }
/** Diagnostic info for health endpoint */
export function getStateDiag(): Record<string, any> {
let fileExists = false;
let fileSize = 0;
let fileKeys: string[] = [];
let fileFavCount = 0;
try {
if (fs.existsSync(stateFile)) {
fileExists = true;
const stat = fs.statSync(stateFile);
fileSize = stat.size;
const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
fileKeys = Object.keys(parsed);
fileFavCount = Array.isArray(parsed.radio_favorites) ? parsed.radio_favorites.length : 0;
}
} catch {}
const memKeys = Object.keys(state);
const memFavCount = Array.isArray(state.radio_favorites) ? state.radio_favorites.length : 0;
return {
stateFile,
fileExists,
fileSize,
fileKeys,
fileFavCount,
memoryKeys: memKeys,
memoryFavCount: memFavCount,
lastSaveOk,
};
}

View file

@ -3,7 +3,7 @@ import path from 'node:path';
import { Client } from 'discord.js'; import { Client } from 'discord.js';
import { createClient } from './core/discord.js'; import { createClient } from './core/discord.js';
import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js'; import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js';
import { loadState, getFullState } from './core/persistence.js'; import { loadState, getFullState, getStateDiag } from './core/persistence.js';
import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js'; import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js';
import radioPlugin from './plugins/radio/index.js'; import radioPlugin from './plugins/radio/index.js';
import soundboardPlugin from './plugins/soundboard/index.js'; import soundboardPlugin from './plugins/soundboard/index.js';
@ -77,6 +77,7 @@ app.get('/api/health', (_req, res) => {
plugins: getPlugins().map(p => ({ name: p.name, version: p.version })), plugins: getPlugins().map(p => ({ name: p.name, version: p.version })),
bots: clients.map(b => ({ name: b.name, user: b.client.user?.tag ?? 'offline', guilds: b.client.guilds.cache.size })), bots: clients.map(b => ({ name: b.name, user: b.client.user?.tag ?? 'offline', guilds: b.client.guilds.cache.size })),
sseClients: getSSEClientCount(), sseClients: getSSEClientCount(),
state: getStateDiag(),
}); });
}); });