perf: 프로필 DB 풀과 advisory lock 격리

역할별 PostgreSQL pool 상한과 동일 process 공유 pool을 적용하고 health 지표를 노출한다.\n\n게임 기능 advisory lock을 schema namespace로 통일하고 실제 PostgreSQL 통합 하네스를 보강한다.
This commit is contained in:
2026-08-17 17:30:06 +00:00
parent 0d535a8221
commit 6f2d6f0379
30 changed files with 423 additions and 70 deletions
+11
View File
@@ -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
+3 -7
View File
@@ -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({
+1
View File
@@ -379,6 +379,7 @@ export const createGameApiServer = async () => {
app.get('/healthz', async () => ({
ok: true,
profile: config.profileName,
postgresPool: postgres.getPoolStats(),
accountIconReconciliation: accountIconResetReconciler.getHealth(),
}));
@@ -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;
},
+4 -4
View File
@@ -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: {
+2 -6
View File
@@ -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<Array<{ meta: unknown }>>`
SELECT meta
FROM world_state
@@ -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<void> => {
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`);
};
@@ -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<void> => {
// 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<string, NpcPossessionCandidate> => {
@@ -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<void> => {
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 (
@@ -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 },
});
@@ -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<void>((resolve) => {
releaseHolder = resolve;
});
let markAcquired = (): void => undefined;
const holderAcquired = new Promise<void>((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 });
});
});
@@ -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<string, string>, 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,
},
+1
View File
@@ -140,6 +140,7 @@ export const createGatewayApiServer = async () => {
app.get('/healthz', async () => ({
ok: true,
postgresPool: postgres.getPoolStats(),
}));
app.addHook('onClose', async () => {
+20 -3
View File
@@ -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', () => {
+4
View File
@@ -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 기준으로 해석합니다.
+3
View File
@@ -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<string, string>;
}
@@ -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(),
+4 -1
View File
@@ -16,7 +16,10 @@ export * from './selfUpgrade.js';
const main = async (): Promise<void> => {
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({
@@ -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<string, string>, 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',
},
},
];
};
@@ -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,
},
@@ -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');
+27 -5
View File
@@ -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 실행
+1 -1
View File
@@ -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": {
@@ -0,0 +1,32 @@
import { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
type GameSchemaAdvisoryLockDatabase = Pick<GamePrismaClient, '$executeRaw' | '$queryRaw'>;
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<void> => {
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<boolean> => {
const rows = await database.$queryRaw<TryLockRow[]>(
GamePrisma.sql`SELECT pg_try_advisory_xact_lock(${lockKeySql(logicalKey)}) AS acquired`
);
return rows[0]?.acquired === true;
};
+1
View File
@@ -9,3 +9,4 @@ export * from './turnEngineDb.js';
export * from './readModelChangeJournal.js';
export * from './readModelOutboxDispatcher.js';
export * from './readModelCoverageActivation.js';
export * from './gameSchemaAdvisoryLock.js';
+111 -10
View File
@@ -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<TClient = unknown> {
readonly prisma: TClient;
connect(): Promise<void>;
disconnect(): Promise<void>;
getPoolStats(): PostgresPoolStats;
}
export interface PrismaClientFactoryOptions {
@@ -27,6 +38,64 @@ export interface PrismaClientFactoryOptions {
export type PrismaClientFactory<TClient> = (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<string, SharedPoolEntry>();
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<void> } => {
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 = <TClient>(
@@ -88,21 +160,50 @@ export const createPostgresConnector = <TClient>(
): PostgresConnector<TClient> => {
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<void> }).$connect(),
disconnect: () => (prisma as { $disconnect: () => Promise<void> }).$disconnect(),
connect: () => {
if (disconnected) {
throw new Error('Postgres connector cannot reconnect after disconnect.');
}
return (prisma as { $connect: () => Promise<void> }).$connect();
},
disconnect: async () => {
if (disconnected) return;
disconnected = true;
let disconnectError: unknown;
try {
await (prisma as { $disconnect: () => Promise<void> }).$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,
};
},
};
};
@@ -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<CoverageRow[]>(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)}.`
);
+29
View File
@@ -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);
});
});
+2 -2
View File
@@ -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
@@ -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
1 # Environment variable Execution mode
12 NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession
13 PROFILE_SEED_CLI_DATABASE_URL core
14 PROFILE_SEED_DATABASE_URL core
15 PROFILE_LOCK_SECONDARY_DATABASE_URL core
16 READ_MODEL_JOURNAL_DATABASE_URL core
17 RESERVED_TURN_DATABASE_URL core
18 SELECT_POOL_DATABASE_URL select_pool
19 TURN_DAEMON_LEASE_DATABASE_URL core
+9 -1
View File
@@ -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"