From c428d551b87ffcd1a23cc7ede187a51b6946e710 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 2 Mar 2026 17:47:06 -0500 Subject: [PATCH] perf: Check socket is still connected before querying db (#11620) --- server/middlewares/requestContext.ts | 15 +++++++++++++++ server/routes/api/index.ts | 2 ++ server/storage/database.ts | 14 ++++++++++++++ server/storage/requestContext.ts | 11 +++++++++++ 4 files changed, 42 insertions(+) create mode 100644 server/middlewares/requestContext.ts create mode 100644 server/storage/requestContext.ts diff --git a/server/middlewares/requestContext.ts b/server/middlewares/requestContext.ts new file mode 100644 index 0000000000..904d53f3fd --- /dev/null +++ b/server/middlewares/requestContext.ts @@ -0,0 +1,15 @@ +import type { Next } from "koa"; +import type { AppContext } from "@server/types"; +import { requestContext } from "@server/storage/requestContext"; + +/** + * Middleware that wraps the request in an AsyncLocalStorage context, making the + * current request available to Sequelize hooks so that queries can be + * short-circuited when the socket has been destroyed (e.g. after a timeout). + * + * @returns The middleware function. + */ +export default function requestContextMiddleware() { + return (ctx: AppContext, next: Next) => + requestContext.run({ req: ctx.req }, next); +} diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index 4f0d819533..f9235858a0 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -8,6 +8,7 @@ import env from "@server/env"; import { NotFoundError } from "@server/errors"; import { apiContext } from "@server/middlewares/apiContext"; import coalesceBody from "@server/middlewares/coaleseBody"; +import requestContextMiddleware from "@server/middlewares/requestContext"; import requestTracer from "@server/middlewares/requestTracer"; import { verifyCSRFToken } from "@server/middlewares/csrf"; import type { AppState, AppContext } from "@server/types"; @@ -55,6 +56,7 @@ const api = new Koa(); const router = new Router(); // middlewares +api.use(requestContextMiddleware()); api.use( bodyParser({ multipart: true, diff --git a/server/storage/database.ts b/server/storage/database.ts index fd2fde89fb..41c5bc5d1d 100644 --- a/server/storage/database.ts +++ b/server/storage/database.ts @@ -6,9 +6,11 @@ import { Sequelize } from "sequelize-typescript"; import type { MigrationError } from "umzug"; import { Umzug, SequelizeStorage } from "umzug"; import env from "@server/env"; +import { ClientClosedRequestError } from "@server/errors"; import type Model from "@server/models/base/Model"; import Logger from "../logging/Logger"; import * as models from "../models"; +import { requestContext } from "./requestContext"; import { getConnectionName } from "./utils"; /** @@ -107,6 +109,18 @@ export function createDatabaseInstance( instance = monkeyPatchSequelizeErrorsForJest(instance); } + // Skip queries when the originating HTTP request socket has been destroyed + // (e.g. client disconnected or server timeout). This avoids wasting database + // resources on work whose response can never be delivered. + const assertConnectionOpen = () => { + const store = requestContext.getStore(); + if (store?.req.socket.destroyed) { + throw ClientClosedRequestError(); + } + }; + instance.addHook("beforeFind", assertConnectionOpen); + instance.addHook("beforeCount", assertConnectionOpen); + // Add hooks to warn about write operations on read-only connections if (isReadOnly) { const warnWriteOperation = (operation: string) => { diff --git a/server/storage/requestContext.ts b/server/storage/requestContext.ts new file mode 100644 index 0000000000..86e363d8ab --- /dev/null +++ b/server/storage/requestContext.ts @@ -0,0 +1,11 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { IncomingMessage } from "node:http"; + +/** + * Async local storage for the current HTTP request context. This allows + * downstream code (e.g. Sequelize hooks) to check whether the originating + * request is still alive without explicitly threading `ctx` through every call. + */ +export const requestContext = new AsyncLocalStorage<{ + req: IncomingMessage; +}>();