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:
+221
-10
@@ -1,4 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -41,16 +42,200 @@ function extractPythonError(error: unknown): string {
|
||||
|
||||
export type ProgressCallback = (percent: number, stage: string) => void;
|
||||
|
||||
// ── Persistent dispatcher ───────────────────────────────────────────
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (result: { stdout: string; stderr: string }) => void;
|
||||
reject: (err: Error) => void;
|
||||
onProgress?: ProgressCallback;
|
||||
stderrLines: string[];
|
||||
}
|
||||
|
||||
let dispatcher: ChildProcess | null = null;
|
||||
let dispatcherReady = false;
|
||||
let dispatcherFailed = false;
|
||||
const pendingRequests = new Map<string, PendingRequest>();
|
||||
let stdoutBuffer = "";
|
||||
|
||||
function startDispatcher(): ChildProcess | null {
|
||||
if (dispatcherFailed) return null;
|
||||
|
||||
try {
|
||||
const child = spawn(getPythonPath(), [resolve(PYTHON_DIR, "dispatcher.py")], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderrBuffer = "";
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderrBuffer += chunk.toString();
|
||||
const lines = stderrBuffer.split("\n");
|
||||
stderrBuffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
|
||||
// Readiness signal
|
||||
if (parsed.ready === true) {
|
||||
dispatcherReady = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Progress event - route to the currently active request
|
||||
if (typeof parsed.progress === "number" && typeof parsed.stage === "string") {
|
||||
// Progress goes to all pending requests (only one should be active at a time
|
||||
// since Python processes synchronously)
|
||||
for (const req of pendingRequests.values()) {
|
||||
req.onProgress?.(parsed.progress, parsed.stage);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON - collect as error output for pending requests
|
||||
for (const req of pendingRequests.values()) {
|
||||
req.stderrLines.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
stdoutBuffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const response = JSON.parse(trimmed);
|
||||
const reqId = response.id;
|
||||
const pending = pendingRequests.get(reqId);
|
||||
if (pending) {
|
||||
pendingRequests.delete(reqId);
|
||||
if (response.exitCode !== 0) {
|
||||
pending.reject(
|
||||
new Error(
|
||||
extractPythonError({
|
||||
stdout: response.stdout,
|
||||
stderr: pending.stderrLines.join("\n"),
|
||||
}) || `Python script exited with code ${response.exitCode}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
pending.resolve({
|
||||
stdout: response.stdout || "",
|
||||
stderr: pending.stderrLines.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not a valid response line
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") {
|
||||
// Venv python not found - mark as failed, will fall back to per-request
|
||||
dispatcherFailed = true;
|
||||
}
|
||||
// Reject all pending requests
|
||||
for (const [id, req] of pendingRequests.entries()) {
|
||||
req.reject(new Error(extractPythonError(err)));
|
||||
pendingRequests.delete(id);
|
||||
}
|
||||
dispatcher = null;
|
||||
dispatcherReady = false;
|
||||
});
|
||||
|
||||
child.on("close", () => {
|
||||
// Reject all pending requests
|
||||
for (const [id, req] of pendingRequests.entries()) {
|
||||
req.reject(new Error("Python dispatcher exited unexpectedly"));
|
||||
pendingRequests.delete(id);
|
||||
}
|
||||
dispatcher = null;
|
||||
dispatcherReady = false;
|
||||
});
|
||||
|
||||
return child;
|
||||
} catch {
|
||||
dispatcherFailed = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDispatcher(): ChildProcess | null {
|
||||
if (dispatcherFailed) return null;
|
||||
if (!dispatcher || dispatcher.killed) {
|
||||
dispatcher = startDispatcher();
|
||||
}
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Python script with real-time progress streaming via stderr.
|
||||
* Falls back to system python3 if the venv is not available.
|
||||
*
|
||||
* Python scripts emit progress as JSON lines to stderr:
|
||||
* {"progress": 50, "stage": "Processing..."}
|
||||
*
|
||||
* Non-JSON stderr lines are collected as error output (backward compatible).
|
||||
* Send a request to the persistent Python dispatcher.
|
||||
* Returns null if the dispatcher is unavailable (caller should fall back).
|
||||
*/
|
||||
export function runPythonWithProgress(
|
||||
function dispatcherRun(
|
||||
scriptName: string,
|
||||
args: string[],
|
||||
options: { onProgress?: ProgressCallback; timeout?: number } = {},
|
||||
): Promise<{ stdout: string; stderr: string }> | null {
|
||||
const proc = getDispatcher();
|
||||
if (!proc || !proc.stdin || !dispatcherReady) return null;
|
||||
|
||||
const id = randomUUID();
|
||||
const timeout = options.timeout ?? 300000;
|
||||
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRequests.delete(id);
|
||||
rejectPromise(new Error("Python script timed out"));
|
||||
}, timeout);
|
||||
|
||||
const wrappedResolve = (result: { stdout: string; stderr: string }) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise(result);
|
||||
};
|
||||
|
||||
const wrappedReject = (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
rejectPromise(err);
|
||||
};
|
||||
|
||||
pendingRequests.set(id, {
|
||||
resolve: wrappedResolve,
|
||||
reject: wrappedReject,
|
||||
onProgress: options.onProgress,
|
||||
stderrLines: [],
|
||||
});
|
||||
|
||||
const request = JSON.stringify({ id, script: scriptName.replace(".py", ""), args });
|
||||
proc.stdin!.write(request + "\n");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the persistent dispatcher process.
|
||||
*/
|
||||
export function shutdownDispatcher(): void {
|
||||
if (dispatcher && !dispatcher.killed) {
|
||||
dispatcher.stdin?.end();
|
||||
dispatcher.kill("SIGTERM");
|
||||
dispatcher = null;
|
||||
dispatcherReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-request fallback (original implementation) ──────────────────
|
||||
|
||||
function runPythonPerRequest(
|
||||
scriptName: string,
|
||||
args: string[],
|
||||
options: {
|
||||
@@ -97,7 +282,7 @@ export function runPythonWithProgress(
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — collect as regular stderr
|
||||
// Not JSON - collect as regular stderr
|
||||
}
|
||||
stderrLines.push(trimmed);
|
||||
}
|
||||
@@ -141,3 +326,29 @@ export function runPythonWithProgress(
|
||||
trySpawn(getPythonPath(), false);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API (unchanged signature) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run a Python script with real-time progress streaming via stderr.
|
||||
*
|
||||
* Tries the persistent dispatcher first for warm-start performance.
|
||||
* Falls back to per-request spawning if the dispatcher is unavailable.
|
||||
*/
|
||||
export function runPythonWithProgress(
|
||||
scriptName: string,
|
||||
args: string[],
|
||||
options: {
|
||||
onProgress?: ProgressCallback;
|
||||
timeout?: number;
|
||||
} = {},
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
// Try persistent dispatcher first
|
||||
const dispatcherPromise = dispatcherRun(scriptName, args, options);
|
||||
if (dispatcherPromise) {
|
||||
return dispatcherPromise;
|
||||
}
|
||||
|
||||
// Fall back to per-request spawning
|
||||
return runPythonPerRequest(scriptName, args, options);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { removeBackground } from "./background-removal.js";
|
||||
export { shutdownDispatcher } from "./bridge.js";
|
||||
export { blurFaces } from "./face-detection.js";
|
||||
export { inpaint } from "./inpainting.js";
|
||||
export { extractText } from "./ocr.js";
|
||||
|
||||
Reference in New Issue
Block a user