mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-18 13:21:53 +02:00
A job that had just started, with in_progress=1 and no checkpoint yet, matched findInterruptedJobs immediately. Since #297 the middleware runs that check on every request, so a live job was "resumed" by recovery while the original process was still working on the same repositories. The request-level 15 second timeout then released the in-flight latch without cancelling recovery, and later requests logged misleading "already in progress" and "completed with some issues" lines. - Move the liveness rule into interrupted-job-detection.ts, as both a predicate and the SQL condition used by findInterruptedJobs. A job with no checkpoint is only interrupted once it is older than the 10 minute checkpoint window (or has no recorded start at all). - Stamp an initial checkpoint on in-progress jobs at creation. - Refresh the checkpoint every 2 minutes from processWithResilience so a single long item cannot make a live job look interrupted. The refresh only touches rows still in progress. - Keep the middleware recovery latch held until the recovery promise settles, not until the request stops waiting, and clear the timeout timer so it no longer leaves a dangling rejection. Tests cover the predicate, the SQL against an in-memory SQLite database, and the wiring into helpers, concurrency, and middleware. Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import { defineMiddleware } from 'astro:middleware';
|
|
import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from './lib/recovery';
|
|
import { startCleanupService, stopCleanupService } from './lib/cleanup-service';
|
|
import { startSchedulerService, stopSchedulerService } from './lib/scheduler-service';
|
|
import { startRepositoryCleanupService, stopRepositoryCleanupService } from './lib/repository-cleanup-service';
|
|
import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager';
|
|
import { setupSignalHandlers } from './lib/signal-handlers';
|
|
import { auth } from './lib/auth';
|
|
import { isHeaderAuthEnabled } from './lib/auth-header';
|
|
import { mintSessionFromHeaders } from './lib/auth-header-bridge';
|
|
import { initializeConfigFromEnv } from './lib/env-config-loader';
|
|
import { db, users } from './lib/db';
|
|
import { getBasePath } from './lib/base-path';
|
|
|
|
const ASTRO_INTERNAL_ASSET_PATH_PATTERN = /(["'])\/(_astro\/|_server-islands\/|_image\b)/g;
|
|
|
|
function prefixAstroInternalAssetPaths(html: string, basePath: string): string {
|
|
return html.replace(ASTRO_INTERNAL_ASSET_PATH_PATTERN, `$1${basePath}/$2`);
|
|
}
|
|
|
|
// Flag to track whether the *startup* recovery pass has run. This
|
|
// only gates the post-startup chain (cleanup service, scheduler,
|
|
// etc.) and the "startup script may not have run" log line — it does
|
|
// NOT gate subsequent recovery attempts, see below.
|
|
let recoveryInitialized = false;
|
|
// Throttle for runtime recovery retries (separate from the
|
|
// initializeRecovery() 5-minute throttle inside recovery.ts, which is
|
|
// keyed on `lastRecoveryAttempt`). This prevents one middleware
|
|
// invocation from triggering recovery while another is in flight.
|
|
// Once a recovery run has started, the latch is held until that run
|
|
// settles, even if the request that started it stops waiting.
|
|
let recoveryInFlight = false;
|
|
let cleanupServiceStarted = false;
|
|
let schedulerServiceStarted = false;
|
|
let repositoryCleanupServiceStarted = false;
|
|
let shutdownManagerInitialized = false;
|
|
let envConfigInitialized = false;
|
|
let envConfigCheckCount = 0; // Track attempts to avoid excessive checking
|
|
|
|
export const onRequest = defineMiddleware(async (context, next) => {
|
|
const basePath = getBasePath();
|
|
|
|
// Set-Cookie headers we mint during the header-auth bridge below.
|
|
// Forwarded onto the outbound response after `next()` so the browser
|
|
// persists the Better Auth session cookie. Until that happens the
|
|
// SPA's /api/auth/get-session call returns null and bounces to
|
|
// /login — see the bridge block for the full rationale.
|
|
let pendingSetCookies: string[] = [];
|
|
|
|
// First, try Better Auth session (cookie-based)
|
|
try {
|
|
const session = await auth.api.getSession({
|
|
headers: context.request.headers,
|
|
});
|
|
|
|
if (session) {
|
|
context.locals.user = session.user;
|
|
context.locals.session = session.session;
|
|
} else if (isHeaderAuthEnabled()) {
|
|
// No cookie session, but header auth is on. Call the
|
|
// header-auth plugin endpoint to mint a real Better Auth
|
|
// session from the trusted upstream headers, then forward the
|
|
// Set-Cookie onto the outbound response so the SPA's next
|
|
// /api/auth/get-session call carries the cookie. Without this
|
|
// bridge the React app sees null on mount and redirects to
|
|
// /login even though server-rendered code paths know the user.
|
|
const bridge = await mintSessionFromHeaders(context.request);
|
|
if (bridge) {
|
|
context.locals.user = bridge.user;
|
|
context.locals.session = bridge.session;
|
|
pendingSetCookies = bridge.setCookies;
|
|
} else {
|
|
context.locals.user = null;
|
|
context.locals.session = null;
|
|
}
|
|
} else {
|
|
context.locals.user = null;
|
|
context.locals.session = null;
|
|
}
|
|
} catch (error) {
|
|
// If there's an error getting the session, set to null
|
|
context.locals.user = null;
|
|
context.locals.session = null;
|
|
}
|
|
|
|
// Initialize shutdown manager and signal handlers first
|
|
if (!shutdownManagerInitialized) {
|
|
try {
|
|
console.log('🔧 Initializing shutdown manager and signal handlers...');
|
|
initializeShutdownManager();
|
|
setupSignalHandlers();
|
|
shutdownManagerInitialized = true;
|
|
console.log('✅ Shutdown manager and signal handlers initialized');
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize shutdown manager:', error);
|
|
// Continue anyway - this shouldn't block the application
|
|
}
|
|
}
|
|
|
|
// Initialize configuration from environment variables
|
|
// Optimized to minimize performance impact:
|
|
// - Once initialized, no checks are performed (envConfigInitialized = true)
|
|
// - Limits checks to first 100 requests to avoid DB queries on every request if no users exist
|
|
// - After user creation, env vars load on next request and flag is set permanently
|
|
if (!envConfigInitialized && envConfigCheckCount < 100) {
|
|
envConfigCheckCount++;
|
|
|
|
// Only check every 10th request after the first 10 to reduce DB load
|
|
const shouldCheck = envConfigCheckCount <= 10 || envConfigCheckCount % 10 === 0;
|
|
|
|
if (shouldCheck) {
|
|
try {
|
|
const hasUsers = await db.select().from(users).limit(1).then(u => u.length > 0);
|
|
|
|
if (hasUsers) {
|
|
// We have users now, try to initialize config
|
|
await initializeConfigFromEnv();
|
|
envConfigInitialized = true; // This ensures we never check again
|
|
console.log('✅ Environment configuration loaded after user creation');
|
|
}
|
|
} catch (error) {
|
|
console.error('⚠️ Failed to initialize configuration from environment:', error);
|
|
// Continue anyway - environment config is optional
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run recovery if jobs need it.
|
|
//
|
|
// The previous implementation used a once-per-process gate, so
|
|
// any mid-runtime interruption (a sync that started after boot,
|
|
// crashed mid-flight, and never got back to the resume path)
|
|
// would sit at `in_progress=true` forever: the periodic detector
|
|
// kept finding it, but the resumer never re-fired. This block
|
|
// now re-evaluates on every request, gated by `recoveryInFlight`
|
|
// (per-process) plus the 5-minute throttle inside
|
|
// `initializeRecovery()` (which prevents thrashing if a resume
|
|
// cycle keeps failing).
|
|
//
|
|
// A request only waits a bounded time for recovery, but the latch
|
|
// stays held until the recovery promise itself settles. Releasing
|
|
// it on the request timeout let later requests start overlapping
|
|
// attempts against the same jobs (issue #372).
|
|
if (!recoveryInFlight) {
|
|
recoveryInFlight = true;
|
|
let latchFollowsRecovery = false;
|
|
|
|
try {
|
|
// Check if recovery is actually needed before attempting
|
|
const needsRecovery = await hasJobsNeedingRecovery();
|
|
|
|
if (needsRecovery) {
|
|
if (!recoveryInitialized) {
|
|
console.log('⚠️ Middleware detected jobs needing recovery (startup script may not have run)');
|
|
} else {
|
|
console.log('⚠️ Middleware detected jobs needing recovery mid-run (sync interrupted after startup)');
|
|
}
|
|
console.log('Attempting recovery from middleware...');
|
|
|
|
const recoveryPromise = initializeRecovery({
|
|
skipIfRecentAttempt: true,
|
|
maxRetries: 2,
|
|
retryDelay: 3000,
|
|
});
|
|
latchFollowsRecovery = true;
|
|
recoveryPromise
|
|
.catch(() => false)
|
|
.finally(() => {
|
|
recoveryInFlight = false;
|
|
});
|
|
|
|
// Bound how long this request waits; the recovery keeps running.
|
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
const timeout = new Promise<boolean>((_, reject) => {
|
|
timeoutHandle = setTimeout(() => reject(new Error('Middleware recovery timeout')), 15000);
|
|
});
|
|
|
|
try {
|
|
const recoveryResult = await Promise.race([recoveryPromise, timeout]);
|
|
|
|
if (recoveryResult) {
|
|
console.log('✅ Middleware recovery completed successfully');
|
|
} else {
|
|
console.log('⚠️ Middleware recovery completed with some issues');
|
|
}
|
|
} finally {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
} else if (!recoveryInitialized) {
|
|
// Only log this on the first request; otherwise we'd spam
|
|
// it on every request.
|
|
console.log('✅ No recovery needed (startup script likely handled it)');
|
|
}
|
|
|
|
recoveryInitialized = true;
|
|
} catch (error) {
|
|
console.error('⚠️ Middleware recovery failed or timed out:', error);
|
|
console.log('Application will continue, but some jobs may remain interrupted');
|
|
|
|
// Log recovery status for debugging
|
|
const status = getRecoveryStatus();
|
|
console.log('Recovery status:', status);
|
|
|
|
recoveryInitialized = true;
|
|
} finally {
|
|
// Once recovery has started, the latch is released when it settles
|
|
// (see above), not when this request stops waiting.
|
|
if (!latchFollowsRecovery) {
|
|
recoveryInFlight = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start cleanup service only once after recovery is complete
|
|
if (recoveryInitialized && !cleanupServiceStarted) {
|
|
try {
|
|
console.log('Starting automatic database cleanup service...');
|
|
startCleanupService();
|
|
|
|
// Register cleanup service shutdown callback
|
|
registerShutdownCallback(async () => {
|
|
console.log('🛑 Shutting down cleanup service...');
|
|
stopCleanupService();
|
|
});
|
|
|
|
cleanupServiceStarted = true;
|
|
} catch (error) {
|
|
console.error('Failed to start cleanup service:', error);
|
|
// Don't fail the request if cleanup service fails to start
|
|
}
|
|
}
|
|
|
|
// Start scheduler service only once after recovery is complete
|
|
if (recoveryInitialized && !schedulerServiceStarted) {
|
|
try {
|
|
console.log('Starting automatic mirror scheduler service...');
|
|
// Start the scheduler service (now async)
|
|
startSchedulerService().catch(error => {
|
|
console.error('Error in scheduler service startup:', error);
|
|
});
|
|
|
|
// Register scheduler service shutdown callback
|
|
registerShutdownCallback(async () => {
|
|
console.log('🛑 Shutting down scheduler service...');
|
|
stopSchedulerService();
|
|
});
|
|
|
|
schedulerServiceStarted = true;
|
|
} catch (error) {
|
|
console.error('Failed to start scheduler service:', error);
|
|
// Don't fail the request if scheduler service fails to start
|
|
}
|
|
}
|
|
|
|
// Start repository cleanup service only once after recovery is complete
|
|
if (recoveryInitialized && !repositoryCleanupServiceStarted) {
|
|
try {
|
|
console.log('Starting repository cleanup service...');
|
|
startRepositoryCleanupService();
|
|
|
|
// Register repository cleanup service shutdown callback
|
|
registerShutdownCallback(async () => {
|
|
console.log('🛑 Shutting down repository cleanup service...');
|
|
stopRepositoryCleanupService();
|
|
});
|
|
|
|
repositoryCleanupServiceStarted = true;
|
|
} catch (error) {
|
|
console.error('Failed to start repository cleanup service:', error);
|
|
// Don't fail the request if repository cleanup service fails to start
|
|
}
|
|
}
|
|
|
|
// Continue with the request
|
|
const response = await next();
|
|
|
|
// Forward any Set-Cookie headers minted by the header-auth bridge
|
|
// onto the outbound response. Done before the early returns below so
|
|
// every return path (basePath rewrite, non-HTML responses, etc.)
|
|
// carries the cookie. The body-rewrite branch further down clones
|
|
// `response.headers`, so anything appended here survives the clone.
|
|
if (pendingSetCookies.length > 0) {
|
|
for (const cookie of pendingSetCookies) {
|
|
response.headers.append("set-cookie", cookie);
|
|
}
|
|
}
|
|
|
|
if (basePath === "/") {
|
|
return response;
|
|
}
|
|
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
if (!contentType.includes("text/html")) {
|
|
return response;
|
|
}
|
|
|
|
const body = await response.text();
|
|
const rewrittenBody = prefixAstroInternalAssetPaths(body, basePath);
|
|
if (rewrittenBody === body) {
|
|
return response;
|
|
}
|
|
|
|
const headers = new Headers(response.headers);
|
|
headers.delete("content-length");
|
|
|
|
return new Response(rewrittenBody, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
});
|