merge: 프로필 DB 풀과 락 격리 개선

This commit is contained in:
2026-08-17 17:30:14 +00:00
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"