mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add worker threads, persistent Python sidecar, graceful shutdown, and architectural improvements
- 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
This commit is contained in:
@@ -43,7 +43,7 @@ export function shouldRunStartupCleanup(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function startCleanupCron() {
|
||||
export function startCleanupCron(): { stop: () => void } {
|
||||
// Ensure workspace directory exists
|
||||
mkdirSync(env.WORKSPACE_PATH, { recursive: true });
|
||||
|
||||
@@ -97,9 +97,16 @@ export function startCleanupCron() {
|
||||
}
|
||||
|
||||
// Schedule recurring cleanup
|
||||
setInterval(cleanup, intervalMs);
|
||||
setInterval(purgeExpiredSessions, 60 * 60 * 1000); // Hourly
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { env } from "../config.js";
|
||||
|
||||
@@ -49,3 +49,39 @@ export async function deleteStoredFile(storedName: string): Promise<void> {
|
||||
export function getStoredFilePath(storedName: string): string {
|
||||
return join(env.FILES_STORAGE_PATH, storedName);
|
||||
}
|
||||
|
||||
// ── Thumbnail cache ──────────────────────────────────────────────────
|
||||
|
||||
const THUMB_DIR = ".thumbs";
|
||||
let thumbDirReady = false;
|
||||
|
||||
async function ensureThumbDir(): Promise<void> {
|
||||
if (thumbDirReady) return;
|
||||
await mkdir(join(env.FILES_STORAGE_PATH, THUMB_DIR), { recursive: true });
|
||||
thumbDirReady = true;
|
||||
}
|
||||
|
||||
function thumbPath(storedName: string): string {
|
||||
return join(env.FILES_STORAGE_PATH, THUMB_DIR, `${storedName}.thumb.jpg`);
|
||||
}
|
||||
|
||||
export async function getCachedThumbnail(storedName: string): Promise<Buffer | null> {
|
||||
try {
|
||||
return await readFile(thumbPath(storedName));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveThumbnail(storedName: string, buffer: Buffer): Promise<void> {
|
||||
await ensureThumbDir();
|
||||
await writeFile(thumbPath(storedName), buffer);
|
||||
}
|
||||
|
||||
export async function deleteThumbnail(storedName: string): Promise<void> {
|
||||
try {
|
||||
await unlink(thumbPath(storedName));
|
||||
} catch {
|
||||
// Thumbnail may not exist
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Piscina worker that executes image tool processing in a worker thread.
|
||||
*
|
||||
* On first call, it imports all tool registration modules using a mock
|
||||
* Fastify instance (only the registry-populating side effects are needed,
|
||||
* not the HTTP route registrations). Subsequent calls reuse the populated
|
||||
* registry for O(1) lookup.
|
||||
*/
|
||||
import { autoOrient } from "./auto-orient.js";
|
||||
|
||||
export interface WorkerInput {
|
||||
toolId: string;
|
||||
inputBuffer: Buffer;
|
||||
settings: unknown;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface WorkerOutput {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
let registryReady = false;
|
||||
|
||||
async function ensureRegistry(): Promise<void> {
|
||||
if (registryReady) return;
|
||||
|
||||
// Create a minimal mock that satisfies the register functions.
|
||||
// createToolRoute calls app.post() (no-op here) and toolRegistry.set() (the part we want).
|
||||
// AI tools also call app.post() and registerToolProcessFn() (also populates the registry).
|
||||
const mockApp = {
|
||||
post: () => {},
|
||||
get: () => {},
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
};
|
||||
|
||||
const { registerToolRoutes } = await import("../routes/tools/index.js");
|
||||
await registerToolRoutes(mockApp as never);
|
||||
registryReady = true;
|
||||
}
|
||||
|
||||
export default async function processInWorker(input: WorkerInput): Promise<WorkerOutput> {
|
||||
await ensureRegistry();
|
||||
|
||||
const { getToolConfig } = await import("../routes/tool-factory.js");
|
||||
const config = getToolConfig(input.toolId);
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`Tool "${input.toolId}" not found in worker registry`);
|
||||
}
|
||||
|
||||
const oriented = await autoOrient(Buffer.from(input.inputBuffer));
|
||||
const result = await config.process(oriented, input.settings, input.filename);
|
||||
|
||||
return {
|
||||
buffer: result.buffer,
|
||||
filename: result.filename,
|
||||
contentType: result.contentType,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Worker pool for offloading CPU-bound image processing from the main event loop.
|
||||
*
|
||||
* Uses Piscina (backed by worker_threads) so Sharp operations don't block
|
||||
* HTTP request handling, SSE streams, or health checks.
|
||||
*/
|
||||
import { availableParallelism } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Piscina from "piscina";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Size the pool: leave 1 thread for the event loop, min 1 worker
|
||||
const maxThreads = Math.max(1, Math.min(availableParallelism() - 1, 4));
|
||||
|
||||
let pool: Piscina | null = null;
|
||||
|
||||
export function getWorkerPool(): Piscina {
|
||||
if (!pool) {
|
||||
pool = new Piscina({
|
||||
filename: resolve(__dirname, "image-worker.ts"),
|
||||
// Inherit tsx loader flags from the main process so .ts files work in workers
|
||||
execArgv: [...process.execArgv],
|
||||
maxThreads,
|
||||
idleTimeout: 30000,
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function shutdownWorkerPool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.destroy();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user