From 6f2d6f0379202e87a65f33209cf74446419492f0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 17:30:06 +0000 Subject: [PATCH] =?UTF-8?q?perf:=20=ED=94=84=EB=A1=9C=ED=95=84=20DB=20?= =?UTF-8?q?=ED=92=80=EA=B3=BC=20advisory=20lock=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 역할별 PostgreSQL pool 상한과 동일 process 공유 pool을 적용하고 health 지표를 노출한다.\n\n게임 기능 advisory lock을 schema namespace로 통일하고 실제 PostgreSQL 통합 하네스를 보강한다. --- .env.example | 11 ++ app/game-api/src/daemon/databaseTransport.ts | 10 +- app/game-api/src/server.ts | 1 + .../test/npcPossession.integration.test.ts | 12 +- app/game-engine/src/auction/opener.ts | 8 +- app/game-engine/src/turn/databaseHooks.ts | 8 +- .../src/turn/joinCreateGeneralService.ts | 4 +- .../src/turn/npcPossessionService.ts | 13 +- app/game-engine/src/turn/selectPoolService.ts | 6 +- .../src/turn/unificationPersistence.ts | 8 +- ...fileSchemaAdvisoryLock.integration.test.ts | 70 ++++++++++ .../src/orchestrator/gatewayOrchestrator.ts | 14 ++ app/gateway-api/src/server.ts | 1 + app/gateway-api/test/orchestratorPlan.test.ts | 23 +++- app/release-controller/README.md | 4 + app/release-controller/src/config.ts | 3 + app/release-controller/src/index.ts | 5 +- .../src/releaseController.ts | 21 ++- app/release-controller/src/selfUpgrade.ts | 1 + .../test/releaseController.test.ts | 21 +++ docs/architecture/runtime.md | 32 ++++- packages/infra/package.json | 2 +- packages/infra/src/gameSchemaAdvisoryLock.ts | 32 +++++ packages/infra/src/index.ts | 1 + packages/infra/src/postgres.ts | 121 ++++++++++++++++-- .../infra/src/readModelCoverageActivation.ts | 16 ++- packages/infra/test/postgres.test.ts | 29 +++++ pnpm-lock.yaml | 4 +- tools/conditional-integration-registry.tsv | 2 + tools/run-conditional-integration.sh | 10 +- 30 files changed, 423 insertions(+), 70 deletions(-) create mode 100644 app/game-engine/test/profileSchemaAdvisoryLock.integration.test.ts create mode 100644 packages/infra/src/gameSchemaAdvisoryLock.ts create mode 100644 packages/infra/test/postgres.test.ts diff --git a/.env.example b/.env.example index de61f188..67f36dd2 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,17 @@ POSTGRES_PASSWORD=change-me # POSTGRES_SCHEMA=public # DATABASE_URL=postgresql://sammo:change-me@127.0.0.1:15432/sammo?schema=public # GATEWAY_DATABASE_URL=postgresql://sammo:change-me@127.0.0.1:15432/sammo?schema=public +# Direct CLI/process fallback. Managed PM2 roles use the role-specific values below first. +# POSTGRES_POOL_MAX=10 +GAME_API_POSTGRES_POOL_MAX=4 +TURN_DAEMON_POSTGRES_POOL_MAX=2 +AUCTION_WORKER_POSTGRES_POOL_MAX=1 +BATTLE_WORKER_POSTGRES_POOL_MAX=1 +TOURNAMENT_WORKER_POSTGRES_POOL_MAX=1 +GATEWAY_API_POSTGRES_POOL_MAX=4 +GATEWAY_ORCHESTRATOR_POSTGRES_POOL_MAX=2 +RELEASE_CONTROLLER_POSTGRES_POOL_MAX=2 +PROFILE_SEED_POSTGRES_POOL_MAX=1 # Redis REDIS_HOST=127.0.0.1 diff --git a/app/game-api/src/daemon/databaseTransport.ts b/app/game-api/src/daemon/databaseTransport.ts index 7b961cda..c075ceb5 100644 --- a/app/game-api/src/daemon/databaseTransport.ts +++ b/app/game-api/src/daemon/databaseTransport.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra'; +import { acquireGameSchemaAdvisoryXactLock, type DatabaseClient, type GamePrisma } from '@sammo-ts/infra'; import type { TurnDaemonTransport } from './transport.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js'; @@ -91,12 +91,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { try { if (command.type === 'npcPossessGeneral' && this.db.$transaction) { const rejectionReason = await this.db.$transaction(async (transaction) => { - await transaction.$executeRaw( - GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))` - ); - await transaction.$executeRaw( - GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${command.userId}`}, 1))` - ); + await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global'); + await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`); const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000); const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now; const token = await transaction.npcSelectionToken.findFirst({ diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 6854c5a1..c0e51c34 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -379,6 +379,7 @@ export const createGameApiServer = async () => { app.get('/healthz', async () => ({ ok: true, profile: config.profileName, + postgresPool: postgres.getPoolStats(), accountIconReconciliation: accountIconResetReconciler.getHealth(), })); diff --git a/app/game-api/test/npcPossession.integration.test.ts b/app/game-api/test/npcPossession.integration.test.ts index e27fbcbe..3a9f9f6c 100644 --- a/app/game-api/test/npcPossession.integration.test.ts +++ b/app/game-api/test/npcPossession.integration.test.ts @@ -2,7 +2,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine'; -import { createGamePostgresConnector, GamePrisma, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra'; +import { + acquireGameSchemaAdvisoryXactLock, + createGamePostgresConnector, + type GamePrisma, + type GamePrismaClient, + type RedisConnector, +} from '@sammo-ts/infra'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; @@ -446,9 +452,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae }); const blocker = db.$transaction( async (transaction) => { - await transaction.$executeRaw( - GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))` - ); + await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global'); markLockReady(); await lockRelease; }, diff --git a/app/game-engine/src/auction/opener.ts b/app/game-engine/src/auction/opener.ts index c1406386..7c47bfd7 100644 --- a/app/game-engine/src/auction/opener.ts +++ b/app/game-engine/src/auction/opener.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { asRecord, JosaUtil } from '@sammo-ts/common'; -import { GamePrisma } from '@sammo-ts/infra'; +import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra'; import { ActionLogger, ItemLoader, @@ -71,7 +71,7 @@ const openResourceAuction = async ( return fail('즉시거래가는 시작판매가의 110% 이상이어야 합니다.'); } - await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(${command.generalId}, 41001)`); + await acquireGameSchemaAdvisoryXactLock(db, `auction:resource:host:${command.generalId}`); const previous = await db.auction.findFirst({ where: { hostGeneralId: command.generalId, @@ -162,8 +162,8 @@ const openUniqueAuction = async ( } } - await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtext(${`auction:unique:item:${itemKey}`}))`); - await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(${command.generalId}, 41002)`); + await acquireGameSchemaAdvisoryXactLock(db, `auction:unique:item:${itemKey}`); + await acquireGameSchemaAdvisoryXactLock(db, `auction:unique:host:${command.generalId}`); const [sameItemAuction, previousHostAuction] = await Promise.all([ db.auction.findFirst({ where: { diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index d5cdd3d5..c43874cb 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,4 +1,5 @@ import { + acquireGameSchemaAdvisoryXactLock, createGamePostgresConnector, GamePrisma, writeReadModelChangeJournal, @@ -1140,12 +1141,7 @@ export const createDatabaseTurnHooks = async ( if (pendingNeutralAuctions.length > 0) { const latestRegistrationKey = pendingNeutralAuctions[pendingNeutralAuctions.length - 1]!.registrationKey; - await prisma.$executeRaw` - SELECT pg_advisory_xact_lock( - hashtext(${'neutral-auction-registration'}), - ${state.id} - ) - `; + await acquireGameSchemaAdvisoryXactLock(prisma, `neutral-auction-registration:world-state:${state.id}`); const persistedRows = await prisma.$queryRaw>` SELECT meta FROM world_state diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index c5b52545..107fb382 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -1,5 +1,5 @@ import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; -import { GamePrisma } from '@sammo-ts/infra'; +import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra'; import { ActionLogger, buildAuctionAlias, @@ -165,7 +165,7 @@ export const buildJoinCreateGeneralSeed = ( ): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, acceptedTick); const lockJoinMutation = async (db: DatabaseClient, userId: string): Promise => { - await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`join-create:${userId}`}, 0))`); + await acquireGameSchemaAdvisoryXactLock(db, `join-create:user:${userId}`); await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`); }; diff --git a/app/game-engine/src/turn/npcPossessionService.ts b/app/game-engine/src/turn/npcPossessionService.ts index edde3c9a..96fc7ce0 100644 --- a/app/game-engine/src/turn/npcPossessionService.ts +++ b/app/game-engine/src/turn/npcPossessionService.ts @@ -1,7 +1,12 @@ import { randomInt } from 'node:crypto'; import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; -import { GamePrisma, type DatabaseClient, type GamePrisma as GamePrismaTypes } from '@sammo-ts/infra'; +import { + acquireGameSchemaAdvisoryXactLock, + GamePrisma, + type DatabaseClient, + type GamePrisma as GamePrismaTypes, +} from '@sammo-ts/infra'; import { ActionLogger, DomesticTraitLoader, @@ -150,10 +155,8 @@ const requireNpcPossessionWorld = (worldState: WorldStateRow): void => { const lockNpcPossession = async (db: DatabaseClient, userId: string): Promise => { // Ref의 서로 다른 owner token 중복과 동일 owner 다중 빙의 race는 데이터 손상 // 가능성이 있어, 후보 예약과 최종 점유 모두 같은 lock 순서로 직렬화한다. - await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))`); - await db.$executeRaw( - GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${userId}`}, 1))` - ); + await acquireGameSchemaAdvisoryXactLock(db, 'npc-possession:global'); + await acquireGameSchemaAdvisoryXactLock(db, `npc-possession:user:${userId}`); }; const parsePickResult = (value: unknown): Record => { diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index cb18675b..4e426bce 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; -import { GamePrisma } from '@sammo-ts/infra'; +import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra'; import { EventDomesticTraitLoader, isEventDomesticTraitKey, @@ -266,9 +266,7 @@ const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => { }; const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise => { - await db.$executeRaw( - GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`select_pool:${userId}`}, 903))` - ); + await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`); }; const requireSelectionToken = async ( diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index ef057a4d..98c9865a 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -1,4 +1,5 @@ import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; +import { acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra'; import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic'; @@ -68,12 +69,7 @@ const claimGeneration = async ( transaction: GamePrisma.TransactionClient, input: UnificationFinalizationInput ): Promise<'CLAIMED' | 'ALREADY_APPLIED'> => { - await transaction.$executeRaw` - SELECT pg_advisory_xact_lock( - hashtext(${'unification-finalization'}), - hashtext(${input.generationKey}) - ) - `; + await acquireGameSchemaAdvisoryXactLock(transaction, `unification-finalization:${input.generationKey}`); const existing = await transaction.unificationFinalization.findUnique({ where: { generationKey: input.generationKey }, }); diff --git a/app/game-engine/test/profileSchemaAdvisoryLock.integration.test.ts b/app/game-engine/test/profileSchemaAdvisoryLock.integration.test.ts new file mode 100644 index 00000000..40c174d7 --- /dev/null +++ b/app/game-engine/test/profileSchemaAdvisoryLock.integration.test.ts @@ -0,0 +1,70 @@ +import { + acquireGameSchemaAdvisoryXactLock, + createGamePostgresConnector, + GamePrisma, + tryGameSchemaAdvisoryXactLock, +} from '@sammo-ts/infra'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const primaryDatabaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const secondaryDatabaseUrl = process.env.PROFILE_LOCK_SECONDARY_DATABASE_URL; +const integration = describe.skipIf(!primaryDatabaseUrl || !secondaryDatabaseUrl); + +integration('profile schema advisory lock and shared pool PostgreSQL boundary', () => { + const primaryOne = createGamePostgresConnector({ url: primaryDatabaseUrl!, maxConnections: 2 }); + const primaryTwo = createGamePostgresConnector({ url: primaryDatabaseUrl!, maxConnections: 2 }); + const secondary = createGamePostgresConnector({ url: secondaryDatabaseUrl!, maxConnections: 1 }); + + beforeAll(async () => { + await Promise.all([primaryOne.connect(), primaryTwo.connect(), secondary.connect()]); + }); + + afterAll(async () => { + await Promise.allSettled([primaryOne.disconnect(), primaryTwo.disconnect(), secondary.disconnect()]); + }); + + it('serializes the same logical key within a schema without blocking a sibling profile schema', async () => { + const logicalKey = 'integration:same-logical-resource'; + let releaseHolder = (): void => undefined; + const holderRelease = new Promise((resolve) => { + releaseHolder = resolve; + }); + let markAcquired = (): void => undefined; + const holderAcquired = new Promise((resolve) => { + markAcquired = resolve; + }); + const holder = primaryOne.prisma.$transaction(async (transaction) => { + await acquireGameSchemaAdvisoryXactLock(transaction, logicalKey); + markAcquired(); + await holderRelease; + }); + await holderAcquired; + + try { + await expect( + primaryTwo.prisma.$transaction((transaction) => tryGameSchemaAdvisoryXactLock(transaction, logicalKey)) + ).resolves.toBe(false); + await expect( + secondary.prisma.$transaction((transaction) => tryGameSchemaAdvisoryXactLock(transaction, logicalKey)) + ).resolves.toBe(true); + + const firstStats = primaryOne.getPoolStats(); + const secondStats = primaryTwo.getPoolStats(); + expect(firstStats).toEqual(secondStats); + expect(firstStats).toMatchObject({ max: 2, total: 2, active: 1, idle: 1, waiting: 0 }); + } finally { + releaseHolder(); + await holder; + } + + await expect( + primaryTwo.prisma.$transaction((transaction) => tryGameSchemaAdvisoryXactLock(transaction, logicalKey)) + ).resolves.toBe(true); + }); + + it('keeps the shared pool alive until its last connector disconnects', async () => { + await primaryOne.disconnect(); + await expect(primaryTwo.prisma.$queryRaw(GamePrisma.sql`SELECT current_schema()`)).resolves.toHaveLength(1); + expect(primaryTwo.getPoolStats()).toMatchObject({ max: 2 }); + }); +}); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 5a87f309..fef7a1d1 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -10,6 +10,7 @@ import { createGamePostgresConnector, createRedisConnector, resolvePostgresConfigFromEnv, + resolvePostgresPoolMax, resolveRedisConfigFromEnv, } from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; @@ -104,6 +105,9 @@ export interface GatewayOrchestratorHandle { const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu; +const managedPostgresPoolMax = (env: Record, roleVariable: string, fallback: number): string => + String(resolvePostgresPoolMax(env[roleVariable] ?? env.POSTGRES_POOL_MAX, fallback)); + export const planProfileReconcile = ( status: GatewayProfileStatus, runtime: ProfileRuntimeState @@ -415,6 +419,7 @@ export const buildProcessDefinitions = ( const turnDaemonNodeOptions = baseEnv.TURN_DAEMON_NODE_OPTIONS?.trim(); const apiEnv = { ...baseEnv, + POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'GAME_API_POSTGRES_POOL_MAX', 4), GAME_API_ROLE: 'server', PROFILE: profile.profile, SCENARIO: profile.currentScenario ?? 'default', @@ -430,6 +435,7 @@ export const buildProcessDefinitions = ( const daemonEnv = { ...baseEnv, ...(turnDaemonNodeOptions ? { NODE_OPTIONS: turnDaemonNodeOptions } : {}), + POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'TURN_DAEMON_POSTGRES_POOL_MAX', 2), GAME_ENGINE_ROLE: 'turn-daemon', TURN_PROFILE: profile.profile, PROFILE: profile.profile, @@ -465,6 +471,7 @@ export const buildProcessDefinitions = ( cwd: apiCwd, env: { ...apiEnv, + POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'AUCTION_WORKER_POSTGRES_POOL_MAX', 1), GAME_API_ROLE: 'auction-worker', }, }, @@ -474,6 +481,7 @@ export const buildProcessDefinitions = ( cwd: apiCwd, env: { ...apiEnv, + POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'BATTLE_WORKER_POSTGRES_POOL_MAX', 1), GAME_API_ROLE: 'battle-sim-worker', }, }, @@ -483,6 +491,7 @@ export const buildProcessDefinitions = ( cwd: apiCwd, env: { ...apiEnv, + POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'TOURNAMENT_WORKER_POSTGRES_POOL_MAX', 1), GAME_API_ROLE: 'tournament-worker', }, }, @@ -1857,6 +1866,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { env: { ...(this.processConfig.baseEnv ?? {}), DATABASE_URL: options.databaseUrl, + POSTGRES_POOL_MAX: managedPostgresPoolMax( + this.processConfig.baseEnv ?? {}, + 'PROFILE_SEED_POSTGRES_POOL_MAX', + 1 + ), GATEWAY_ROLE: 'profile-seed', PROFILE_SEED_REQUEST_FILE: requestFile, }, diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 55df425d..5203b8de 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -140,6 +140,7 @@ export const createGatewayApiServer = async () => { app.get('/healthz', async () => ({ ok: true, + postgresPool: postgres.getPoolStats(), })); app.addHook('onClose', async () => { diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 31a934d8..8d31e88e 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -139,6 +139,7 @@ describe('buildProcessDefinitions', () => { expect(definitions.api.script).toBe(path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js')); expect(definitions.api.env).toMatchObject({ GAME_PROFILE_NAME: 'che:2', + POSTGRES_POOL_MAX: '4', GAME_TRPC_PATH: '/che/api/trpc', GAME_API_EVENTS_PATH: '/che/api/events', GATEWAY_INTERNAL_API_URL: 'http://127.0.0.1:13000', @@ -146,20 +147,21 @@ describe('buildProcessDefinitions', () => { }); expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine')); expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js')); + expect(definitions.daemon.env.POSTGRES_POOL_MAX).toBe('2'); expect(definitions.auction).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-api'), script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), - env: { GAME_API_ROLE: 'auction-worker' }, + env: { GAME_API_ROLE: 'auction-worker', POSTGRES_POOL_MAX: '1' }, }); expect(definitions.battleSim).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-api'), script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), - env: { GAME_API_ROLE: 'battle-sim-worker' }, + env: { GAME_API_ROLE: 'battle-sim-worker', POSTGRES_POOL_MAX: '1' }, }); expect(definitions.tournament).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-api'), script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), - env: { GAME_API_ROLE: 'tournament-worker' }, + env: { GAME_API_ROLE: 'tournament-worker', POSTGRES_POOL_MAX: '1' }, }); }); @@ -250,6 +252,21 @@ describe('buildProcessDefinitions', () => { expect(definitions.battleSim.env.NODE_OPTIONS).toBe('--max-old-space-size=1536'); expect(definitions.tournament.env.NODE_OPTIONS).toBe('--max-old-space-size=1536'); }); + + it('accepts role-specific pool budget overrides without changing sibling roles', () => { + const definitions = buildProcessDefinitions(buildProfile(), { + ...processConfig, + baseEnv: { + GAME_API_POSTGRES_POOL_MAX: '6', + AUCTION_WORKER_POSTGRES_POOL_MAX: '2', + }, + }); + + expect(definitions.api.env.POSTGRES_POOL_MAX).toBe('6'); + expect(definitions.daemon.env.POSTGRES_POOL_MAX).toBe('2'); + expect(definitions.auction.env.POSTGRES_POOL_MAX).toBe('2'); + expect(definitions.tournament.env.POSTGRES_POOL_MAX).toBe('1'); + }); }); describe('sanitizeManagedProcessEnv', () => { diff --git a/app/release-controller/README.md b/app/release-controller/README.md index ec8bf9fd..591d3f62 100644 --- a/app/release-controller/README.md +++ b/app/release-controller/README.md @@ -29,6 +29,10 @@ Gateway process 환경에 전달하지 않습니다. 이 값이 frontend 정의 frontend build 계약입니다. - `RELEASE_CONTROLLER_POLL_MS`, `RELEASE_CONTROLLER_READINESS_TIMEOUT_MS`: queue poll과 준비 제한 시간입니다. +- `RELEASE_CONTROLLER_POSTGRES_POOL_MAX`: controller 자체 Gateway DB pool 상한이며 + 기본값은 2입니다. Gateway API/orchestrator는 각각 + `GATEWAY_API_POSTGRES_POOL_MAX`(기본 4), + `GATEWAY_ORCHESTRATOR_POSTGRES_POOL_MAX`(기본 2)를 사용합니다. - `TURBO_CACHE_DIR`: 선택 사항인 공유 local cache 경로입니다. 없으면 원래 `RELEASE_CONTROLLER_WORKSPACE_ROOT/.turbo/release-cache`를 사용합니다. 상대 경로는 원래 workspace 기준으로 해석합니다. diff --git a/app/release-controller/src/config.ts b/app/release-controller/src/config.ts index 1d0c2f99..9e23fd83 100644 --- a/app/release-controller/src/config.ts +++ b/app/release-controller/src/config.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { sanitizeManagedProcessEnv } from '@sammo-ts/gateway-api'; +import { resolvePostgresPoolMax } from '@sammo-ts/infra'; const parsePositiveInt = (value: string | undefined, fallback: number, name: string): number => { if (!value) return fallback; @@ -25,6 +26,7 @@ export interface ReleaseControllerConfig { gatewayBasePath: string; pollIntervalMs: number; readinessTimeoutMs: number; + postgresPoolMax: number; baseEnv: Record; } @@ -54,6 +56,7 @@ export const resolveReleaseControllerConfig = (env: NodeJS.ProcessEnv = process. 60000, 'RELEASE_CONTROLLER_READINESS_TIMEOUT_MS' ), + postgresPoolMax: resolvePostgresPoolMax(env.RELEASE_CONTROLLER_POSTGRES_POOL_MAX ?? env.POSTGRES_POOL_MAX, 2), baseEnv: { ...sanitizeManagedProcessEnv(env), REDIS_URL: env.REDIS_URL.trim(), diff --git a/app/release-controller/src/index.ts b/app/release-controller/src/index.ts index 5883d95e..19b43276 100644 --- a/app/release-controller/src/index.ts +++ b/app/release-controller/src/index.ts @@ -16,7 +16,10 @@ export * from './selfUpgrade.js'; const main = async (): Promise => { const config = resolveReleaseControllerConfig(); - const postgres = createGatewayPostgresConnector({ url: config.gatewayDatabaseUrl }); + const postgres = createGatewayPostgresConnector({ + url: config.gatewayDatabaseUrl, + maxConnections: config.postgresPoolMax, + }); await postgres.connect(); const repository = createGatewayReleaseRepository(postgres.prisma as GatewayPrismaClient); const workspaceManager = new GitWorkspaceManager({ diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index fe540f27..fd906721 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -17,6 +17,7 @@ import { readReleaseManifest, sanitizeManagedProcessEnv, } from '@sammo-ts/gateway-api'; +import { resolvePostgresPoolMax } from '@sammo-ts/infra'; import type { ReleaseControllerConfig } from './config.js'; @@ -25,6 +26,9 @@ const HEARTBEAT_INTERVAL_MS = 60_000; const PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const; const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu; +const managedPostgresPoolMax = (env: Record, roleVariable: string, fallback: number): string => + String(resolvePostgresPoolMax(env[roleVariable] ?? env.POSTGRES_POOL_MAX, fallback)); + const buildGatewayReleaseCommands = ( workspaceRoot: string, needsInstall: boolean, @@ -78,7 +82,16 @@ export const buildGatewayProcessDefinitions = ( GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl, }; return [ - { name: 'sammo:gateway-api', script: apiScript, cwd: apiCwd, env: { ...env, GATEWAY_ROLE: 'api' } }, + { + name: 'sammo:gateway-api', + script: apiScript, + cwd: apiCwd, + env: { + ...env, + POSTGRES_POOL_MAX: managedPostgresPoolMax(config.baseEnv, 'GATEWAY_API_POSTGRES_POOL_MAX', 4), + GATEWAY_ROLE: 'api', + }, + }, { name: 'sammo:gateway-frontend', script: frontendScript, @@ -90,7 +103,11 @@ export const buildGatewayProcessDefinitions = ( name: 'sammo:gateway-orchestrator', script: apiScript, cwd: apiCwd, - env: { ...env, GATEWAY_ROLE: 'orchestrator' }, + env: { + ...env, + POSTGRES_POOL_MAX: managedPostgresPoolMax(config.baseEnv, 'GATEWAY_ORCHESTRATOR_POSTGRES_POOL_MAX', 2), + GATEWAY_ROLE: 'orchestrator', + }, }, ]; }; diff --git a/app/release-controller/src/selfUpgrade.ts b/app/release-controller/src/selfUpgrade.ts index bc09ff81..5f8dd02b 100644 --- a/app/release-controller/src/selfUpgrade.ts +++ b/app/release-controller/src/selfUpgrade.ts @@ -41,6 +41,7 @@ export const buildReleaseControllerDefinition = ( ...sanitizeManagedProcessEnv(config.baseEnv), GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl, GATEWAY_DB_SCHEMA: config.gatewayDbSchema, + POSTGRES_POOL_MAX: String(config.postgresPoolMax), RELEASE_CONTROLLER_WORKSPACE_ROOT: config.workspaceRoot, RELEASE_CONTROLLER_WORKTREE_ROOT: config.worktreeRoot, }, diff --git a/app/release-controller/test/releaseController.test.ts b/app/release-controller/test/releaseController.test.ts index 213dc704..7f246912 100644 --- a/app/release-controller/test/releaseController.test.ts +++ b/app/release-controller/test/releaseController.test.ts @@ -73,6 +73,7 @@ const config: ReleaseControllerConfig = { gatewayBasePath: '/gateway', pollIntervalMs: 5, readinessTimeoutMs: 10, + postgresPoolMax: 2, baseEnv: { REDIS_URL: 'redis://integration.invalid:6379/0', GATEWAY_BOOTSTRAP_TOKEN: 'bootstrap-secret-value', @@ -134,6 +135,14 @@ it('runs Gateway preview from the frontend workspace dependency', () => { const definitions = buildGatewayProcessDefinitions('/srv/sammo/release', config); const frontend = definitions.find((definition) => definition.name === 'sammo:gateway-frontend'); expect(frontend?.script).toBe('/srv/sammo/release/app/gateway-frontend/node_modules/vite/bin/vite.js'); + expect(definitions.find((definition) => definition.name === 'sammo:gateway-api')?.env).toHaveProperty( + 'POSTGRES_POOL_MAX', + '4' + ); + expect(definitions.find((definition) => definition.name === 'sammo:gateway-orchestrator')?.env).toHaveProperty( + 'POSTGRES_POOL_MAX', + '2' + ); }); it('does not forward release-controller PM2 identity to Gateway processes', () => { @@ -323,6 +332,17 @@ describe('resolveReleaseControllerConfig', () => { }); expect(new URL(resolved.gatewayDatabaseUrl).searchParams.get('schema')).toBe('gateway_release'); + expect(resolved.postgresPoolMax).toBe(2); + }); + + it('applies a dedicated release-controller pool budget', () => { + const resolved = resolveReleaseControllerConfig({ + GATEWAY_DATABASE_URL: 'postgresql://user:pass@127.0.0.1:5432/sammo', + REDIS_URL: 'redis://127.0.0.1:6379/0', + RELEASE_CONTROLLER_POSTGRES_POOL_MAX: '3', + }); + + expect(resolved.postgresPoolMax).toBe(3); }); it('removes PM2 metadata from the inherited controller environment', () => { @@ -371,6 +391,7 @@ describe('upgradeReleaseController', () => { expect(definition.env).toMatchObject({ DATABASE_URL: 'postgresql://integration.invalid/sammo' }); expect(definition.env).toHaveProperty('RELEASE_CONTROLLER_WORKSPACE_ROOT', config.workspaceRoot); + expect(definition.env).toHaveProperty('POSTGRES_POOL_MAX', '2'); expect(definition.env).not.toHaveProperty('pm_id'); expect(definition.env).not.toHaveProperty('pm_exec_path'); expect(definition.env).not.toHaveProperty('name'); diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 13855756..58ad14b8 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -25,11 +25,33 @@ Fastify process입니다. worker 역할 분리는 API event loop의 작업을 frontend/API replica나 장애 대체 backend를 제공하지는 않습니다. Profile은 PostgreSQL schema와 Redis namespace를 분리하지만 같은 database, -PostgreSQL instance, runtime cgroup을 공유합니다. 현재 `PrismaPg` adapter에는 -role별 pool 상한을 명시하지 않아 각 DB 사용 process가 `pg` 기본 pool 상한을 -독립적으로 가질 수 있습니다. 따라서 profile 수를 늘릴 때는 process RSS뿐 아니라 -API, daemon, 세 worker와 Gateway 계열의 합산 connection budget을 PostgreSQL -`max_connections` 안에서 먼저 정해야 합니다. +PostgreSQL instance, runtime cgroup을 공유합니다. 관리되는 PM2 정의는 game API 4, +turn daemon 2, auction/battle/tournament worker 각 1, Gateway API 4, Gateway +orchestrator/release-controller 각 2, profile seed 1을 기본 pool 상한으로 전달합니다. +각 값은 대응하는 `*_POSTGRES_POOL_MAX`로 바꿀 수 있고, 명시적 공통 +`POSTGRES_POOL_MAX`는 role별 값이 없을 때 기본값보다 우선합니다. 수치는 고정 성능 +계약이 아니라 PostgreSQL `max_connections`, pool waiting과 응답 p95를 함께 보고 +조정하는 최초 admission budget입니다. + +한 process에서 같은 URL·schema·상한으로 생성되는 여러 Prisma client는 하나의 +`pg.Pool`을 공유합니다. 따라서 turn daemon 내부의 loader, command, persistence +client 수를 곱해 상한을 초과하지 않습니다. 마지막 connector가 disconnect할 때만 +pool을 닫고, API `/healthz`는 `postgresPool.max/total/active/idle/waiting`을 +노출합니다. 다른 schema는 `search_path`가 다르므로 pool을 공유하지 않으며 Gateway +orchestrator가 여러 profile schema를 동시에 읽는 경우는 합산 connection budget에 +별도로 포함해야 합니다. turn daemon도 game schema pool max 2와 Gateway profile +gate/admin action용 schema pool max 2를 각각 유지합니다. 같은 daemon의 두 Gateway +connector끼리는 pool 하나를 공유하지만 game pool과는 합쳐지지 않습니다. 여섯 +profile의 이론 상주 상한은 game schema 54 + daemon Gateway control 12 + 중앙 Gateway +API/orchestrator/release-controller 8 = 74이며 migration, seed와 운영 명령 reserve가 +추가로 필요합니다. + +Game schema 내부의 기능성 PostgreSQL advisory lock은 +`hashtextextended(current_schema() + logical key)`라는 공통 64-bit key를 사용합니다. +NPC 빙의, select/join, 경매, 중립 경매 등록, 통일 finalization과 read-model coverage는 +같은 schema 안에서는 기존 직렬화 순서를 유지하지만 같은 ID/key를 쓰는 다른 profile +schema와는 충돌하지 않습니다. Gateway operation/release claim lock은 여러 profile의 +제어 plane을 의도적으로 직렬화하므로 schema namespace로 바꾸지 않습니다. ## Gateway 실행 diff --git a/packages/infra/package.json b/packages/infra/package.json index e673dc31..434099ca 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -32,7 +32,7 @@ "@sammo-ts/common": "workspace:*", "@sammo-ts/logic": "workspace:*", "es-toolkit": "^1.43.0", - "pg": "^8.16.3", + "pg": "8.23.0", "redis": "^5.10.0" }, "devDependencies": { diff --git a/packages/infra/src/gameSchemaAdvisoryLock.ts b/packages/infra/src/gameSchemaAdvisoryLock.ts new file mode 100644 index 00000000..3048ca3d --- /dev/null +++ b/packages/infra/src/gameSchemaAdvisoryLock.ts @@ -0,0 +1,32 @@ +import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; + +type GameSchemaAdvisoryLockDatabase = Pick; + +interface TryLockRow { + acquired: boolean; +} + +const lockKeySql = (logicalKey: string): GamePrisma.Sql => + GamePrisma.sql`hashtextextended(current_schema() || chr(31) || ${logicalKey}, 0)`; + +/** + * Acquires a transaction-scoped lock whose namespace is the active game schema. + * Callers must already be inside the transaction that owns the protected write. + */ +export const acquireGameSchemaAdvisoryXactLock = async ( + database: GameSchemaAdvisoryLockDatabase, + logicalKey: string +): Promise => { + await database.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(${lockKeySql(logicalKey)})`); +}; + +/** Test and diagnostics boundary matching acquireGameSchemaAdvisoryXactLock. */ +export const tryGameSchemaAdvisoryXactLock = async ( + database: GameSchemaAdvisoryLockDatabase, + logicalKey: string +): Promise => { + const rows = await database.$queryRaw( + GamePrisma.sql`SELECT pg_try_advisory_xact_lock(${lockKeySql(logicalKey)}) AS acquired` + ); + return rows[0]?.acquired === true; +}; diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index ecee1325..aba382c6 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -9,3 +9,4 @@ export * from './turnEngineDb.js'; export * from './readModelChangeJournal.js'; export * from './readModelOutboxDispatcher.js'; export * from './readModelCoverageActivation.js'; +export * from './gameSchemaAdvisoryLock.js'; diff --git a/packages/infra/src/postgres.ts b/packages/infra/src/postgres.ts index 5a60b2b3..f781d377 100644 --- a/packages/infra/src/postgres.ts +++ b/packages/infra/src/postgres.ts @@ -1,4 +1,5 @@ import { PrismaPg } from '@prisma/adapter-pg'; +import pg, { type Pool as PgPool } from 'pg'; export type PostgresLogLevel = 'query' | 'info' | 'warn' | 'error'; @@ -12,12 +13,22 @@ export type PostgresLogOption = export interface PostgresConfig { url: string; log?: PostgresLogOption[]; + maxConnections?: number; +} + +export interface PostgresPoolStats { + max: number; + total: number; + active: number; + idle: number; + waiting: number; } export interface PostgresConnector { readonly prisma: TClient; connect(): Promise; disconnect(): Promise; + getPoolStats(): PostgresPoolStats; } export interface PrismaClientFactoryOptions { @@ -27,6 +38,64 @@ export interface PrismaClientFactoryOptions { export type PrismaClientFactory = (options: PrismaClientFactoryOptions) => TClient; +export const DEFAULT_POSTGRES_POOL_MAX = 10; +const MAX_POSTGRES_POOL_MAX = 1_000; + +export const resolvePostgresPoolMax = ( + value: string | number | undefined, + fallback = DEFAULT_POSTGRES_POOL_MAX +): number => { + const candidate = value === undefined || value === '' ? fallback : Number(value); + if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > MAX_POSTGRES_POOL_MAX) { + throw new RangeError(`PostgreSQL pool max must be a safe integer from 1 to ${MAX_POSTGRES_POOL_MAX}.`); + } + return candidate; +}; + +interface SharedPoolEntry { + pool: PgPool; + references: number; + maxConnections: number; +} + +const sharedPools = new Map(); + +const buildSharedPoolKey = (url: string, schema: string | undefined, maxConnections: number): string => + JSON.stringify([url, schema ?? '', maxConnections]); + +const acquireSharedPool = ( + url: string, + schema: string | undefined, + maxConnections: number +): { entry: SharedPoolEntry; release: () => Promise } => { + const key = buildSharedPoolKey(url, schema, maxConnections); + let entry = sharedPools.get(key); + if (!entry) { + const pool = new pg.Pool({ + connectionString: url, + max: maxConnections, + ...(schema ? { options: `-c search_path=${schema}` } : {}), + }); + entry = { pool, references: 0, maxConnections }; + sharedPools.set(key, entry); + } + entry.references += 1; + + let released = false; + return { + entry, + release: async () => { + if (released) return; + released = true; + entry.references -= 1; + if (entry.references === 0 && sharedPools.get(key) === entry) { + sharedPools.delete(key); + await entry.pool.end(); + } + }, + }; +}; + const resolveSchemaName = (value: string | undefined): string => { if (!value) { return 'public'; @@ -79,7 +148,10 @@ export const resolvePostgresConfigFromEnv = ( throw new Error('DATABASE_URL is required to create a Postgres client.'); } - return { url }; + return { + url, + maxConnections: resolvePostgresPoolMax(env.POSTGRES_POOL_MAX), + }; }; export const createPostgresConnector = ( @@ -88,21 +160,50 @@ export const createPostgresConnector = ( ): PostgresConnector => { const schema = extractSchemaFromDatabaseUrl(config.url) ?? process.env.POSTGRES_SCHEMA ?? process.env.DATABASE_SCHEMA; - const adapter = new PrismaPg( - { - connectionString: config.url, - ...(schema ? { options: `-c search_path=${schema}` } : {}), - }, - schema ? { schema } : undefined - ); + const maxConnections = resolvePostgresPoolMax(config.maxConnections ?? process.env.POSTGRES_POOL_MAX); + const sharedPool = acquireSharedPool(config.url, schema, maxConnections); + const adapter = new PrismaPg(sharedPool.entry.pool, schema ? { schema } : undefined); const prisma = createClient({ adapter, log: config.log, }); + let disconnected = false; return { prisma, - connect: () => (prisma as { $connect: () => Promise }).$connect(), - disconnect: () => (prisma as { $disconnect: () => Promise }).$disconnect(), + connect: () => { + if (disconnected) { + throw new Error('Postgres connector cannot reconnect after disconnect.'); + } + return (prisma as { $connect: () => Promise }).$connect(); + }, + disconnect: async () => { + if (disconnected) return; + disconnected = true; + let disconnectError: unknown; + try { + await (prisma as { $disconnect: () => Promise }).$disconnect(); + } catch (error) { + disconnectError = error; + } + try { + await sharedPool.release(); + } catch (error) { + disconnectError ??= error; + } + if (disconnectError) throw disconnectError; + }, + getPoolStats: () => { + const { pool } = sharedPool.entry; + const total = pool.totalCount; + const idle = pool.idleCount; + return { + max: sharedPool.entry.maxConnections, + total, + active: Math.max(0, total - idle), + idle, + waiting: pool.waitingCount, + }; + }, }; }; diff --git a/packages/infra/src/readModelCoverageActivation.ts b/packages/infra/src/readModelCoverageActivation.ts index e03b7f05..730888ce 100644 --- a/packages/infra/src/readModelCoverageActivation.ts +++ b/packages/infra/src/readModelCoverageActivation.ts @@ -1,4 +1,5 @@ import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; +import { acquireGameSchemaAdvisoryXactLock } from './gameSchemaAdvisoryLock.js'; export const READ_MODEL_REVISION_COVERAGE_VERSION = 1; @@ -27,12 +28,10 @@ export const activateReadModelRevisionCoverage = async ( throw new RangeError('Expected read-model coverage version must be a non-negative safe integer.'); } - await transaction.$executeRaw(GamePrisma.sql` - SELECT pg_advisory_xact_lock( - hashtext('read-model-revision-coverage'), - ${READ_MODEL_REVISION_COVERAGE_VERSION} - ) - `); + await acquireGameSchemaAdvisoryXactLock( + transaction, + `read-model-revision-coverage:${READ_MODEL_REVISION_COVERAGE_VERSION}` + ); const rows = await transaction.$queryRaw(GamePrisma.sql` SELECT "coverage_version" AS "coverageVersion" FROM "read_model_revision_meta" @@ -40,7 +39,10 @@ export const activateReadModelRevisionCoverage = async ( FOR UPDATE `); const current = rows.length === 1 ? rows[0]?.coverageVersion : undefined; - if (!Number.isSafeInteger(current) || (current !== expectedVersion && current !== READ_MODEL_REVISION_COVERAGE_VERSION)) { + if ( + !Number.isSafeInteger(current) || + (current !== expectedVersion && current !== READ_MODEL_REVISION_COVERAGE_VERSION) + ) { throw new Error( `Read-model coverage activation expected ${expectedVersion} or ${READ_MODEL_REVISION_COVERAGE_VERSION}, received ${String(current)}.` ); diff --git a/packages/infra/test/postgres.test.ts b/packages/infra/test/postgres.test.ts new file mode 100644 index 00000000..60ba527c --- /dev/null +++ b/packages/infra/test/postgres.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_POSTGRES_POOL_MAX, resolvePostgresConfigFromEnv, resolvePostgresPoolMax } from '../src/postgres.js'; + +describe('PostgreSQL pool configuration', () => { + it('uses the driver-compatible default when no explicit budget exists', () => { + expect(resolvePostgresPoolMax(undefined)).toBe(DEFAULT_POSTGRES_POOL_MAX); + expect( + resolvePostgresConfigFromEnv({ + env: { DATABASE_URL: 'postgresql://integration.invalid/sammo?schema=che' }, + }) + ).toMatchObject({ maxConnections: DEFAULT_POSTGRES_POOL_MAX }); + }); + + it('accepts an explicit environment budget', () => { + expect( + resolvePostgresConfigFromEnv({ + env: { + DATABASE_URL: 'postgresql://integration.invalid/sammo?schema=che', + POSTGRES_POOL_MAX: '4', + }, + }) + ).toMatchObject({ maxConnections: 4 }); + }); + + it.each(['0', '-1', '1.5', '1001', 'not-a-number'])('rejects invalid pool budget %s', (value) => { + expect(() => resolvePostgresPoolMax(value)).toThrow(/pool max/u); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc274d90..49a3a742 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -443,8 +443,8 @@ importers: specifier: ^1.43.0 version: 1.43.0 pg: - specifier: ^8.16.3 - version: 8.16.3 + specifier: 8.23.0 + version: 8.23.0 redis: specifier: ^5.10.0 version: 5.10.0 diff --git a/tools/conditional-integration-registry.tsv b/tools/conditional-integration-registry.tsv index 3d0526f8..254395eb 100644 --- a/tools/conditional-integration-registry.tsv +++ b/tools/conditional-integration-registry.tsv @@ -12,6 +12,8 @@ NPC_POSSESSION_DATABASE_URL npc_possession NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession PROFILE_SEED_CLI_DATABASE_URL core PROFILE_SEED_DATABASE_URL core +PROFILE_LOCK_SECONDARY_DATABASE_URL core +READ_MODEL_JOURNAL_DATABASE_URL core RESERVED_TURN_DATABASE_URL core SELECT_POOL_DATABASE_URL select_pool TURN_DAEMON_LEASE_DATABASE_URL core diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index 855a55ca..a5ea7145 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -483,10 +483,18 @@ export TURN_DIFFERENTIAL_DATABASE_URL=$database_url export RESERVED_TURN_DATABASE_URL=$database_url export PROFILE_SEED_CLI_DATABASE_URL=$database_url export PROFILE_SEED_DATABASE_URL=$database_url +profile_lock_secondary_database_url=$(build_database_url "$scenario_schema") +export PROFILE_LOCK_SECONDARY_DATABASE_URL=$profile_lock_secondary_database_url +export READ_MODEL_JOURNAL_DATABASE_URL=$database_url -pnpm --filter @sammo-ts/infra prisma:db:push:game +# The infra PostgreSQL boundary tests assert migration-owned seed rows, CHECK +# constraints, and indexes. `prisma db push` only materializes the Prisma data +# model, so provision the primary integration schema through the production +# migration chain. +pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game core_database_markers=$(markers_for_mode core) +run_marked_tests packages/infra "$core_database_markers" "infra_postgresql" run_marked_tests app/game-api "$core_database_markers" "game_api_postgresql" run_marked_tests app/game-engine "$core_database_markers" "game_engine_postgresql" run_marked_tests tools/integration-tests "$core_database_markers" "snapshot_postgresql"