Skip to content
Merged
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
33 changes: 32 additions & 1 deletion src/routes/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,42 @@
* /api/reports/scheduled — CRUD for user-owned scheduled report configs
*/

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 { scheduledReportsRouter } from "./reports/scheduled";
import { idempotency } from "../middleware/idempotency";

let inFlightReportsRequests = 0;

/** Wait for report handlers to finish before the database is closed. */
export async function drainReportsRequests(timeoutMs = 10000): Promise<void> {
const start = Date.now();
while (inFlightReportsRequests > 0 && Date.now() - start <= timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}

/** Track all requests entering /api/reports, including auth failures. */
export function reportsInFlightMiddleware(
_req: Request,
res: Response,
next: NextFunction,
): void {
inFlightReportsRequests += 1;
let finished = false;
const cleanup = () => {
if (!finished) {
finished = true;
inFlightReportsRequests = Math.max(0, inFlightReportsRequests - 1);
}
};

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

export interface ReportsRouterOptions {
rateLimit?: {
capacity?: number;
Expand All @@ -27,6 +57,7 @@ export interface ReportsRouterOptions {
export function createReportsRouter(options: ReportsRouterOptions = {}): Router {
const router = Router();

router.use(reportsInFlightMiddleware);
router.use(requireAuth);
router.use(
createPerUserTokenBucketLimiter({
Expand Down
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { startPredictionsConfirmer } from "./workers/predictionsConfirmer";
import { drainSearchRequests } from "./routes/search";
import { drainExportsRequests } from "./routes/exports";
import { drainFingerprintRequests } from "./routes/fingerprint";
import { drainReportsRequests } from "./routes/reports";

const app = createApp();
let webhookWorker: WebhookWorker | null = null;
Expand Down Expand Up @@ -44,12 +45,17 @@ connectWithRetry()
process.exit(1);
}, 5000).unref();

// Stop accepting new connections while existing route handlers drain.
server.close();

// Ensure in-flight /api/search requests finish
await drainSearchRequests(4000);
// Ensure in-flight /api/exports requests finish
await drainExportsRequests(4000);
// Ensure in-flight /api/fingerprint requests finish
await drainFingerprintRequests(4000);
// Ensure in-flight /api/reports requests finish before closing Postgres
await drainReportsRequests(4000);

stopScheduler();
await closeDb();
Expand Down
26 changes: 26 additions & 0 deletions tests/reportsDrain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { EventEmitter } from "node:events";
import type { Request, Response } from "express";
import {
drainReportsRequests,
reportsInFlightMiddleware,
} from "../src/routes/reports";

describe("/api/reports graceful shutdown drain", () => {
it("waits for an in-flight response to finish", async () => {
const response = new EventEmitter() as unknown as Response;
const next = jest.fn();

reportsInFlightMiddleware({} as Request, response, next);
const draining = drainReportsRequests(500);

await new Promise((resolve) => setImmediate(resolve));
response.emit("finish");

await expect(draining).resolves.toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
});

it("returns immediately when no requests are in flight", async () => {
await expect(drainReportsRequests(50)).resolves.toBeUndefined();
});
});
Loading