fix: reduce log noise, harden DB reload, and tune caches
- Demote per-request game service logs to debug level - Add reentrant guard to DatabaseService.syncAndReload - Enable WAL mode + PRAGMA tuning for user.db - Align CachedSystemSettingsService cleanup to 90s interval - Extract CACHE_TTL constant in GameSearchCache - Add PerformanceMetrics hourly cleanup with .unref() - Fix migration timing to measure exec only, type result - Add parseInt NaN fallback for OTEL metrics interval - Make config object readonly with `as const`
This commit is contained in:
@@ -107,7 +107,7 @@ export const config = {
|
||||
// Auto-detected from version.txt; affects metadata sync and image path availability
|
||||
flashpointEdition: flashpointVersion.edition,
|
||||
flashpointVersionString: flashpointVersion.versionString,
|
||||
};
|
||||
} as const;
|
||||
|
||||
/** Resolves external image CDN URLs from Flashpoint preferences, with hardcoded fallbacks. */
|
||||
export async function getExternalImageUrls(): Promise<string[]> {
|
||||
|
||||
@@ -280,10 +280,10 @@ export class GameZipServer {
|
||||
let fileData = result.data;
|
||||
if (ext === 'html' || ext === 'htm') {
|
||||
fileData = injectPolyfills(result.data);
|
||||
logger.info(`[GameZipServer] Injected polyfills into HTML file: ${relPath}`);
|
||||
logger.debug(`[GameZipServer] Injected polyfills into HTML file: ${relPath}`);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`[GameZipServer] ✓ Serving from ZIP ${result.mountId}: ${relPath} (${fileData.length} bytes)`
|
||||
);
|
||||
|
||||
|
||||
@@ -162,8 +162,7 @@ export class LegacyServer {
|
||||
const host = hostname.split(':')[0];
|
||||
const relPath = path.posix.join(host, urlPath);
|
||||
|
||||
logger.debug(`[LegacyServer] Serving: ${host}${urlPath}`);
|
||||
logger.debug(`[LegacyServer] Relative path: ${relPath}`);
|
||||
logger.debug(`[LegacyServer] Serving: ${host}${urlPath} (relPath: ${relPath})`);
|
||||
|
||||
const pathCandidates = this.buildPathCandidates(relPath, urlPath, settings);
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export class ProxyRequestHandler {
|
||||
/** Replicates the request processing flow from Go's goproxy */
|
||||
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
try {
|
||||
logger.info(`[ProxyHandler] ${req.method} ${req.url}`);
|
||||
logger.debug(`[ProxyHandler] ${req.method} ${req.url}`);
|
||||
|
||||
if (!req.url) {
|
||||
this.sendError(res, 400, 'Bad Request: No URL');
|
||||
|
||||
@@ -175,12 +175,8 @@ export class ZipManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug(`[ZipManager] Reading from ZIP ${id}: ${normalizedPath}`);
|
||||
|
||||
const data = await mounted.zip.entryData(normalizedPath);
|
||||
|
||||
logger.debug(`[ZipManager] ✓ Read ${data.length} bytes from ${id}:${normalizedPath}`);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// File not found in ZIP
|
||||
|
||||
+12
-1
@@ -26,6 +26,7 @@ import { MetadataSyncJob } from './jobs/MetadataSyncJob';
|
||||
import { RuffleUpdateJob } from './jobs/RuffleUpdateJob';
|
||||
import { CachedSystemSettingsService } from './services/CachedSystemSettingsService';
|
||||
import { PermissionCache } from './services/PermissionCache';
|
||||
import { PerformanceMetrics } from './services/PerformanceMetrics';
|
||||
import { RuffleService } from './services/RuffleService';
|
||||
import { ConfigManager } from './game/config';
|
||||
import { PreferencesService } from './game/services/PreferencesService';
|
||||
@@ -259,7 +260,7 @@ async function startServer() {
|
||||
} else {
|
||||
logger.warn('File logging verification failed - check permissions');
|
||||
}
|
||||
}, 1000);
|
||||
}, 1000).unref();
|
||||
} else if (loggingStatus.fileError) {
|
||||
logger.warn(`File logging unavailable: ${loggingStatus.fileError}`);
|
||||
}
|
||||
@@ -321,6 +322,15 @@ async function startServer() {
|
||||
logger.error('Failed to cleanup old activity logs:', error);
|
||||
});
|
||||
|
||||
// Cleanup old performance metrics every hour
|
||||
const metricsCleanupInterval = setInterval(
|
||||
() => {
|
||||
PerformanceMetrics.cleanupOldMetrics();
|
||||
},
|
||||
60 * 60 * 1000
|
||||
);
|
||||
metricsCleanupInterval.unref();
|
||||
|
||||
const jobExecutionService = new JobExecutionService();
|
||||
const jobLogCleanupInterval = setInterval(
|
||||
() => {
|
||||
@@ -343,6 +353,7 @@ async function startServer() {
|
||||
clearInterval(loginAttemptsCleanupInterval);
|
||||
clearInterval(activityLogsCleanupInterval);
|
||||
clearInterval(jobLogCleanupInterval);
|
||||
clearInterval(metricsCleanupInterval);
|
||||
|
||||
try {
|
||||
gameZipServer.dispose();
|
||||
|
||||
@@ -50,8 +50,8 @@ export class CachedSystemSettingsService extends SystemSettingsService {
|
||||
super();
|
||||
this.cacheTTL = cacheTTL;
|
||||
|
||||
// Start cache cleanup interval (every 5 minutes)
|
||||
this.cleanupIntervalId = setInterval(() => this.cleanup(), 5 * 60 * 1000).unref();
|
||||
// Start cache cleanup interval — aligned to cache TTL (90s vs 60s TTL)
|
||||
this.cleanupIntervalId = setInterval(() => this.cleanup(), 90 * 1000).unref();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,7 @@ export class DatabaseService {
|
||||
private static sourceDbPath: string = ''; // Original path (may be network)
|
||||
private static activeDbPath: string = ''; // Path actually used (local or network)
|
||||
private static isUsingLocalCopy: boolean = false;
|
||||
private static isReloading: boolean = false;
|
||||
|
||||
static async initialize(): Promise<void> {
|
||||
try {
|
||||
@@ -299,6 +300,11 @@ export class DatabaseService {
|
||||
* Called when source database file changes
|
||||
*/
|
||||
private static async syncAndReload(): Promise<void> {
|
||||
if (this.isReloading) {
|
||||
logger.debug('syncAndReload already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
this.isReloading = true;
|
||||
try {
|
||||
// Check if file was actually modified
|
||||
const stats = fs.statSync(this.sourceDbPath);
|
||||
@@ -345,12 +351,15 @@ export class DatabaseService {
|
||||
logger.error('Failed to sync and reload database:', error);
|
||||
// Log but don't retry — calling initialize() here risks rapid retry loops
|
||||
// since it re-registers the file watcher which could trigger another syncAndReload
|
||||
} finally {
|
||||
this.isReloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backward compatibility
|
||||
* @deprecated Use syncAndReload instead
|
||||
* @see syncAndReload
|
||||
*/
|
||||
private static async reloadFromDisk(): Promise<void> {
|
||||
return this.syncAndReload();
|
||||
@@ -542,9 +551,9 @@ export class DatabaseService {
|
||||
);
|
||||
}
|
||||
|
||||
// Save database changes to disk
|
||||
// Note: With better-sqlite3 in DELETE journal mode, changes are automatically written to disk
|
||||
// This method exists for compatibility but is essentially a no-op
|
||||
/**
|
||||
* @deprecated No-op — changes are automatically flushed in DELETE journal mode.
|
||||
*/
|
||||
static save(): void {
|
||||
// No action needed in DELETE journal mode
|
||||
// Changes are automatically flushed to disk
|
||||
|
||||
@@ -17,13 +17,15 @@ import { config } from '../config';
|
||||
* since Flashpoint database is read-only and changes are rare)
|
||||
*/
|
||||
export class GameSearchCache {
|
||||
private static readonly CACHE_TTL = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
// In-flight request tracking for cache stampede prevention
|
||||
private static inFlightRequests = new Map<string, Promise<PaginatedResult<Game>>>();
|
||||
|
||||
private static cache = new LRUCache<string, PaginatedResult<Game>>({
|
||||
max: 1000, // Maximum 1000 cached search queries
|
||||
maxSize: 10000, // Maximum total items across all cached results (10k games)
|
||||
ttl: 1000 * 60 * 5, // 5 minutes TTL
|
||||
ttl: GameSearchCache.CACHE_TTL,
|
||||
updateAgeOnGet: true, // Reset TTL on cache hit
|
||||
allowStale: false, // Never return stale data
|
||||
|
||||
@@ -197,7 +199,7 @@ export class GameSearchCache {
|
||||
primary: {
|
||||
size: this.cache.size,
|
||||
max: this.cache.max,
|
||||
ttl: 1000 * 60 * 5, // 5 minutes
|
||||
ttl: GameSearchCache.CACHE_TTL,
|
||||
},
|
||||
fallback: this.fallbackCache.getStats(),
|
||||
};
|
||||
|
||||
@@ -22,8 +22,11 @@ export class UserDatabaseService {
|
||||
this.db = new BetterSqlite3(config.userDbPath);
|
||||
logger.info(`[UserDB] Opened database at: ${config.userDbPath}`);
|
||||
|
||||
// Enable foreign keys
|
||||
// Apply PRAGMA optimizations (WAL mode for better concurrent read/write)
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
this.db.pragma('cache_size = -8000'); // 8MB cache
|
||||
this.db.pragma('mmap_size = 67108864'); // 64MB mmap
|
||||
|
||||
// Bootstrap migration registry
|
||||
await this.bootstrapMigrationRegistry();
|
||||
@@ -153,21 +156,21 @@ export class UserDatabaseService {
|
||||
// Read migration SQL
|
||||
const sql = fs.readFileSync(migrationPath, 'utf-8');
|
||||
const checksum = this.computeChecksum(sql);
|
||||
const start = Date.now();
|
||||
|
||||
// Wrap migration execution and registry insert in a single transaction
|
||||
const runMigration = this.db!.transaction(() => {
|
||||
const start = Date.now();
|
||||
this.db!.exec(sql);
|
||||
|
||||
const executionTime = Date.now() - start;
|
||||
|
||||
this.db!.prepare(
|
||||
'INSERT INTO migrations (name, applied_at, checksum, execution_time_ms) VALUES (?, ?, ?, ?)'
|
||||
).run(name, new Date().toISOString(), checksum, executionTime);
|
||||
|
||||
return executionTime;
|
||||
});
|
||||
|
||||
runMigration();
|
||||
|
||||
const executionTime = Date.now() - start;
|
||||
const executionTime = runMigration();
|
||||
logger.info(`[UserDB] Migration completed: ${name} (${executionTime}ms)`);
|
||||
} else {
|
||||
logger.debug(`[UserDB] Migration already applied: ${name}`);
|
||||
@@ -186,8 +189,9 @@ export class UserDatabaseService {
|
||||
*/
|
||||
static needsInitialSetup(): boolean {
|
||||
try {
|
||||
const userCount = this.get('SELECT COUNT(*) as count FROM users', []);
|
||||
const needsSetup = userCount?.count === 0;
|
||||
const userCount = this.get<{ count: number }>('SELECT COUNT(*) as count FROM users', []);
|
||||
const count = userCount?.count ?? 0;
|
||||
const needsSetup = count === 0;
|
||||
|
||||
if (needsSetup) {
|
||||
logger.info('╔═══════════════════════════════════════════════════════════════╗');
|
||||
@@ -196,7 +200,7 @@ export class UserDatabaseService {
|
||||
logger.info('║ The first registered user will become an administrator. ║');
|
||||
logger.info('╚═══════════════════════════════════════════════════════════════╝');
|
||||
} else {
|
||||
logger.debug(`[UserDB] Found ${userCount.count} existing user(s)`);
|
||||
logger.debug(`[UserDB] Found ${count} existing user(s)`);
|
||||
}
|
||||
|
||||
return needsSetup;
|
||||
|
||||
@@ -44,7 +44,8 @@ function getOtelConfig() {
|
||||
serviceVersion: process.env.npm_package_version || '1.0.0',
|
||||
tracesEnabled: process.env.OTEL_TRACES_ENABLED !== 'false',
|
||||
metricsEnabled: process.env.OTEL_METRICS_ENABLED !== 'false',
|
||||
metricsExportInterval: parseInt(process.env.OTEL_METRICS_EXPORT_INTERVAL || '60000', 10),
|
||||
metricsExportInterval:
|
||||
parseInt(process.env.OTEL_METRICS_EXPORT_INTERVAL || '60000', 10) || 60000,
|
||||
logLevel: process.env.OTEL_LOG_LEVEL || 'info',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user