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

역할별 PostgreSQL pool 상한과 동일 process 공유 pool을 적용하고 health 지표를 노출한다.\n\n게임 기능 advisory lock을 schema namespace로 통일하고 실제 PostgreSQL 통합 하네스를 보강한다.
This commit is contained in:
2026-08-17 17:30:06 +00:00
parent 0d535a8221
commit 6f2d6f0379
30 changed files with 423 additions and 70 deletions
+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);
});
});