diff --git a/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts b/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts index bf71e4af..afa21858 100644 --- a/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts +++ b/app/gateway-api/test/gatewayReleaseRepository.integration.test.ts @@ -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>` + 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); + }); }); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 643b8d68..a87e15d6 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -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', }); }); diff --git a/docs/release-operations.md b/docs/release-operations.md index a0dc5612..1537a1bc 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -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 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도 diff --git a/packages/infra/prisma/gateway-migrations/20260821173000_gateway_release_instant_timestamps/migration.sql b/packages/infra/prisma/gateway-migrations/20260821173000_gateway_release_instant_timestamps/migration.sql new file mode 100644 index 00000000..20c19c70 --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260821173000_gateway_release_instant_timestamps/migration.sql @@ -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; diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index 67f3fe82..e17de6fe 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -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]) diff --git a/packages/infra/src/gatewayPrisma.ts b/packages/infra/src/gatewayPrisma.ts index 0f2809ee..7ee9e67a 100644 --- a/packages/infra/src/gatewayPrisma.ts +++ b/packages/infra/src/gatewayPrisma.ts @@ -6,4 +6,4 @@ import type { PostgresConfig, PostgresConnector } from './postgres.js'; import { createPostgresConnector } from './postgres.js'; export const createGatewayPostgresConnector = (config: PostgresConfig): PostgresConnector => - createPostgresConnector(config, (options) => new GatewayPrismaClient(options)); + createPostgresConnector({ ...config, sessionTimezone: 'UTC' }, (options) => new GatewayPrismaClient(options)); diff --git a/packages/infra/src/postgres.ts b/packages/infra/src/postgres.ts index f781d377..8126dea9 100644 --- a/packages/infra/src/postgres.ts +++ b/packages/infra/src/postgres.ts @@ -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(); -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 } => { - 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 = ( 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, diff --git a/release-manifest.json b/release-manifest.json index de7f2e25..25ec550c 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -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"] }