merge: Gateway 릴리스 이력 시각 교정을 main에 반영

This commit is contained in:
2026-08-21 17:35:10 +00:00
8 changed files with 114 additions and 18 deletions
@@ -1,4 +1,5 @@
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
import { randomUUID } from 'node:crypto';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayReleaseRepository } from '../src/orchestrator/gatewayReleaseRepository.js';
@@ -115,4 +116,41 @@ describeDatabase('gateway release operation persistence', () => {
});
await expect(repository.renewOperationLease(operation.id, 'controller-a', now, 1_000)).resolves.toBe(false);
});
it('stores direct SQL defaults as the same instant in a Seoul database session', async () => {
const operationId = randomUUID();
const beforeInsert = Date.now();
const [session] = await connector.prisma.$queryRaw<Array<{ timezone: string }>>`
SELECT current_setting('TimeZone') AS "timezone"
`;
expect(session?.timezone).toBe('UTC');
await connector.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`;
await tx.$executeRaw`
INSERT INTO "gateway_release_operation" (
"id", "type", "status", "source_mode", "source_ref", "payload",
"requested_by", "attempts", "updated_at"
) VALUES (
${operationId}, 'DEPLOY', 'QUEUED', 'BRANCH', 'main', '{}'::jsonb,
'direct-sql-test', 0, CURRENT_TIMESTAMP
)
`;
await tx.$executeRaw`
INSERT INTO "gateway_release_log" ("operation_id", "level", "phase", "message")
VALUES (${operationId}, 'INFO', 'queue', 'direct SQL timestamp test')
`;
});
const afterInsert = Date.now();
const operation = await repository.getOperation(operationId);
const [log] = await repository.listOperationLogs(operationId);
expect(operation).toBeDefined();
const createdAt = Date.parse(operation?.createdAt ?? '');
const updatedAt = Date.parse(operation?.updatedAt ?? '');
expect(createdAt).toBeGreaterThanOrEqual(beforeInsert);
expect(createdAt).toBeLessThanOrEqual(afterInsert);
expect(updatedAt).toBeGreaterThanOrEqual(beforeInsert);
expect(updatedAt).toBeLessThanOrEqual(afterInsert);
expect(log?.createdAt).toBe(operation?.createdAt);
});
});
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260819000000_backfill_profile_release_source',
gatewaySchemaHead: '20260821173000_gateway_release_instant_timestamps',
gameSchemaHead: '20260820002000_persist_official_game_index',
});
});
+12
View File
@@ -292,6 +292,18 @@ Gateway process definition에는 `GATEWAY_DATABASE_URL`과 `REDIS_URL`이 모두
Gateway 전체에는 활성 릴리스 작업을 동시에 하나만 둘 수 있습니다. 화면의
릴리스 이력에서 요청 source, 고정 commit, 상태와 오류를 확인할 수 있습니다.
릴리스 이력의 생성·시작·완료·로그 시각은 모두 PostgreSQL `timestamptz` instant로
저장합니다. 운영 PostgreSQL session이 `Asia/Seoul`이어도 `CURRENT_TIMESTAMP`는 같은
실제 instant를 저장하며, 화면에서만 고정 UTC+9 서버 시각으로 투영합니다. 기존
`timestamp without time zone` 값을 이관할 때는 raw 값을 UTC로 해석하여 그대로
보존하므로 과거에 잘못 들어간 행을 소급 보정하지 않습니다.
관리자 session을 통한 API 요청을 우선합니다. 명시적 운영 권한 아래 durable queue를
직접 등록해야 하는 예외 상황에도 KST 벽시계 문자열이나 timezone 없는 문자열을
`created_at`에 직접 만들지 말고 column default 또는 timezone-aware instant를
사용합니다. `now()`를 timezone 없는 열에 쓰는 방식은 DB session이 KST일 때 화면에서
다시 UTC+9가 적용되어 생성 시각만 9시간 미래가 될 수 있습니다.
작업을 선택하면 관리자 화면이 `admin.releases.logs`를 최대 20초씩 long polling하여
commit 해석, worktree 준비, build 명령 출력, migration, process 전환,
readiness와 rollback 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도
@@ -0,0 +1,34 @@
-- Gateway release history is an absolute control-plane timeline. Preserve every
-- existing raw value as its current UTC interpretation (including historical bad
-- rows), then make future CURRENT_TIMESTAMP writes independent of session timezone.
ALTER TABLE "gateway_release_operation"
ALTER COLUMN "created_at" DROP DEFAULT;
ALTER TABLE "gateway_release_log"
ALTER COLUMN "created_at" DROP DEFAULT;
ALTER TABLE "gateway_release_state"
ALTER COLUMN "last_successful_at" TYPE TIMESTAMPTZ(3)
USING "last_successful_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3)
USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "gateway_release_operation"
ALTER COLUMN "started_at" TYPE TIMESTAMPTZ(3)
USING "started_at" AT TIME ZONE 'UTC',
ALTER COLUMN "completed_at" TYPE TIMESTAMPTZ(3)
USING "completed_at" AT TIME ZONE 'UTC',
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3)
USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3)
USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "gateway_release_log"
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3)
USING "created_at" AT TIME ZONE 'UTC';
ALTER TABLE "gateway_release_operation"
ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "gateway_release_log"
ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;
+9 -9
View File
@@ -315,9 +315,9 @@ model GatewayReleaseState {
activeWorkspace String? @map("active_workspace")
previousCommitSha String? @map("previous_commit_sha")
previousWorkspace String? @map("previous_workspace")
lastSuccessfulAt DateTime? @map("last_successful_at")
lastSuccessfulAt DateTime? @map("last_successful_at") @db.Timestamptz(3)
lastError String? @map("last_error")
updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@map("gateway_release_state")
}
@@ -334,15 +334,15 @@ model GatewayReleaseOperation {
payload Json @default(dbgenerated("'{}'::jsonb"))
reason String?
requestedBy String @map("requested_by")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
completedAt DateTime? @map("completed_at") @db.Timestamptz(3)
error String?
leaseOwner String? @map("lease_owner")
leaseUntil DateTime? @map("lease_until")
heartbeatAt DateTime? @map("heartbeat_at")
leaseUntil DateTime? @map("lease_until") @db.Timestamptz(6)
heartbeatAt DateTime? @map("heartbeat_at") @db.Timestamptz(6)
attempts Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
logs GatewayReleaseLog[]
@@index([status, leaseUntil, createdAt])
@@ -356,7 +356,7 @@ model GatewayReleaseLog {
level String
phase String
message String @db.Text
createdAt DateTime @default(now()) @map("created_at")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
operation GatewayReleaseOperation @relation(fields: [operationId], references: [id], onDelete: Cascade)
@@index([operationId, id])
+1 -1
View File
@@ -6,4 +6,4 @@ import type { PostgresConfig, PostgresConnector } from './postgres.js';
import { createPostgresConnector } from './postgres.js';
export const createGatewayPostgresConnector = (config: PostgresConfig): PostgresConnector<GatewayPrismaClient> =>
createPostgresConnector(config, (options) => new GatewayPrismaClient(options));
createPostgresConnector({ ...config, sessionTimezone: 'UTC' }, (options) => new GatewayPrismaClient(options));
+18 -6
View File
@@ -14,6 +14,7 @@ export interface PostgresConfig {
url: string;
log?: PostgresLogOption[];
maxConnections?: number;
sessionTimezone?: 'UTC';
}
export interface PostgresPoolStats {
@@ -60,21 +61,32 @@ interface SharedPoolEntry {
const sharedPools = new Map<string, SharedPoolEntry>();
const buildSharedPoolKey = (url: string, schema: string | undefined, maxConnections: number): string =>
JSON.stringify([url, schema ?? '', maxConnections]);
const buildSharedPoolKey = (
url: string,
schema: string | undefined,
maxConnections: number,
sessionTimezone: 'UTC' | undefined
): string => JSON.stringify([url, schema ?? '', maxConnections, sessionTimezone ?? '']);
const acquireSharedPool = (
url: string,
schema: string | undefined,
maxConnections: number
maxConnections: number,
sessionTimezone: 'UTC' | undefined
): { entry: SharedPoolEntry; release: () => Promise<void> } => {
const key = buildSharedPoolKey(url, schema, maxConnections);
const key = buildSharedPoolKey(url, schema, maxConnections, sessionTimezone);
let entry = sharedPools.get(key);
if (!entry) {
const connectionOptions = [
schema ? `-c search_path=${schema}` : undefined,
sessionTimezone ? `-c timezone=${sessionTimezone}` : undefined,
]
.filter((option): option is string => option !== undefined)
.join(' ');
const pool = new pg.Pool({
connectionString: url,
max: maxConnections,
...(schema ? { options: `-c search_path=${schema}` } : {}),
...(connectionOptions ? { options: connectionOptions } : {}),
});
entry = { pool, references: 0, maxConnections };
sharedPools.set(key, entry);
@@ -161,7 +173,7 @@ export const createPostgresConnector = <TClient>(
const schema =
extractSchemaFromDatabaseUrl(config.url) ?? process.env.POSTGRES_SCHEMA ?? process.env.DATABASE_SCHEMA;
const maxConnections = resolvePostgresPoolMax(config.maxConnections ?? process.env.POSTGRES_POOL_MAX);
const sharedPool = acquireSharedPool(config.url, schema, maxConnections);
const sharedPool = acquireSharedPool(config.url, schema, maxConnections, config.sessionTimezone);
const adapter = new PrismaPg(sharedPool.entry.pool, schema ? { schema } : undefined);
const prisma = createClient({
adapter,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 2,
"gatewaySchemaHead": "20260819000000_backfill_profile_release_source",
"gatewaySchemaHead": "20260821173000_gateway_release_instant_timestamps",
"gameSchemaHead": "20260820002000_persist_official_game_index",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}