Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion src/routes/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,56 @@
* /api/exports — Prediction history export (default alias)
*/

import { Router } from "express";
import { Router, type Request, type Response, type NextFunction } from "express";
import { requireAuth } from "../middleware/requireAuth";
import { createPerUserTokenBucketLimiter } from "../middleware/rateLimit";
import { exportsPredictionsRouter } from "./exports/predictions";
import { logger } from "../config/logger";

// In-flight request tracking for graceful shutdown drain
let inFlightExportsRequests = 0;

/**
* Wait for all in-flight /api/exports requests to finish.
* @param timeoutMs Maximum time to wait before forcing resolution
*/
export async function drainExportsRequests(timeoutMs = 10000): Promise<void> {
const start = Date.now();
if (inFlightExportsRequests === 0) {
logger.info("No in-flight /api/exports requests to drain");
return;
}

logger.info({ inFlight: inFlightExportsRequests }, "Draining in-flight /api/exports requests...");

while (inFlightExportsRequests > 0) {
if (Date.now() - start > timeoutMs) {
logger.warn({ inFlight: inFlightExportsRequests }, "Timeout waiting for /api/exports requests to drain");
break;
}
// Poll every 50ms
await new Promise((resolve) => setTimeout(resolve, 50));
}

if (inFlightExportsRequests === 0) {
logger.info("Successfully drained all /api/exports requests");
}
}

function exportsInFlightMiddleware(_req: Request, res: Response, next: NextFunction): void {
inFlightExportsRequests++;
let finished = false;
const cleanup = () => {
if (!finished) {
finished = true;
inFlightExportsRequests = Math.max(0, inFlightExportsRequests - 1);
}
};

res.once("finish", cleanup);
res.once("close", cleanup);
next();
}

export interface ExportsRouterOptions {
rateLimit?: {
Expand All @@ -27,6 +73,7 @@ export interface ExportsRouterOptions {
export function createExportsRouter(options: ExportsRouterOptions = {}): Router {
const router = Router();

router.use(exportsInFlightMiddleware);
router.use(requireAuth);
router.use(
createPerUserTokenBucketLimiter({
Expand Down
3 changes: 3 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
import { reconciliationWorker } from "./workers/reconciliationWorker";
import { startSlowQueryAlerter } from "./workers/slowQueryAlerter";
import { drainSearchRequests } from "./routes/search";
import { drainExportsRequests } from "./routes/exports";

const app = createApp();
let webhookWorker: WebhookWorker | null = null;
let probeHandle: ReturnType<typeof setInterval> | null = null;

Check failure on line 17 in src/server.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

'probeHandle' is assigned a value but never used

connectWithRetry()
.then(() => {
Expand Down Expand Up @@ -41,6 +42,8 @@

// Ensure in-flight /api/search requests finish
await drainSearchRequests(4000);
// Ensure in-flight /api/exports requests finish
await drainExportsRequests(4000);

stopScheduler();
await closeDb();
Expand Down
Loading