perf: 프로필 DB 풀과 advisory lock 격리
역할별 PostgreSQL pool 상한과 동일 process 공유 pool을 적용하고 health 지표를 노출한다.\n\n게임 기능 advisory lock을 schema namespace로 통일하고 실제 PostgreSQL 통합 하네스를 보강한다.
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -140,6 +140,7 @@ export const createGatewayApiServer = async () => {
|
||||
|
||||
app.get('/healthz', async () => ({
|
||||
ok: true,
|
||||
postgresPool: postgres.getPoolStats(),
|
||||
}));
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 기준으로 해석합니다.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user