From 42798b63bb9ef38cc8352f34e165f4a512b3f232 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 16 May 2026 15:16:21 +0000 Subject: [PATCH] fix: stabilize code queue overview proxy --- src/components/backend-core/src/index.ts | 3 + .../backend-core/src/microservice-proxy.ts | 85 ++++++++++++++++--- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/components/backend-core/src/index.ts b/src/components/backend-core/src/index.ts index b1f469e5..f7208f82 100644 --- a/src/components/backend-core/src/index.ts +++ b/src/components/backend-core/src/index.ts @@ -18,6 +18,7 @@ import type { WsData, ProviderSocket } from "./types"; const runtimeConfig = readConfig(); const { logger: loggerFn, recentLogs } = createLogger("backend-core", runtimeConfig.logFile); +const serverIdleTimeoutSeconds = 120; ctx.config = runtimeConfig; ctx.logger = loggerFn; @@ -119,6 +120,7 @@ async function providerRoute(req: Request, server: Server): Promise({ port: config().port, + idleTimeout: serverIdleTimeoutSeconds, fetch(req, server) { return route(req, server); }, @@ -143,6 +145,7 @@ const apiServer = Bun.serve({ const providerServer = Bun.serve({ port: config().providerPort, + idleTimeout: serverIdleTimeoutSeconds, async fetch(req, server) { const started = performance.now(); const url = new URL(req.url); diff --git a/src/components/backend-core/src/microservice-proxy.ts b/src/components/backend-core/src/microservice-proxy.ts index af6518be..e2e2973f 100644 --- a/src/components/backend-core/src/microservice-proxy.ts +++ b/src/components/backend-core/src/microservice-proxy.ts @@ -11,6 +11,7 @@ import { getNodes, getNodeDockerStatuses } from "./db"; const microserviceProxyMaxBodyTextLength = 8 * 1024 * 1024; const microserviceAvailabilityTtlMs = 30_000; +const codeQueueOverviewPathFallbackStaleMs = 30_000; const microserviceForwardRequestHeaders = [ "accept", "content-type", @@ -416,7 +417,19 @@ function microserviceCacheKey(service: MicroserviceConfig, method: string, targe return JSON.stringify([service.id, method, targetPath, proxyOptions.query, proxyOptions.jsonArrayLimits]); } -function responseFromMicroserviceCache(entry: MicroserviceProxyCacheEntry, state: "hit" | "stale"): Response { +function microservicePathFallbackCacheKey(service: MicroserviceConfig, method: string, targetPath: string): string { + return JSON.stringify([service.id, method, targetPath, "__path_fallback__"]); +} + +function canUseMicroservicePathFallback(service: MicroserviceConfig, method: string, targetPath: string): boolean { + return service.id === "code-queue" && targetPath === "/api/tasks/overview" && (method === "GET" || method === "HEAD"); +} + +function cloneMicroserviceCacheEntry(entry: MicroserviceProxyCacheEntry): MicroserviceProxyCacheEntry { + return { ...entry }; +} + +function responseFromMicroserviceCache(entry: MicroserviceProxyCacheEntry, state: "hit" | "stale" | "path-stale"): Response { return new Response(entry.bodyText, { status: entry.status, headers: { @@ -448,6 +461,18 @@ function readStaleMicroserviceCache(key: string): Response | null { return responseFromMicroserviceCache(entry, "stale"); } +function readMicroservicePathFallback(service: MicroserviceConfig, method: string, targetPath: string): Response | null { + if (!canUseMicroservicePathFallback(service, method, targetPath)) return null; + const key = microservicePathFallbackCacheKey(service, method, targetPath); + const entry = ctx.microserviceProxyCache.get(key); + if (entry === undefined) return null; + if (entry.staleExpiresAt <= Date.now()) { + ctx.microserviceProxyCache.delete(key); + return null; + } + return responseFromMicroserviceCache(entry, "path-stale"); +} + async function cacheableResponseSnapshot(response: Response): Promise { if (response.status < 200 || response.status >= 300) return null; if (response.headers.get("x-unidesk-response-truncated") === "true") return null; @@ -464,9 +489,10 @@ async function cacheableResponseSnapshot(response: Response): Promise 300) { const now = Date.now(); for (const [cacheKey, cacheEntry] of ctx.microserviceProxyCache) { @@ -475,6 +501,20 @@ function rememberMicroserviceCache(key: string, ttlMs: number, entry: Microservi } } +function rememberMicroservicePathFallback( + service: MicroserviceConfig, + method: string, + targetPath: string, + entry: MicroserviceProxyCacheEntry | null, +): void { + if (entry === null || !canUseMicroservicePathFallback(service, method, targetPath)) return; + const now = Date.now(); + const stored = cloneMicroserviceCacheEntry(entry); + stored.expiresAt = now; + stored.staleExpiresAt = now + codeQueueOverviewPathFallbackStaleMs; + ctx.microserviceProxyCache.set(microservicePathFallbackCacheKey(service, method, targetPath), stored); +} + function invalidateMicroserviceCache(serviceId: string): void { const prefix = `["${serviceId}",`; for (const key of ctx.microserviceProxyCache.keys()) { @@ -607,18 +647,23 @@ function refreshMicroserviceCacheInBackground( cacheKey: string, ttlMs: number, fetchResponse: () => Promise, + fallbackContext?: { service: MicroserviceConfig; method: string; targetPath: string }, + refreshKey = cacheKey, ): void { - if (ctx.microserviceProxyRefreshes.has(cacheKey)) return; + if (ctx.microserviceProxyRefreshes.has(refreshKey)) return; const refresh = fetchResponse() .then((response) => cacheableResponseSnapshot(response)) - .then((entry) => rememberMicroserviceCache(cacheKey, ttlMs, entry)) + .then((entry) => { + rememberMicroserviceCache(cacheKey, ttlMs, entry); + if (fallbackContext !== undefined) rememberMicroservicePathFallback(fallbackContext.service, fallbackContext.method, fallbackContext.targetPath, entry); + }) .catch((error) => { logger("warn", "microservice_cache_revalidate_failed", { cacheKey, error: errorToJson(error) }); }) .finally(() => { - ctx.microserviceProxyRefreshes.delete(cacheKey); + ctx.microserviceProxyRefreshes.delete(refreshKey); }); - ctx.microserviceProxyRefreshes.set(cacheKey, refresh); + ctx.microserviceProxyRefreshes.set(refreshKey, refresh); } // --------------------------------------------------------------------------- @@ -785,11 +830,31 @@ export async function microserviceRoute(req: Request, url: URL): Promise fetchMicroserviceUpstreamResponse(service, method, targetPath, proxyOptions, requestHeaders, bodyText)); + refreshMicroserviceCacheInBackground( + cacheKey, + cacheTtlMs, + () => fetchMicroserviceUpstreamResponse(service, method, targetPath, proxyOptions, requestHeaders, bodyText), + { service, method, targetPath }, + ); return stale; } + const fallback = readMicroservicePathFallback(service, method, targetPath); + if (fallback !== null) { + refreshMicroserviceCacheInBackground( + cacheKey, + cacheTtlMs, + () => fetchMicroserviceUpstreamResponse(service, method, targetPath, proxyOptions, requestHeaders, bodyText), + { service, method, targetPath }, + microservicePathFallbackCacheKey(service, method, targetPath), + ); + return fallback; + } } const response = await fetchMicroserviceUpstreamResponse(service, method, targetPath, proxyOptions, requestHeaders, bodyText, req.signal); - if ((method === "GET" || method === "HEAD") && cacheTtlMs > 0) rememberMicroserviceCache(cacheKey, cacheTtlMs, await cacheableResponseSnapshot(response)); + if ((method === "GET" || method === "HEAD") && cacheTtlMs > 0) { + const snapshot = await cacheableResponseSnapshot(response); + rememberMicroserviceCache(cacheKey, cacheTtlMs, snapshot); + rememberMicroservicePathFallback(service, method, targetPath, snapshot); + } return response; }