mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Graceful shutdown: SIGTERM/SIGINT handlers drain HTTP, stop workers, close DB - Thumbnail caching: disk-cached thumbnails with immutable Cache-Control headers - Worker thread pool: Piscina offloads Sharp processing off the main event loop - Persistent Python dispatcher: pre-imports ML libraries, eliminates cold-start latency - Tool page registry: declarative tool-to-component mapping replaces 750-line switch - File store cleanup: remove dead derived fields, stable files array reference - Job persistence: progress written to SQLite jobs table, stale jobs recovered on startup
113 lines
3.2 KiB
TypeScript
113 lines
3.2 KiB
TypeScript
import { mkdirSync } from "node:fs";
|
|
import { readdir, rm, stat } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { eq, lt } from "drizzle-orm";
|
|
import { env } from "../config.js";
|
|
import { db, schema } from "../db/index.js";
|
|
|
|
/**
|
|
* Read the temp file max age from DB settings, falling back to env var.
|
|
* Called each cleanup cycle so changes take effect without restart.
|
|
*/
|
|
export function getMaxAgeMs(): number {
|
|
try {
|
|
const row = db
|
|
.select()
|
|
.from(schema.settings)
|
|
.where(eq(schema.settings.key, "tempFileMaxAgeHours"))
|
|
.get();
|
|
if (row) {
|
|
const hours = parseFloat(row.value);
|
|
if (!Number.isNaN(hours) && hours > 0) return hours * 60 * 60 * 1000;
|
|
}
|
|
} catch {
|
|
/* DB not ready yet, use env */
|
|
}
|
|
return env.FILE_MAX_AGE_HOURS * 60 * 60 * 1000;
|
|
}
|
|
|
|
/**
|
|
* Check whether startup cleanup should run.
|
|
* Returns true by default; only returns false when explicitly set to "false".
|
|
*/
|
|
export function shouldRunStartupCleanup(): boolean {
|
|
try {
|
|
const row = db
|
|
.select()
|
|
.from(schema.settings)
|
|
.where(eq(schema.settings.key, "startupCleanup"))
|
|
.get();
|
|
return row ? row.value !== "false" : true;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export function startCleanupCron(): { stop: () => void } {
|
|
// Ensure workspace directory exists
|
|
mkdirSync(env.WORKSPACE_PATH, { recursive: true });
|
|
|
|
const intervalMs = env.CLEANUP_INTERVAL_MINUTES * 60 * 1000;
|
|
|
|
const cleanup = async () => {
|
|
const maxAgeMs = getMaxAgeMs();
|
|
try {
|
|
const entries = await readdir(env.WORKSPACE_PATH, { withFileTypes: true }).catch(() => []);
|
|
const now = Date.now();
|
|
let cleaned = 0;
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = join(env.WORKSPACE_PATH, entry.name);
|
|
try {
|
|
const stats = await stat(fullPath);
|
|
if (now - stats.mtimeMs > maxAgeMs) {
|
|
await rm(fullPath, { recursive: true });
|
|
cleaned++;
|
|
}
|
|
} catch {
|
|
// Skip files that can't be stat'd
|
|
}
|
|
}
|
|
|
|
if (cleaned > 0) {
|
|
console.log(`Cleanup: removed ${cleaned} expired workspace entries`);
|
|
}
|
|
} catch (err) {
|
|
console.error("Cleanup error:", err);
|
|
}
|
|
};
|
|
|
|
// Purge expired sessions from the database
|
|
const purgeExpiredSessions = () => {
|
|
try {
|
|
const now = new Date();
|
|
const result = db.delete(schema.sessions).where(lt(schema.sessions.expiresAt, now)).run();
|
|
if (result.changes > 0) {
|
|
console.log(`Cleanup: purged ${result.changes} expired sessions`);
|
|
}
|
|
} catch (err) {
|
|
console.error("Session cleanup error:", err);
|
|
}
|
|
};
|
|
|
|
// Run on startup only if setting allows it
|
|
if (shouldRunStartupCleanup()) {
|
|
cleanup();
|
|
purgeExpiredSessions();
|
|
}
|
|
|
|
// Schedule recurring cleanup
|
|
const cleanupTimer = setInterval(cleanup, intervalMs);
|
|
const sessionTimer = setInterval(purgeExpiredSessions, 60 * 60 * 1000); // Hourly
|
|
console.log(
|
|
`Cleanup scheduled: every ${env.CLEANUP_INTERVAL_MINUTES}m, max age configurable (env default: ${env.FILE_MAX_AGE_HOURS}h)`,
|
|
);
|
|
|
|
return {
|
|
stop: () => {
|
|
clearInterval(cleanupTimer);
|
|
clearInterval(sessionTimer);
|
|
},
|
|
};
|
|
}
|