merge: 프로필 DB 풀과 락 격리 개선
This commit is contained in:
@@ -7,6 +7,17 @@ POSTGRES_PASSWORD=change-me
|
|||||||
# POSTGRES_SCHEMA=public
|
# POSTGRES_SCHEMA=public
|
||||||
# DATABASE_URL=postgresql://sammo:change-me@127.0.0.1:15432/sammo?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
|
# 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
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=127.0.0.1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
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 { TurnDaemonTransport } from './transport.js';
|
||||||
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
||||||
@@ -91,12 +91,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
try {
|
try {
|
||||||
if (command.type === 'npcPossessGeneral' && this.db.$transaction) {
|
if (command.type === 'npcPossessGeneral' && this.db.$transaction) {
|
||||||
const rejectionReason = await this.db.$transaction(async (transaction) => {
|
const rejectionReason = await this.db.$transaction(async (transaction) => {
|
||||||
await transaction.$executeRaw(
|
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
|
||||||
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))`
|
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
|
||||||
);
|
|
||||||
await transaction.$executeRaw(
|
|
||||||
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${command.userId}`}, 1))`
|
|
||||||
);
|
|
||||||
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
|
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||||
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
|
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
|
||||||
const token = await transaction.npcSelectionToken.findFirst({
|
const token = await transaction.npcSelectionToken.findFirst({
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ export const createGameApiServer = async () => {
|
|||||||
app.get('/healthz', async () => ({
|
app.get('/healthz', async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
profile: config.profileName,
|
profile: config.profileName,
|
||||||
|
postgresPool: postgres.getPoolStats(),
|
||||||
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
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 type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
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 { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
import { InMemoryFlushStore } from '../src/auth/flushStore.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(
|
const blocker = db.$transaction(
|
||||||
async (transaction) => {
|
async (transaction) => {
|
||||||
await transaction.$executeRaw(
|
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
|
||||||
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))`
|
|
||||||
);
|
|
||||||
markLockReady();
|
markLockReady();
|
||||||
await lockRelease;
|
await lockRelease;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
||||||
import {
|
import {
|
||||||
ActionLogger,
|
ActionLogger,
|
||||||
ItemLoader,
|
ItemLoader,
|
||||||
@@ -71,7 +71,7 @@ const openResourceAuction = async (
|
|||||||
return fail('즉시거래가는 시작판매가의 110% 이상이어야 합니다.');
|
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({
|
const previous = await db.auction.findFirst({
|
||||||
where: {
|
where: {
|
||||||
hostGeneralId: command.generalId,
|
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 acquireGameSchemaAdvisoryXactLock(db, `auction:unique:item:${itemKey}`);
|
||||||
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(${command.generalId}, 41002)`);
|
await acquireGameSchemaAdvisoryXactLock(db, `auction:unique:host:${command.generalId}`);
|
||||||
const [sameItemAuction, previousHostAuction] = await Promise.all([
|
const [sameItemAuction, previousHostAuction] = await Promise.all([
|
||||||
db.auction.findFirst({
|
db.auction.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
writeReadModelChangeJournal,
|
writeReadModelChangeJournal,
|
||||||
@@ -1140,12 +1141,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
if (pendingNeutralAuctions.length > 0) {
|
if (pendingNeutralAuctions.length > 0) {
|
||||||
const latestRegistrationKey =
|
const latestRegistrationKey =
|
||||||
pendingNeutralAuctions[pendingNeutralAuctions.length - 1]!.registrationKey;
|
pendingNeutralAuctions[pendingNeutralAuctions.length - 1]!.registrationKey;
|
||||||
await prisma.$executeRaw`
|
await acquireGameSchemaAdvisoryXactLock(prisma, `neutral-auction-registration:world-state:${state.id}`);
|
||||||
SELECT pg_advisory_xact_lock(
|
|
||||||
hashtext(${'neutral-auction-registration'}),
|
|
||||||
${state.id}
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
const persistedRows = await prisma.$queryRaw<Array<{ meta: unknown }>>`
|
const persistedRows = await prisma.$queryRaw<Array<{ meta: unknown }>>`
|
||||||
SELECT meta
|
SELECT meta
|
||||||
FROM world_state
|
FROM world_state
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
||||||
import {
|
import {
|
||||||
ActionLogger,
|
ActionLogger,
|
||||||
buildAuctionAlias,
|
buildAuctionAlias,
|
||||||
@@ -165,7 +165,7 @@ export const buildJoinCreateGeneralSeed = (
|
|||||||
): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, acceptedTick);
|
): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, acceptedTick);
|
||||||
|
|
||||||
const lockJoinMutation = async (db: DatabaseClient, userId: string): Promise<void> => {
|
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`);
|
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { randomInt } from 'node:crypto';
|
import { randomInt } from 'node:crypto';
|
||||||
|
|
||||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
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 {
|
import {
|
||||||
ActionLogger,
|
ActionLogger,
|
||||||
DomesticTraitLoader,
|
DomesticTraitLoader,
|
||||||
@@ -150,10 +155,8 @@ const requireNpcPossessionWorld = (worldState: WorldStateRow): void => {
|
|||||||
const lockNpcPossession = async (db: DatabaseClient, userId: string): Promise<void> => {
|
const lockNpcPossession = async (db: DatabaseClient, userId: string): Promise<void> => {
|
||||||
// Ref의 서로 다른 owner token 중복과 동일 owner 다중 빙의 race는 데이터 손상
|
// Ref의 서로 다른 owner token 중복과 동일 owner 다중 빙의 race는 데이터 손상
|
||||||
// 가능성이 있어, 후보 예약과 최종 점유 모두 같은 lock 순서로 직렬화한다.
|
// 가능성이 있어, 후보 예약과 최종 점유 모두 같은 lock 순서로 직렬화한다.
|
||||||
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))`);
|
await acquireGameSchemaAdvisoryXactLock(db, 'npc-possession:global');
|
||||||
await db.$executeRaw(
|
await acquireGameSchemaAdvisoryXactLock(db, `npc-possession:user:${userId}`);
|
||||||
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${userId}`}, 1))`
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsePickResult = (value: unknown): Record<string, NpcPossessionCandidate> => {
|
const parsePickResult = (value: unknown): Record<string, NpcPossessionCandidate> => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
||||||
import {
|
import {
|
||||||
EventDomesticTraitLoader,
|
EventDomesticTraitLoader,
|
||||||
isEventDomesticTraitKey,
|
isEventDomesticTraitKey,
|
||||||
@@ -266,9 +266,7 @@ const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
|
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
|
||||||
await db.$executeRaw(
|
await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`);
|
||||||
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`select_pool:${userId}`}, 903))`
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const requireSelectionToken = async (
|
const requireSelectionToken = async (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
|
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 type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||||
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
|
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
|
||||||
|
|
||||||
@@ -68,12 +69,7 @@ const claimGeneration = async (
|
|||||||
transaction: GamePrisma.TransactionClient,
|
transaction: GamePrisma.TransactionClient,
|
||||||
input: UnificationFinalizationInput
|
input: UnificationFinalizationInput
|
||||||
): Promise<'CLAIMED' | 'ALREADY_APPLIED'> => {
|
): Promise<'CLAIMED' | 'ALREADY_APPLIED'> => {
|
||||||
await transaction.$executeRaw`
|
await acquireGameSchemaAdvisoryXactLock(transaction, `unification-finalization:${input.generationKey}`);
|
||||||
SELECT pg_advisory_xact_lock(
|
|
||||||
hashtext(${'unification-finalization'}),
|
|
||||||
hashtext(${input.generationKey})
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
const existing = await transaction.unificationFinalization.findUnique({
|
const existing = await transaction.unificationFinalization.findUnique({
|
||||||
where: { generationKey: input.generationKey },
|
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,
|
createGamePostgresConnector,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
resolvePostgresConfigFromEnv,
|
resolvePostgresConfigFromEnv,
|
||||||
|
resolvePostgresPoolMax,
|
||||||
resolveRedisConfigFromEnv,
|
resolveRedisConfigFromEnv,
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
import { isRecord } from '@sammo-ts/common';
|
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 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 = (
|
export const planProfileReconcile = (
|
||||||
status: GatewayProfileStatus,
|
status: GatewayProfileStatus,
|
||||||
runtime: ProfileRuntimeState
|
runtime: ProfileRuntimeState
|
||||||
@@ -415,6 +419,7 @@ export const buildProcessDefinitions = (
|
|||||||
const turnDaemonNodeOptions = baseEnv.TURN_DAEMON_NODE_OPTIONS?.trim();
|
const turnDaemonNodeOptions = baseEnv.TURN_DAEMON_NODE_OPTIONS?.trim();
|
||||||
const apiEnv = {
|
const apiEnv = {
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
|
POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'GAME_API_POSTGRES_POOL_MAX', 4),
|
||||||
GAME_API_ROLE: 'server',
|
GAME_API_ROLE: 'server',
|
||||||
PROFILE: profile.profile,
|
PROFILE: profile.profile,
|
||||||
SCENARIO: profile.currentScenario ?? 'default',
|
SCENARIO: profile.currentScenario ?? 'default',
|
||||||
@@ -430,6 +435,7 @@ export const buildProcessDefinitions = (
|
|||||||
const daemonEnv = {
|
const daemonEnv = {
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
...(turnDaemonNodeOptions ? { NODE_OPTIONS: turnDaemonNodeOptions } : {}),
|
...(turnDaemonNodeOptions ? { NODE_OPTIONS: turnDaemonNodeOptions } : {}),
|
||||||
|
POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'TURN_DAEMON_POSTGRES_POOL_MAX', 2),
|
||||||
GAME_ENGINE_ROLE: 'turn-daemon',
|
GAME_ENGINE_ROLE: 'turn-daemon',
|
||||||
TURN_PROFILE: profile.profile,
|
TURN_PROFILE: profile.profile,
|
||||||
PROFILE: profile.profile,
|
PROFILE: profile.profile,
|
||||||
@@ -465,6 +471,7 @@ export const buildProcessDefinitions = (
|
|||||||
cwd: apiCwd,
|
cwd: apiCwd,
|
||||||
env: {
|
env: {
|
||||||
...apiEnv,
|
...apiEnv,
|
||||||
|
POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'AUCTION_WORKER_POSTGRES_POOL_MAX', 1),
|
||||||
GAME_API_ROLE: 'auction-worker',
|
GAME_API_ROLE: 'auction-worker',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -474,6 +481,7 @@ export const buildProcessDefinitions = (
|
|||||||
cwd: apiCwd,
|
cwd: apiCwd,
|
||||||
env: {
|
env: {
|
||||||
...apiEnv,
|
...apiEnv,
|
||||||
|
POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'BATTLE_WORKER_POSTGRES_POOL_MAX', 1),
|
||||||
GAME_API_ROLE: 'battle-sim-worker',
|
GAME_API_ROLE: 'battle-sim-worker',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -483,6 +491,7 @@ export const buildProcessDefinitions = (
|
|||||||
cwd: apiCwd,
|
cwd: apiCwd,
|
||||||
env: {
|
env: {
|
||||||
...apiEnv,
|
...apiEnv,
|
||||||
|
POSTGRES_POOL_MAX: managedPostgresPoolMax(baseEnv, 'TOURNAMENT_WORKER_POSTGRES_POOL_MAX', 1),
|
||||||
GAME_API_ROLE: 'tournament-worker',
|
GAME_API_ROLE: 'tournament-worker',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1857,6 +1866,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
env: {
|
env: {
|
||||||
...(this.processConfig.baseEnv ?? {}),
|
...(this.processConfig.baseEnv ?? {}),
|
||||||
DATABASE_URL: options.databaseUrl,
|
DATABASE_URL: options.databaseUrl,
|
||||||
|
POSTGRES_POOL_MAX: managedPostgresPoolMax(
|
||||||
|
this.processConfig.baseEnv ?? {},
|
||||||
|
'PROFILE_SEED_POSTGRES_POOL_MAX',
|
||||||
|
1
|
||||||
|
),
|
||||||
GATEWAY_ROLE: 'profile-seed',
|
GATEWAY_ROLE: 'profile-seed',
|
||||||
PROFILE_SEED_REQUEST_FILE: requestFile,
|
PROFILE_SEED_REQUEST_FILE: requestFile,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export const createGatewayApiServer = async () => {
|
|||||||
|
|
||||||
app.get('/healthz', async () => ({
|
app.get('/healthz', async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
postgresPool: postgres.getPoolStats(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.addHook('onClose', async () => {
|
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.script).toBe(path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'));
|
||||||
expect(definitions.api.env).toMatchObject({
|
expect(definitions.api.env).toMatchObject({
|
||||||
GAME_PROFILE_NAME: 'che:2',
|
GAME_PROFILE_NAME: 'che:2',
|
||||||
|
POSTGRES_POOL_MAX: '4',
|
||||||
GAME_TRPC_PATH: '/che/api/trpc',
|
GAME_TRPC_PATH: '/che/api/trpc',
|
||||||
GAME_API_EVENTS_PATH: '/che/api/events',
|
GAME_API_EVENTS_PATH: '/che/api/events',
|
||||||
GATEWAY_INTERNAL_API_URL: 'http://127.0.0.1:13000',
|
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.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.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
||||||
|
expect(definitions.daemon.env.POSTGRES_POOL_MAX).toBe('2');
|
||||||
expect(definitions.auction).toMatchObject({
|
expect(definitions.auction).toMatchObject({
|
||||||
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||||
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
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({
|
expect(definitions.battleSim).toMatchObject({
|
||||||
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||||
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
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({
|
expect(definitions.tournament).toMatchObject({
|
||||||
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||||
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
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.battleSim.env.NODE_OPTIONS).toBe('--max-old-space-size=1536');
|
||||||
expect(definitions.tournament.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', () => {
|
describe('sanitizeManagedProcessEnv', () => {
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ Gateway process 환경에 전달하지 않습니다. 이 값이 frontend 정의
|
|||||||
frontend build 계약입니다.
|
frontend build 계약입니다.
|
||||||
- `RELEASE_CONTROLLER_POLL_MS`, `RELEASE_CONTROLLER_READINESS_TIMEOUT_MS`: queue
|
- `RELEASE_CONTROLLER_POLL_MS`, `RELEASE_CONTROLLER_READINESS_TIMEOUT_MS`: queue
|
||||||
poll과 준비 제한 시간입니다.
|
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 경로입니다. 없으면 원래
|
- `TURBO_CACHE_DIR`: 선택 사항인 공유 local cache 경로입니다. 없으면 원래
|
||||||
`RELEASE_CONTROLLER_WORKSPACE_ROOT/.turbo/release-cache`를 사용합니다. 상대 경로는
|
`RELEASE_CONTROLLER_WORKSPACE_ROOT/.turbo/release-cache`를 사용합니다. 상대 경로는
|
||||||
원래 workspace 기준으로 해석합니다.
|
원래 workspace 기준으로 해석합니다.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { sanitizeManagedProcessEnv } from '@sammo-ts/gateway-api';
|
import { sanitizeManagedProcessEnv } from '@sammo-ts/gateway-api';
|
||||||
|
import { resolvePostgresPoolMax } from '@sammo-ts/infra';
|
||||||
|
|
||||||
const parsePositiveInt = (value: string | undefined, fallback: number, name: string): number => {
|
const parsePositiveInt = (value: string | undefined, fallback: number, name: string): number => {
|
||||||
if (!value) return fallback;
|
if (!value) return fallback;
|
||||||
@@ -25,6 +26,7 @@ export interface ReleaseControllerConfig {
|
|||||||
gatewayBasePath: string;
|
gatewayBasePath: string;
|
||||||
pollIntervalMs: number;
|
pollIntervalMs: number;
|
||||||
readinessTimeoutMs: number;
|
readinessTimeoutMs: number;
|
||||||
|
postgresPoolMax: number;
|
||||||
baseEnv: Record<string, string>;
|
baseEnv: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ export const resolveReleaseControllerConfig = (env: NodeJS.ProcessEnv = process.
|
|||||||
60000,
|
60000,
|
||||||
'RELEASE_CONTROLLER_READINESS_TIMEOUT_MS'
|
'RELEASE_CONTROLLER_READINESS_TIMEOUT_MS'
|
||||||
),
|
),
|
||||||
|
postgresPoolMax: resolvePostgresPoolMax(env.RELEASE_CONTROLLER_POSTGRES_POOL_MAX ?? env.POSTGRES_POOL_MAX, 2),
|
||||||
baseEnv: {
|
baseEnv: {
|
||||||
...sanitizeManagedProcessEnv(env),
|
...sanitizeManagedProcessEnv(env),
|
||||||
REDIS_URL: env.REDIS_URL.trim(),
|
REDIS_URL: env.REDIS_URL.trim(),
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export * from './selfUpgrade.js';
|
|||||||
|
|
||||||
const main = async (): Promise<void> => {
|
const main = async (): Promise<void> => {
|
||||||
const config = resolveReleaseControllerConfig();
|
const config = resolveReleaseControllerConfig();
|
||||||
const postgres = createGatewayPostgresConnector({ url: config.gatewayDatabaseUrl });
|
const postgres = createGatewayPostgresConnector({
|
||||||
|
url: config.gatewayDatabaseUrl,
|
||||||
|
maxConnections: config.postgresPoolMax,
|
||||||
|
});
|
||||||
await postgres.connect();
|
await postgres.connect();
|
||||||
const repository = createGatewayReleaseRepository(postgres.prisma as GatewayPrismaClient);
|
const repository = createGatewayReleaseRepository(postgres.prisma as GatewayPrismaClient);
|
||||||
const workspaceManager = new GitWorkspaceManager({
|
const workspaceManager = new GitWorkspaceManager({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
readReleaseManifest,
|
readReleaseManifest,
|
||||||
sanitizeManagedProcessEnv,
|
sanitizeManagedProcessEnv,
|
||||||
} from '@sammo-ts/gateway-api';
|
} from '@sammo-ts/gateway-api';
|
||||||
|
import { resolvePostgresPoolMax } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { ReleaseControllerConfig } from './config.js';
|
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 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 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 = (
|
const buildGatewayReleaseCommands = (
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
needsInstall: boolean,
|
needsInstall: boolean,
|
||||||
@@ -78,7 +82,16 @@ export const buildGatewayProcessDefinitions = (
|
|||||||
GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl,
|
GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl,
|
||||||
};
|
};
|
||||||
return [
|
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',
|
name: 'sammo:gateway-frontend',
|
||||||
script: frontendScript,
|
script: frontendScript,
|
||||||
@@ -90,7 +103,11 @@ export const buildGatewayProcessDefinitions = (
|
|||||||
name: 'sammo:gateway-orchestrator',
|
name: 'sammo:gateway-orchestrator',
|
||||||
script: apiScript,
|
script: apiScript,
|
||||||
cwd: apiCwd,
|
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),
|
...sanitizeManagedProcessEnv(config.baseEnv),
|
||||||
GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl,
|
GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl,
|
||||||
GATEWAY_DB_SCHEMA: config.gatewayDbSchema,
|
GATEWAY_DB_SCHEMA: config.gatewayDbSchema,
|
||||||
|
POSTGRES_POOL_MAX: String(config.postgresPoolMax),
|
||||||
RELEASE_CONTROLLER_WORKSPACE_ROOT: config.workspaceRoot,
|
RELEASE_CONTROLLER_WORKSPACE_ROOT: config.workspaceRoot,
|
||||||
RELEASE_CONTROLLER_WORKTREE_ROOT: config.worktreeRoot,
|
RELEASE_CONTROLLER_WORKTREE_ROOT: config.worktreeRoot,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const config: ReleaseControllerConfig = {
|
|||||||
gatewayBasePath: '/gateway',
|
gatewayBasePath: '/gateway',
|
||||||
pollIntervalMs: 5,
|
pollIntervalMs: 5,
|
||||||
readinessTimeoutMs: 10,
|
readinessTimeoutMs: 10,
|
||||||
|
postgresPoolMax: 2,
|
||||||
baseEnv: {
|
baseEnv: {
|
||||||
REDIS_URL: 'redis://integration.invalid:6379/0',
|
REDIS_URL: 'redis://integration.invalid:6379/0',
|
||||||
GATEWAY_BOOTSTRAP_TOKEN: 'bootstrap-secret-value',
|
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 definitions = buildGatewayProcessDefinitions('/srv/sammo/release', config);
|
||||||
const frontend = definitions.find((definition) => definition.name === 'sammo:gateway-frontend');
|
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(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', () => {
|
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(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', () => {
|
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).toMatchObject({ DATABASE_URL: 'postgresql://integration.invalid/sammo' });
|
||||||
expect(definition.env).toHaveProperty('RELEASE_CONTROLLER_WORKSPACE_ROOT', config.workspaceRoot);
|
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_id');
|
||||||
expect(definition.env).not.toHaveProperty('pm_exec_path');
|
expect(definition.env).not.toHaveProperty('pm_exec_path');
|
||||||
expect(definition.env).not.toHaveProperty('name');
|
expect(definition.env).not.toHaveProperty('name');
|
||||||
|
|||||||
@@ -25,11 +25,33 @@ Fastify process입니다. worker 역할 분리는 API event loop의 작업을
|
|||||||
frontend/API replica나 장애 대체 backend를 제공하지는 않습니다.
|
frontend/API replica나 장애 대체 backend를 제공하지는 않습니다.
|
||||||
|
|
||||||
Profile은 PostgreSQL schema와 Redis namespace를 분리하지만 같은 database,
|
Profile은 PostgreSQL schema와 Redis namespace를 분리하지만 같은 database,
|
||||||
PostgreSQL instance, runtime cgroup을 공유합니다. 현재 `PrismaPg` adapter에는
|
PostgreSQL instance, runtime cgroup을 공유합니다. 관리되는 PM2 정의는 game API 4,
|
||||||
role별 pool 상한을 명시하지 않아 각 DB 사용 process가 `pg` 기본 pool 상한을
|
turn daemon 2, auction/battle/tournament worker 각 1, Gateway API 4, Gateway
|
||||||
독립적으로 가질 수 있습니다. 따라서 profile 수를 늘릴 때는 process RSS뿐 아니라
|
orchestrator/release-controller 각 2, profile seed 1을 기본 pool 상한으로 전달합니다.
|
||||||
API, daemon, 세 worker와 Gateway 계열의 합산 connection budget을 PostgreSQL
|
각 값은 대응하는 `*_POSTGRES_POOL_MAX`로 바꿀 수 있고, 명시적 공통
|
||||||
`max_connections` 안에서 먼저 정해야 합니다.
|
`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 실행
|
## Gateway 실행
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@sammo-ts/common": "workspace:*",
|
"@sammo-ts/common": "workspace:*",
|
||||||
"@sammo-ts/logic": "workspace:*",
|
"@sammo-ts/logic": "workspace:*",
|
||||||
"es-toolkit": "^1.43.0",
|
"es-toolkit": "^1.43.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "8.23.0",
|
||||||
"redis": "^5.10.0"
|
"redis": "^5.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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;
|
||||||
|
};
|
||||||
@@ -9,3 +9,4 @@ export * from './turnEngineDb.js';
|
|||||||
export * from './readModelChangeJournal.js';
|
export * from './readModelChangeJournal.js';
|
||||||
export * from './readModelOutboxDispatcher.js';
|
export * from './readModelOutboxDispatcher.js';
|
||||||
export * from './readModelCoverageActivation.js';
|
export * from './readModelCoverageActivation.js';
|
||||||
|
export * from './gameSchemaAdvisoryLock.js';
|
||||||
|
|||||||
+111
-10
@@ -1,4 +1,5 @@
|
|||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import pg, { type Pool as PgPool } from 'pg';
|
||||||
|
|
||||||
export type PostgresLogLevel = 'query' | 'info' | 'warn' | 'error';
|
export type PostgresLogLevel = 'query' | 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
@@ -12,12 +13,22 @@ export type PostgresLogOption =
|
|||||||
export interface PostgresConfig {
|
export interface PostgresConfig {
|
||||||
url: string;
|
url: string;
|
||||||
log?: PostgresLogOption[];
|
log?: PostgresLogOption[];
|
||||||
|
maxConnections?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostgresPoolStats {
|
||||||
|
max: number;
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
idle: number;
|
||||||
|
waiting: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostgresConnector<TClient = unknown> {
|
export interface PostgresConnector<TClient = unknown> {
|
||||||
readonly prisma: TClient;
|
readonly prisma: TClient;
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
|
getPoolStats(): PostgresPoolStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrismaClientFactoryOptions {
|
export interface PrismaClientFactoryOptions {
|
||||||
@@ -27,6 +38,64 @@ export interface PrismaClientFactoryOptions {
|
|||||||
|
|
||||||
export type PrismaClientFactory<TClient> = (options: PrismaClientFactoryOptions) => TClient;
|
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 => {
|
const resolveSchemaName = (value: string | undefined): string => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return 'public';
|
return 'public';
|
||||||
@@ -79,7 +148,10 @@ export const resolvePostgresConfigFromEnv = (
|
|||||||
throw new Error('DATABASE_URL is required to create a Postgres client.');
|
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>(
|
export const createPostgresConnector = <TClient>(
|
||||||
@@ -88,21 +160,50 @@ export const createPostgresConnector = <TClient>(
|
|||||||
): PostgresConnector<TClient> => {
|
): PostgresConnector<TClient> => {
|
||||||
const schema =
|
const schema =
|
||||||
extractSchemaFromDatabaseUrl(config.url) ?? process.env.POSTGRES_SCHEMA ?? process.env.DATABASE_SCHEMA;
|
extractSchemaFromDatabaseUrl(config.url) ?? process.env.POSTGRES_SCHEMA ?? process.env.DATABASE_SCHEMA;
|
||||||
const adapter = new PrismaPg(
|
const maxConnections = resolvePostgresPoolMax(config.maxConnections ?? process.env.POSTGRES_POOL_MAX);
|
||||||
{
|
const sharedPool = acquireSharedPool(config.url, schema, maxConnections);
|
||||||
connectionString: config.url,
|
const adapter = new PrismaPg(sharedPool.entry.pool, schema ? { schema } : undefined);
|
||||||
...(schema ? { options: `-c search_path=${schema}` } : {}),
|
|
||||||
},
|
|
||||||
schema ? { schema } : undefined
|
|
||||||
);
|
|
||||||
const prisma = createClient({
|
const prisma = createClient({
|
||||||
adapter,
|
adapter,
|
||||||
log: config.log,
|
log: config.log,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let disconnected = false;
|
||||||
return {
|
return {
|
||||||
prisma,
|
prisma,
|
||||||
connect: () => (prisma as { $connect: () => Promise<void> }).$connect(),
|
connect: () => {
|
||||||
disconnect: () => (prisma as { $disconnect: () => Promise<void> }).$disconnect(),
|
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 { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
|
||||||
|
import { acquireGameSchemaAdvisoryXactLock } from './gameSchemaAdvisoryLock.js';
|
||||||
|
|
||||||
export const READ_MODEL_REVISION_COVERAGE_VERSION = 1;
|
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.');
|
throw new RangeError('Expected read-model coverage version must be a non-negative safe integer.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.$executeRaw(GamePrisma.sql`
|
await acquireGameSchemaAdvisoryXactLock(
|
||||||
SELECT pg_advisory_xact_lock(
|
transaction,
|
||||||
hashtext('read-model-revision-coverage'),
|
`read-model-revision-coverage:${READ_MODEL_REVISION_COVERAGE_VERSION}`
|
||||||
${READ_MODEL_REVISION_COVERAGE_VERSION}
|
);
|
||||||
)
|
|
||||||
`);
|
|
||||||
const rows = await transaction.$queryRaw<CoverageRow[]>(GamePrisma.sql`
|
const rows = await transaction.$queryRaw<CoverageRow[]>(GamePrisma.sql`
|
||||||
SELECT "coverage_version" AS "coverageVersion"
|
SELECT "coverage_version" AS "coverageVersion"
|
||||||
FROM "read_model_revision_meta"
|
FROM "read_model_revision_meta"
|
||||||
@@ -40,7 +39,10 @@ export const activateReadModelRevisionCoverage = async (
|
|||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
`);
|
`);
|
||||||
const current = rows.length === 1 ? rows[0]?.coverageVersion : undefined;
|
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(
|
throw new Error(
|
||||||
`Read-model coverage activation expected ${expectedVersion} or ${READ_MODEL_REVISION_COVERAGE_VERSION}, received ${String(current)}.`
|
`Read-model coverage activation expected ${expectedVersion} or ${READ_MODEL_REVISION_COVERAGE_VERSION}, received ${String(current)}.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+2
-2
@@ -443,8 +443,8 @@ importers:
|
|||||||
specifier: ^1.43.0
|
specifier: ^1.43.0
|
||||||
version: 1.43.0
|
version: 1.43.0
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.16.3
|
specifier: 8.23.0
|
||||||
version: 8.16.3
|
version: 8.23.0
|
||||||
redis:
|
redis:
|
||||||
specifier: ^5.10.0
|
specifier: ^5.10.0
|
||||||
version: 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
|
NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession
|
||||||
PROFILE_SEED_CLI_DATABASE_URL core
|
PROFILE_SEED_CLI_DATABASE_URL core
|
||||||
PROFILE_SEED_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
|
RESERVED_TURN_DATABASE_URL core
|
||||||
SELECT_POOL_DATABASE_URL select_pool
|
SELECT_POOL_DATABASE_URL select_pool
|
||||||
TURN_DAEMON_LEASE_DATABASE_URL core
|
TURN_DAEMON_LEASE_DATABASE_URL core
|
||||||
|
|||||||
|
@@ -483,10 +483,18 @@ export TURN_DIFFERENTIAL_DATABASE_URL=$database_url
|
|||||||
export RESERVED_TURN_DATABASE_URL=$database_url
|
export RESERVED_TURN_DATABASE_URL=$database_url
|
||||||
export PROFILE_SEED_CLI_DATABASE_URL=$database_url
|
export PROFILE_SEED_CLI_DATABASE_URL=$database_url
|
||||||
export PROFILE_SEED_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)
|
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-api "$core_database_markers" "game_api_postgresql"
|
||||||
run_marked_tests app/game-engine "$core_database_markers" "game_engine_postgresql"
|
run_marked_tests app/game-engine "$core_database_markers" "game_engine_postgresql"
|
||||||
run_marked_tests tools/integration-tests "$core_database_markers" "snapshot_postgresql"
|
run_marked_tests tools/integration-tests "$core_database_markers" "snapshot_postgresql"
|
||||||
|
|||||||
Reference in New Issue
Block a user