From 79201820a9ba910c9d13ba9d6b501c26dc43dc35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 19:52:46 +0000 Subject: [PATCH] Consolidate server postgres connections into one shared pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four separate postgres() clients were open simultaneously (app, timer, snapshots, socket), each defaulting to 10 connections, exhausting the database's max_connections limit. Replaced with a single shared lazy- initialized client in server/db.ts using a Proxy to defer the DATABASE_URL check until first use (preserving test compatibility). Also bumps the CS2 Champions Stage stochastic test from 200 → 1000 iterations to eliminate flakiness. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR --- .../__tests__/cs-major-simulator.test.ts | 4 +-- server/app.ts | 15 +--------- server/db.ts | 29 +++++++++++++++++++ server/snapshots.ts | 11 +------ server/socket.ts | 17 +---------- server/timer.ts | 11 +------ 6 files changed, 35 insertions(+), 52 deletions(-) create mode 100644 server/db.ts diff --git a/app/services/simulations/__tests__/cs-major-simulator.test.ts b/app/services/simulations/__tests__/cs-major-simulator.test.ts index 2006438..78861b6 100644 --- a/app/services/simulations/__tests__/cs-major-simulator.test.ts +++ b/app/services/simulations/__tests__/cs-major-simulator.test.ts @@ -220,11 +220,11 @@ describe("simulateChampionsStage", () => { const teams = makeTeams(8); // team-0 has highest Elo, team-7 has lowest let wins = 0; - for (let i = 0; i < 200; i++) { + for (let i = 0; i < 1000; i++) { const result = simulateChampionsStage(teams); if (result.placements.get("team-0") === 1) wins++; } // Should win significantly more than 12.5% of the time (1/8 random) - expect(wins / 200).toBeGreaterThan(0.2); + expect(wins / 1000).toBeGreaterThan(0.2); }); }); diff --git a/server/app.ts b/server/app.ts index c175623..48a9e00 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1,27 +1,14 @@ import { createRequestHandler } from "@react-router/express"; -import { drizzle } from "drizzle-orm/postgres-js"; import express from "express"; -import postgres from "postgres"; import type { ServerBuild } from "react-router"; import { RouterContextProvider } from "react-router"; import { DatabaseContext } from "~/database/context"; -import * as schema from "~/database/schema"; +import { db } from "./db"; import { expressValueContext } from "~/contexts/express"; export const app = express(); -if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required"); - -// In dev, ssrLoadModule re-evaluates this file on every request. Cache the -// client on globalThis so HMR re-loads reuse the same connection pool instead -// of leaking a new one each time. -declare global { - // TypeScript requires `var` in `declare global` blocks — `let`/`const` are not allowed here. - var __pgClient: ReturnType | undefined; // eslint-disable-line no-var -} -const client = (globalThis.__pgClient ??= postgres(process.env.DATABASE_URL)); -const db = drizzle(client, { schema }); app.use((_, __, next) => DatabaseContext.run(db, next)); app.use( diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..911f809 --- /dev/null +++ b/server/db.ts @@ -0,0 +1,29 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "~/database/schema"; + +// Cache the client on globalThis so HMR re-evaluations and multiple server +// modules share a single connection pool instead of each opening their own. +declare global { + // eslint-disable-next-line no-var + var __pgClient: ReturnType | undefined; + // eslint-disable-next-line no-var + var __pgDb: ReturnType> | undefined; +} + +function getDb() { + if (!globalThis.__pgDb) { + if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required"); + globalThis.__pgClient ??= postgres(process.env.DATABASE_URL); + globalThis.__pgDb = drizzle(globalThis.__pgClient, { schema }); + } + return globalThis.__pgDb; +} + +// Export a Proxy so callers can use `db.query.foo` etc. without calling getDb() themselves. +// The proxy defers the DATABASE_URL check until the first actual property access. +export const db = new Proxy({} as ReturnType>, { + get(_target, prop) { + return getDb()[prop as keyof ReturnType>]; + }, +}); diff --git a/server/snapshots.ts b/server/snapshots.ts index 360d9a9..848206a 100644 --- a/server/snapshots.ts +++ b/server/snapshots.ts @@ -1,17 +1,8 @@ -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; import * as schema from "~/database/schema"; import { eq, or } from "drizzle-orm"; import { createDailySnapshot } from "~/models/standings"; import { logger } from "./logger"; - -// Create a dedicated database connection for the snapshot system -const connectionString = process.env.DATABASE_URL; -if (!connectionString) { - throw new Error("DATABASE_URL is required for snapshot system"); -} -const client = postgres(connectionString); -const db = drizzle(client, { schema }); +import { db } from "./db"; let snapshotInterval: NodeJS.Timeout | null = null; const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // Check once per day (in milliseconds) diff --git a/server/socket.ts b/server/socket.ts index 29c31ad..8bdea68 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -1,23 +1,10 @@ import type { Socket } from "socket.io"; import { Server as SocketIOServer } from "socket.io"; import type { Server as HTTPServer } from "http"; -import { drizzle } from "drizzle-orm/postgres-js"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; import * as schema from "~/database/schema"; import { eq, and, asc } from "drizzle-orm"; import { logger } from "./logger"; - -// Lazy-initialized DB for socket-level validation queries (team ownership checks) -let _socketDb: PostgresJsDatabase | null = null; -function getSocketDb(): PostgresJsDatabase { - if (!_socketDb) { - const url = process.env.DATABASE_URL; - if (!url) throw new Error("DATABASE_URL is required"); - _socketDb = drizzle(postgres(url), { schema }); - } - return _socketDb; -} +import { db } from "./db"; // Socket event types interface ServerToClientEvents { @@ -142,7 +129,6 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { // If teamId provided, validate it belongs to this season before tracking if (teamId) { try { - const db = getSocketDb(); const team = await db.query.teams.findFirst({ where: and(eq(schema.teams.id, teamId), eq(schema.teams.seasonId, seasonId)), }); @@ -185,7 +171,6 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { // tokens or flaky mobile networks. This socket-based sync provides an // additional, more reliable path since the socket is already connected. try { - const db = getSocketDb(); const [seasonData, picks, timerRows, queueItems] = await Promise.all([ db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), diff --git a/server/timer.ts b/server/timer.ts index 753a407..7f19994 100644 --- a/server/timer.ts +++ b/server/timer.ts @@ -1,19 +1,10 @@ -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; import * as schema from "~/database/schema"; import { eq, and, asc, sql } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm"; import { getSocketIO } from "./socket"; import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils"; import { logger } from "./logger"; - -// Create a dedicated database connection for the timer -const connectionString = process.env.DATABASE_URL; -if (!connectionString) { - throw new Error("DATABASE_URL is required for timer system"); -} -const client = postgres(connectionString); -const db = drizzle(client, { schema }); +import { db } from "./db"; let timerInterval: NodeJS.Timeout | null = null; let timerTickRunning = false;