fix(gateway): 릴리스 이력 시각을 instant로 저장
운영 DB 세션 시간대와 무관하게 Gateway 릴리스 작업 및 로그 시각을 저장하고, 기존 raw 값은 현재 UTC 해석으로 보존한다. Gateway Prisma 세션을 UTC로 고정하고 KST 직접 입력 회귀 테스트 및 manifest head를 갱신한다.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createGatewayReleaseRepository } from '../src/orchestrator/gatewayReleaseRepository.js';
|
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);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
|
|||||||
|
|
||||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20260819000000_backfill_profile_release_source',
|
gatewaySchemaHead: '20260821173000_gateway_release_instant_timestamps',
|
||||||
gameSchemaHead: '20260820002000_persist_official_game_index',
|
gameSchemaHead: '20260820002000_persist_official_game_index',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -292,6 +292,18 @@ Gateway process definition에는 `GATEWAY_DATABASE_URL`과 `REDIS_URL`이 모두
|
|||||||
|
|
||||||
Gateway 전체에는 활성 릴리스 작업을 동시에 하나만 둘 수 있습니다. 화면의
|
Gateway 전체에는 활성 릴리스 작업을 동시에 하나만 둘 수 있습니다. 화면의
|
||||||
릴리스 이력에서 요청 source, 고정 commit, 상태와 오류를 확인할 수 있습니다.
|
릴리스 이력에서 요청 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하여
|
작업을 선택하면 관리자 화면이 `admin.releases.logs`를 최대 20초씩 long polling하여
|
||||||
commit 해석, worktree 준비, build 명령 출력, migration, process 전환,
|
commit 해석, worktree 준비, build 명령 출력, migration, process 전환,
|
||||||
readiness와 rollback 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도
|
readiness와 rollback 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도
|
||||||
|
|||||||
+34
@@ -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;
|
||||||
@@ -315,9 +315,9 @@ model GatewayReleaseState {
|
|||||||
activeWorkspace String? @map("active_workspace")
|
activeWorkspace String? @map("active_workspace")
|
||||||
previousCommitSha String? @map("previous_commit_sha")
|
previousCommitSha String? @map("previous_commit_sha")
|
||||||
previousWorkspace String? @map("previous_workspace")
|
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")
|
lastError String? @map("last_error")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
@@map("gateway_release_state")
|
@@map("gateway_release_state")
|
||||||
}
|
}
|
||||||
@@ -334,15 +334,15 @@ model GatewayReleaseOperation {
|
|||||||
payload Json @default(dbgenerated("'{}'::jsonb"))
|
payload Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
reason String?
|
reason String?
|
||||||
requestedBy String @map("requested_by")
|
requestedBy String @map("requested_by")
|
||||||
startedAt DateTime? @map("started_at")
|
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
|
||||||
completedAt DateTime? @map("completed_at")
|
completedAt DateTime? @map("completed_at") @db.Timestamptz(3)
|
||||||
error String?
|
error String?
|
||||||
leaseOwner String? @map("lease_owner")
|
leaseOwner String? @map("lease_owner")
|
||||||
leaseUntil DateTime? @map("lease_until")
|
leaseUntil DateTime? @map("lease_until") @db.Timestamptz(6)
|
||||||
heartbeatAt DateTime? @map("heartbeat_at")
|
heartbeatAt DateTime? @map("heartbeat_at") @db.Timestamptz(6)
|
||||||
attempts Int @default(0)
|
attempts Int @default(0)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
logs GatewayReleaseLog[]
|
logs GatewayReleaseLog[]
|
||||||
|
|
||||||
@@index([status, leaseUntil, createdAt])
|
@@index([status, leaseUntil, createdAt])
|
||||||
@@ -356,7 +356,7 @@ model GatewayReleaseLog {
|
|||||||
level String
|
level String
|
||||||
phase String
|
phase String
|
||||||
message String @db.Text
|
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)
|
operation GatewayReleaseOperation @relation(fields: [operationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([operationId, id])
|
@@index([operationId, id])
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ import type { PostgresConfig, PostgresConnector } from './postgres.js';
|
|||||||
import { createPostgresConnector } from './postgres.js';
|
import { createPostgresConnector } from './postgres.js';
|
||||||
|
|
||||||
export const createGatewayPostgresConnector = (config: PostgresConfig): PostgresConnector<GatewayPrismaClient> =>
|
export const createGatewayPostgresConnector = (config: PostgresConfig): PostgresConnector<GatewayPrismaClient> =>
|
||||||
createPostgresConnector(config, (options) => new GatewayPrismaClient(options));
|
createPostgresConnector({ ...config, sessionTimezone: 'UTC' }, (options) => new GatewayPrismaClient(options));
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface PostgresConfig {
|
|||||||
url: string;
|
url: string;
|
||||||
log?: PostgresLogOption[];
|
log?: PostgresLogOption[];
|
||||||
maxConnections?: number;
|
maxConnections?: number;
|
||||||
|
sessionTimezone?: 'UTC';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostgresPoolStats {
|
export interface PostgresPoolStats {
|
||||||
@@ -60,21 +61,32 @@ interface SharedPoolEntry {
|
|||||||
|
|
||||||
const sharedPools = new Map<string, SharedPoolEntry>();
|
const sharedPools = new Map<string, SharedPoolEntry>();
|
||||||
|
|
||||||
const buildSharedPoolKey = (url: string, schema: string | undefined, maxConnections: number): string =>
|
const buildSharedPoolKey = (
|
||||||
JSON.stringify([url, schema ?? '', maxConnections]);
|
url: string,
|
||||||
|
schema: string | undefined,
|
||||||
|
maxConnections: number,
|
||||||
|
sessionTimezone: 'UTC' | undefined
|
||||||
|
): string => JSON.stringify([url, schema ?? '', maxConnections, sessionTimezone ?? '']);
|
||||||
|
|
||||||
const acquireSharedPool = (
|
const acquireSharedPool = (
|
||||||
url: string,
|
url: string,
|
||||||
schema: string | undefined,
|
schema: string | undefined,
|
||||||
maxConnections: number
|
maxConnections: number,
|
||||||
|
sessionTimezone: 'UTC' | undefined
|
||||||
): { entry: SharedPoolEntry; release: () => Promise<void> } => {
|
): { entry: SharedPoolEntry; release: () => Promise<void> } => {
|
||||||
const key = buildSharedPoolKey(url, schema, maxConnections);
|
const key = buildSharedPoolKey(url, schema, maxConnections, sessionTimezone);
|
||||||
let entry = sharedPools.get(key);
|
let entry = sharedPools.get(key);
|
||||||
if (!entry) {
|
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({
|
const pool = new pg.Pool({
|
||||||
connectionString: url,
|
connectionString: url,
|
||||||
max: maxConnections,
|
max: maxConnections,
|
||||||
...(schema ? { options: `-c search_path=${schema}` } : {}),
|
...(connectionOptions ? { options: connectionOptions } : {}),
|
||||||
});
|
});
|
||||||
entry = { pool, references: 0, maxConnections };
|
entry = { pool, references: 0, maxConnections };
|
||||||
sharedPools.set(key, entry);
|
sharedPools.set(key, entry);
|
||||||
@@ -161,7 +173,7 @@ export const createPostgresConnector = <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 maxConnections = resolvePostgresPoolMax(config.maxConnections ?? process.env.POSTGRES_POOL_MAX);
|
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 adapter = new PrismaPg(sharedPool.entry.pool, schema ? { schema } : undefined);
|
||||||
const prisma = createClient({
|
const prisma = createClient({
|
||||||
adapter,
|
adapter,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260819000000_backfill_profile_release_source",
|
"gatewaySchemaHead": "20260821173000_gateway_release_instant_timestamps",
|
||||||
"gameSchemaHead": "20260820002000_persist_official_game_index",
|
"gameSchemaHead": "20260820002000_persist_official_game_index",
|
||||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user