From fa9cee36cf8a6df86dd09ad4b76d7e7badc2f05c Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 07:40:45 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=20outbox=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20=EC=8B=9C=EA=B0=81=EC=9D=84=20UTC=20wall=EB=A1=9C?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KST 세션에서 ReadModel과 WebPush outbox의 producer provenance를 분리하고 due, lease, retention 비교를 UTC wall 계약으로 맞춘다. 기존 TIMESTAMP(3) 타입과 이전 DML 형태를 유지하면서 populated upgrade, pending requeue, delivered 보존과 두 번째 deploy no-op을 자동 검증한다. --- .../src/services/webPushOutboxWorker.ts | 9 +- app/gateway-api/test/releaseManifest.test.ts | 2 +- packages/infra/package.json | 1 + packages/infra/prisma/game.prisma | 16 +- .../migration.sql | 40 ++ packages/infra/prisma/migrations/README.md | 22 +- .../verify-game-outbox-utc-wall-migration.sh | 596 ++++++++++++++++++ ...dModelOutboxDispatcher.integration.test.ts | 92 ++- release-manifest.json | 2 +- 9 files changed, 765 insertions(+), 15 deletions(-) create mode 100644 packages/infra/prisma/migrations/20260824070000_game_outbox_utc_wall_timestamps/migration.sql create mode 100755 packages/infra/scripts/verify-game-outbox-utc-wall-migration.sh diff --git a/app/game-api/src/services/webPushOutboxWorker.ts b/app/game-api/src/services/webPushOutboxWorker.ts index 89d837d4..c2cc6929 100644 --- a/app/game-api/src/services/webPushOutboxWorker.ts +++ b/app/game-api/src/services/webPushOutboxWorker.ts @@ -44,8 +44,11 @@ export class WebPushOutboxWorker { SELECT "id" FROM "web_push_outbox" WHERE "delivered_at" IS NULL - AND "available_at" <= CURRENT_TIMESTAMP - AND ("locked_at" IS NULL OR "locked_at" <= CURRENT_TIMESTAMP - INTERVAL '30 seconds') + AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + AND ( + "locked_at" IS NULL + OR "locked_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds' + ) ORDER BY "id" FOR UPDATE SKIP LOCKED LIMIT 50 @@ -116,7 +119,7 @@ export class WebPushOutboxWorker { WITH expired AS ( SELECT "id" FROM "web_push_outbox" - WHERE "delivered_at" < CURRENT_TIMESTAMP - INTERVAL '1 day' + WHERE "delivered_at" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 day' ORDER BY "id" LIMIT 500 ) diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index ba261de8..021a96e1 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260823010000_add_web_push_notifications', - gameSchemaHead: '20260823010000_add_web_push_outbox', + gameSchemaHead: '20260824070000_game_outbox_utc_wall_timestamps', }); }); diff --git a/packages/infra/package.json b/packages/infra/package.json index 434099ca..669d8503 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -21,6 +21,7 @@ "verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh", "verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh", "verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh", + "verify:migration:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh", "coverage:activate:game": "node scripts/activate-read-model-coverage.mjs", "prisma:db:push:game": "prisma db push --schema prisma/game.prisma", "prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma" diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 06d8873c..791db1ca 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -97,12 +97,12 @@ model ReadModelOutbox { id BigInt @id @default(autoincrement()) payload Json attempts Int @default(0) - availableAt DateTime @default(now()) @map("available_at") - lockedAt DateTime? @map("locked_at") + availableAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("available_at") @db.Timestamp(3) + lockedAt DateTime? @map("locked_at") @db.Timestamp(3) lockOwner String? @map("lock_owner") - deliveredAt DateTime? @map("delivered_at") + deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3) lastError String? @map("last_error") - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("created_at") @db.Timestamp(3) @@index([deliveredAt, availableAt, id], map: "read_model_outbox_delivered_at_available_at_id_idx") @@map("read_model_outbox") @@ -116,12 +116,12 @@ model WebPushOutbox { year Int? month Int? attempts Int @default(0) - availableAt DateTime @default(now()) @map("available_at") - lockedAt DateTime? @map("locked_at") + availableAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("available_at") @db.Timestamp(3) + lockedAt DateTime? @map("locked_at") @db.Timestamp(3) lockOwner String? @map("lock_owner") - deliveredAt DateTime? @map("delivered_at") + deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3) lastError String? @map("last_error") - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("created_at") @db.Timestamp(3) @@index([deliveredAt, availableAt, id], map: "web_push_outbox_delivered_at_available_at_id_idx") @@map("web_push_outbox") diff --git a/packages/infra/prisma/migrations/20260824070000_game_outbox_utc_wall_timestamps/migration.sql b/packages/infra/prisma/migrations/20260824070000_game_outbox_utc_wall_timestamps/migration.sql new file mode 100644 index 00000000..e95f5be5 --- /dev/null +++ b/packages/infra/prisma/migrations/20260824070000_game_outbox_utc_wall_timestamps/migration.sql @@ -0,0 +1,40 @@ +-- The game schema contains legacy wall-clock DateTime fields, so its shared +-- connection pool cannot be changed to UTC wholesale. Keep the two operational +-- outboxes rollback-compatible as TIMESTAMP(3), but make their scheduling and +-- event-time contract explicitly UTC wall time. +BEGIN; + +-- Read-model journal inserts omit both timestamp columns and therefore used the +-- database session wall clock. Normalize that single-provenance history to UTC +-- wall time. Web Push createMany already wrote JavaScript UTC fields, so its +-- created_at values must remain byte-for-byte unchanged. +UPDATE "read_model_outbox" +SET "created_at" = ("created_at" AT TIME ZONE current_setting('TimeZone')) AT TIME ZONE 'UTC'; + +-- Both outboxes are at-least-once. Release stale leases and make every pending +-- row immediately eligible so mixed historical available_at provenance cannot +-- strand it. Read-model replay is a tolerated duplicate invalidation; Web Push +-- replay is deduplicated by its stable Gateway event ID. +UPDATE "read_model_outbox" +SET + "available_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + "locked_at" = NULL, + "lock_owner" = NULL +WHERE "delivered_at" IS NULL; + +UPDATE "web_push_outbox" +SET + "available_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', + "locked_at" = NULL, + "lock_owner" = NULL +WHERE "delivered_at" IS NULL; + +ALTER TABLE "read_model_outbox" + ALTER COLUMN "available_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); + +ALTER TABLE "web_push_outbox" + ALTER COLUMN "available_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); + +COMMIT; diff --git a/packages/infra/prisma/migrations/README.md b/packages/infra/prisma/migrations/README.md index e3549f19..6af16e99 100644 --- a/packages/infra/prisma/migrations/README.md +++ b/packages/infra/prisma/migrations/README.md @@ -27,7 +27,11 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여 - `world_state`, `nation`, `city`, `general`, `message`, `troop` - `general_turn`, `nation_turn`과 revision·lease field - `input_event`, `turn_daemon_lease` -- `read_model_revision`, `read_model_outbox`, `read_model_revision_meta` +- `read_model_revision`, `read_model_outbox`, `read_model_revision_meta`, `web_push_outbox` +- 두 outbox의 `available_at`, `locked_at`, `delivered_at`, `created_at`은 + millisecond 정밀도 `timestamp without time zone`을 유지한다. 이 migration + 이후 신규 값과 pending/dispatcher 운영 계약은 UTC wall 값으로 통일하되, + 이미 전달된 과거 행의 표시용 시각 전체를 일괄 재해석하지 않는다. - `read_model_revision_meta.id=1`의 `coverage_version=0` - `diplomacy`, `event`, `log_entry`, `error_log` - auction, board, vote, yearbook, archive와 inheritance table @@ -42,6 +46,22 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여 검증이 끝나면 이름을 직접 확인한 임시 database와 role만 제거합니다. 공유 database나 Compose volume을 삭제하지 않습니다. +## Game outbox UTC-wall populated upgrade 검증 + +Git에서 제외된 전용 PostgreSQL URL을 주입해 target 직전 migration chain부터 +실제 data upgrade와 두 번째 deploy no-op까지 검증합니다. + +```sh +GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL=... \ + pnpm --filter @sammo-ts/infra verify:migration:outbox-utc +``` + +검증기는 실행별 소유권 comment가 있는 schema만 만들고 정리합니다. KST DB +default로 생성된 ReadModel `created_at`의 UTC-wall 변환, 기존 JavaScript UTC +WebPush `created_at` 보존, 두 pending outbox의 requeue·lease 해제, delivered 행 +보존, target checksum과 이전 DML shape 호환성을 확인합니다. 이전 binary를 별도 +build해 실행하거나 down migration을 제공한다는 뜻은 아닙니다. + ## NPC selection 중복 owner preflight `20260731000000_add_npc_selection_token`은 `general.user_id` 중복을 발견하면 diff --git a/packages/infra/scripts/verify-game-outbox-utc-wall-migration.sh b/packages/infra/scripts/verify-game-outbox-utc-wall-migration.sh new file mode 100755 index 00000000..f3fa827a --- /dev/null +++ b/packages/infra/scripts/verify-game-outbox-utc-wall-migration.sh @@ -0,0 +1,596 @@ +#!/bin/sh +set -eu + +: "${GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL:?GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL is required}" + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +package_dir=$(dirname "$script_dir") +prisma_dir="$package_dir/prisma" +target_migration=20260824070000_game_outbox_utc_wall_timestamps +run_id=$(date -u +%m%d%H%M%S)_$$ +schema_name="game_outbox_utc_upgrade_$run_id" +ownership_token="sammo-game-outbox-utc-migration:$run_id" +work_dir=$(mktemp -d "$package_dir/.game-outbox-utc-migration.XXXXXX") + +case "$schema_name" in + game_outbox_utc_upgrade_[0-9]*_[0-9]*) ;; + *) + echo "unsafe outbox migration schema name" >&2 + exit 64 + ;; +esac + +cleanup() { + cleanup_status=0 + OWNERSHIP_TOKEN=$ownership_token \ + SCHEMA_NAME=$schema_name \ + DATABASE_URL=$GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL \ + pnpm --dir "$package_dir" exec node --input-type=module -e ' + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`; + await client.connect(); + try { + const ownership = await client.query( + "SELECT obj_description(oid, $$pg_namespace$$) AS owner FROM pg_namespace WHERE nspname = $1", + [process.env.SCHEMA_NAME] + ); + if (ownership.rowCount > 0) { + if (ownership.rows[0]?.owner !== process.env.OWNERSHIP_TOKEN) { + throw new Error(`refusing to drop unowned schema: ${process.env.SCHEMA_NAME}`); + } + await client.query(`DROP SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)} CASCADE`); + } + } finally { + await client.end(); + } + ' >/dev/null 2>&1 || cleanup_status=1 + case "$work_dir" in + "$package_dir"/.game-outbox-utc-migration.*) + rm -r -- "$work_dir" || cleanup_status=1 + ;; + *) + echo "refusing to remove unsafe outbox migration work directory: $work_dir" >&2 + cleanup_status=1 + ;; + esac + return "$cleanup_status" +} + +handle_exit() { + exit_status=$? + trap - EXIT HUP INT TERM + if ! cleanup && [ "$exit_status" -eq 0 ]; then + exit_status=1 + fi + exit "$exit_status" +} + +trap handle_exit EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +[ -d "$prisma_dir/migrations/$target_migration" ] || { + echo "target migration is missing: $target_migration" >&2 + exit 66 +} + +build_database_url() { + SCHEMA_NAME=$schema_name DATABASE_URL=$GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL \ + pnpm --dir "$package_dir" exec node --input-type=module -e ' + const url = new URL(process.env.DATABASE_URL); + url.searchParams.set("schema", process.env.SCHEMA_NAME); + // The migration must reinterpret the predecessor DB-default timestamps + // from the real legacy KST session contract, independently of host TZ. + url.searchParams.set("options", "-c TimeZone=Asia/Seoul"); + process.stdout.write(url.href); + ' +} + +database_url=$(build_database_url) + +OWNERSHIP_TOKEN=$ownership_token \ + SCHEMA_NAME=$schema_name \ + DATABASE_URL=$GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL \ + pnpm --dir "$package_dir" exec node --input-type=module -e ' + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`; + const apostrophe = String.fromCharCode(39); + const quoteLiteral = (value) => + `${apostrophe}${value.replaceAll(apostrophe, apostrophe.repeat(2))}${apostrophe}`; + await client.connect(); + try { + await client.query("BEGIN"); + await client.query(`CREATE SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)}`); + await client.query( + `COMMENT ON SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)} IS ${quoteLiteral( + process.env.OWNERSHIP_TOKEN + )}` + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + await client.end(); + } + ' + +stage_prisma="$work_dir/prisma" +mkdir -p "$stage_prisma/migrations" +cp "$prisma_dir/game.prisma" "$stage_prisma/game.prisma" +found_target=0 +for migration_dir in "$prisma_dir"/migrations/[0-9]*; do + migration_name=$(basename "$migration_dir") + if [ "$migration_name" = "$target_migration" ]; then + found_target=1 + break + fi + cp -R "$migration_dir" "$stage_prisma/migrations/$migration_name" +done +[ "$found_target" -eq 1 ] || { + echo "target migration was not found in migration order" >&2 + exit 1 +} + +cd "$package_dir" +if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$stage_prisma/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$stage_prisma/game.prisma" \ + >"$work_dir/predecessor-deploy.log" 2>&1; then + echo "failed to deploy the predecessor game migration chain" >&2 + exit 1 +fi + +SCHEMA_NAME=$schema_name DATABASE_URL=$database_url \ + pnpm exec node --input-type=module -e ' + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`; + await client.connect(); + try { + const timezone = await client.query("SHOW TimeZone"); + if (timezone.rows[0]?.TimeZone !== "Asia/Seoul") { + throw new Error(`expected Asia/Seoul fixture session, received ${timezone.rows[0]?.TimeZone}`); + } + await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`); + await client.query("BEGIN"); + await client.query(` + CREATE TABLE "_game_outbox_utc_upgrade_probe" ( + "id" INTEGER PRIMARY KEY, + "before_utc" TIMESTAMP(3) NOT NULL, + "read_pending_created_before" TIMESTAMP(3) + ) + `); + await client.query(` + INSERT INTO "_game_outbox_utc_upgrade_probe" ("id", "before_utc") + VALUES (1, CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + `); + await client.query(` + INSERT INTO "read_model_outbox" ( + "id", "payload", "attempts", "available_at", "locked_at", + "lock_owner", "delivered_at", "last_error", "created_at" + ) VALUES + ( + 910001, + $json$ {"version":1,"changes":[["general",101,"2"]],"fixture":"read-pending"} $json$::jsonb, + 2, + TIMESTAMP $$2030-01-02 03:04:05.111$$, + TIMESTAMP $$2026-08-24 16:01:02.222$$, + $$legacy-read-worker$$, + NULL, + $$read retry$$, + DEFAULT + ), + ( + 910002, + $json$ {"version":1,"changes":[["nation",7,"3"]],"fixture":"read-delivered"} $json$::jsonb, + 3, + TIMESTAMP $$2026-08-24 15:00:00.333$$, + TIMESTAMP $$2026-08-24 15:01:00.444$$, + $$delivered-read-worker$$, + TIMESTAMP $$2026-08-24 15:02:00.555$$, + $$delivered read marker$$, + TIMESTAMP $$2026-08-24 16:10:00.789$$ + ) + `); + await client.query(` + INSERT INTO "web_push_outbox" ( + "id", "event_id", "event_type", "user_ids", "year", "month", + "attempts", "available_at", "locked_at", "lock_owner", + "delivered_at", "last_error", "created_at" + ) VALUES + ( + 920001, + $$upgrade-web-pending$$, + $$PRIVATE_MESSAGE_RECEIVED$$, + ARRAY[$$user-b$$, $$user-a$$], + 201, + 7, + 4, + TIMESTAMP $$2030-02-03 04:05:06.222$$, + TIMESTAMP $$2026-08-24 07:02:03.333$$, + $$legacy-web-worker$$, + NULL, + $$web retry$$, + TIMESTAMP $$2026-08-24 07:00:00.456$$ + ), + ( + 920002, + $$upgrade-web-delivered$$, + $$MONTH_CHANGED$$, + ARRAY[$$user-c$$], + 202, + 8, + 5, + TIMESTAMP $$2026-08-24 07:10:00.333$$, + TIMESTAMP $$2026-08-24 07:11:00.444$$, + $$delivered-web-worker$$, + TIMESTAMP $$2026-08-24 07:12:00.555$$, + $$delivered web marker$$, + TIMESTAMP $$2026-08-24 07:09:00.789$$ + ) + `); + await client.query(` + UPDATE "_game_outbox_utc_upgrade_probe" + SET "read_pending_created_before" = ( + SELECT "created_at" FROM "read_model_outbox" WHERE "id" = 910001 + ) + WHERE "id" = 1 + `); + const readProvenance = await client.query(` + SELECT read."created_at" = probe."before_utc" + INTERVAL $$9 hours$$ AS "isKstDefault" + FROM "read_model_outbox" read + CROSS JOIN "_game_outbox_utc_upgrade_probe" probe + WHERE read."id" = 910001 + `); + if (readProvenance.rows[0]?.isKstDefault !== true) { + throw new Error("predecessor read-model created_at did not use the KST database default"); + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + await client.end(); + } + ' + +if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" \ + >"$work_dir/incremental-deploy.log" 2>&1; then + echo "failed to deploy the outbox UTC-wall migration" >&2 + exit 1 +fi + +SCHEMA_NAME=$schema_name \ + TARGET_MIGRATION=$target_migration \ + MIGRATION_FILE="$prisma_dir/migrations/$target_migration/migration.sql" \ + DATABASE_URL=$database_url \ + pnpm exec node --input-type=module -e ' + import { createHash } from "node:crypto"; + import { readFile } from "node:fs/promises"; + import pg from "pg"; + + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`; + const canonicalize = (value) => { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalize(entry)]) + ); + } + return value; + }; + const assertEqual = (actual, expected, label) => { + if (JSON.stringify(canonicalize(actual)) !== JSON.stringify(canonicalize(expected))) { + throw new Error(`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`); + } + }; + + await client.connect(); + try { + await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`); + + const readRows = await client.query(` + SELECT + "read_model_outbox"."id"::int AS "id", + "payload", + "attempts", + to_char("available_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "availableAt", + CASE WHEN "locked_at" IS NULL THEN NULL + ELSE to_char("locked_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "lockedAt", + "lock_owner" AS "lockOwner", + CASE WHEN "delivered_at" IS NULL THEN NULL + ELSE to_char("delivered_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "deliveredAt", + "last_error" AS "lastError", + to_char("created_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "createdAt", + "created_at" = ( + probe."read_pending_created_before" AT TIME ZONE $$Asia/Seoul$$ + ) AT TIME ZONE $$UTC$$ AS "createdNormalized", + "available_at" >= probe."before_utc" - INTERVAL $$1 second$$ + AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ + AS "availableInMigrationWindow" + FROM "read_model_outbox" + CROSS JOIN "_game_outbox_utc_upgrade_probe" probe + WHERE "read_model_outbox"."id" IN (910001, 910002) + ORDER BY "read_model_outbox"."id" + `); + const [readPending, readDelivered] = readRows.rows; + if (!readPending || !readDelivered) throw new Error("read-model upgrade fixtures are missing"); + assertEqual( + { + id: readPending.id, + payload: readPending.payload, + attempts: readPending.attempts, + lockedAt: readPending.lockedAt, + lockOwner: readPending.lockOwner, + deliveredAt: readPending.deliveredAt, + lastError: readPending.lastError, + createdNormalized: readPending.createdNormalized, + availableInMigrationWindow: readPending.availableInMigrationWindow, + }, + { + id: 910001, + payload: { version: 1, changes: [["general", 101, "2"]], fixture: "read-pending" }, + attempts: 2, + lockedAt: null, + lockOwner: null, + deliveredAt: null, + lastError: "read retry", + createdNormalized: true, + availableInMigrationWindow: true, + }, + "pending read-model row" + ); + assertEqual( + { + id: readDelivered.id, + payload: readDelivered.payload, + attempts: readDelivered.attempts, + availableAt: readDelivered.availableAt, + lockedAt: readDelivered.lockedAt, + lockOwner: readDelivered.lockOwner, + deliveredAt: readDelivered.deliveredAt, + lastError: readDelivered.lastError, + createdAt: readDelivered.createdAt, + }, + { + id: 910002, + payload: { version: 1, changes: [["nation", 7, "3"]], fixture: "read-delivered" }, + attempts: 3, + availableAt: "2026-08-24 15:00:00.333", + lockedAt: "2026-08-24 15:01:00.444", + lockOwner: "delivered-read-worker", + deliveredAt: "2026-08-24 15:02:00.555", + lastError: "delivered read marker", + createdAt: "2026-08-24 07:10:00.789", + }, + "delivered read-model row" + ); + + const webRows = await client.query(` + SELECT + "web_push_outbox"."id"::int AS "id", + "event_id" AS "eventId", + "event_type" AS "eventType", + "user_ids" AS "userIds", + "year", + "month", + "attempts", + to_char("available_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "availableAt", + CASE WHEN "locked_at" IS NULL THEN NULL + ELSE to_char("locked_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "lockedAt", + "lock_owner" AS "lockOwner", + CASE WHEN "delivered_at" IS NULL THEN NULL + ELSE to_char("delivered_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "deliveredAt", + "last_error" AS "lastError", + to_char("created_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "createdAt", + "available_at" >= probe."before_utc" - INTERVAL $$1 second$$ + AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ + AS "availableInMigrationWindow" + FROM "web_push_outbox" + CROSS JOIN "_game_outbox_utc_upgrade_probe" probe + WHERE "web_push_outbox"."id" IN (920001, 920002) + ORDER BY "web_push_outbox"."id" + `); + const [webPending, webDelivered] = webRows.rows; + if (!webPending || !webDelivered) throw new Error("web-push upgrade fixtures are missing"); + assertEqual( + { + id: webPending.id, + eventId: webPending.eventId, + eventType: webPending.eventType, + userIds: webPending.userIds, + year: webPending.year, + month: webPending.month, + attempts: webPending.attempts, + lockedAt: webPending.lockedAt, + lockOwner: webPending.lockOwner, + deliveredAt: webPending.deliveredAt, + lastError: webPending.lastError, + createdAt: webPending.createdAt, + availableInMigrationWindow: webPending.availableInMigrationWindow, + }, + { + id: 920001, + eventId: "upgrade-web-pending", + eventType: "PRIVATE_MESSAGE_RECEIVED", + userIds: ["user-b", "user-a"], + year: 201, + month: 7, + attempts: 4, + lockedAt: null, + lockOwner: null, + deliveredAt: null, + lastError: "web retry", + createdAt: "2026-08-24 07:00:00.456", + availableInMigrationWindow: true, + }, + "pending web-push row" + ); + assertEqual( + { + id: webDelivered.id, + eventId: webDelivered.eventId, + eventType: webDelivered.eventType, + userIds: webDelivered.userIds, + year: webDelivered.year, + month: webDelivered.month, + attempts: webDelivered.attempts, + availableAt: webDelivered.availableAt, + lockedAt: webDelivered.lockedAt, + lockOwner: webDelivered.lockOwner, + deliveredAt: webDelivered.deliveredAt, + lastError: webDelivered.lastError, + createdAt: webDelivered.createdAt, + }, + { + id: 920002, + eventId: "upgrade-web-delivered", + eventType: "MONTH_CHANGED", + userIds: ["user-c"], + year: 202, + month: 8, + attempts: 5, + availableAt: "2026-08-24 07:10:00.333", + lockedAt: "2026-08-24 07:11:00.444", + lockOwner: "delivered-web-worker", + deliveredAt: "2026-08-24 07:12:00.555", + lastError: "delivered web marker", + createdAt: "2026-08-24 07:09:00.789", + }, + "delivered web-push row" + ); + + const columns = await client.query(` + SELECT table_name AS "tableName", column_name AS "columnName", + data_type AS "dataType", datetime_precision AS "precision" + FROM information_schema.columns + WHERE table_schema = $1 + AND table_name IN ($$read_model_outbox$$, $$web_push_outbox$$) + AND column_name IN ($$available_at$$, $$locked_at$$, $$delivered_at$$, $$created_at$$) + ORDER BY table_name, column_name + `, [process.env.SCHEMA_NAME]); + if (columns.rowCount !== 8) throw new Error(`expected eight outbox timestamp columns, received ${columns.rowCount}`); + for (const column of columns.rows) { + if (column.dataType !== "timestamp without time zone" || column.precision !== 3) { + throw new Error(`rollback-incompatible timestamp column: ${JSON.stringify(column)}`); + } + } + + const expectedChecksum = createHash("sha256") + .update(await readFile(process.env.MIGRATION_FILE)) + .digest("hex"); + const history = await client.query(` + SELECT checksum, finished_at IS NOT NULL AS finished, + rolled_back_at IS NULL AS "notRolledBack", applied_steps_count AS steps + FROM "_prisma_migrations" + WHERE migration_name = $1 + `, [process.env.TARGET_MIGRATION]); + assertEqual(history.rows, [{ checksum: expectedChecksum, finished: true, notRolledBack: true, steps: 1 }], "migration history"); + + // This is the rollback-compatibility boundary: the predecessor runtime + // DML shapes and TIMESTAMP(3) mappings remain accepted. It deliberately + // does not claim a down migration or execute a separately built old binary. + const rollbackProbe = await client.query(` + WITH inserted_read AS ( + INSERT INTO "read_model_outbox" ("payload") + VALUES ($json$ {"version":1,"changes":[],"fixture":"old-read-shape"} $json$::jsonb) + RETURNING "created_at", "available_at" + ), inserted_web AS ( + INSERT INTO "web_push_outbox" ( + "event_id", "event_type", "user_ids", "available_at", "created_at" + ) VALUES ( + $$upgrade-old-web-shape$$, + $$PRIVATE_MESSAGE_RECEIVED$$, + ARRAY[]::TEXT[], + CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$, + CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$ + ) + RETURNING "created_at", "available_at" + ) + SELECT + inserted_read."created_at" BETWEEN + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "readCreatedUtc", + inserted_read."available_at" BETWEEN + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "readAvailableUtc", + inserted_web."created_at" BETWEEN + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "webCreatedUtc", + inserted_web."available_at" BETWEEN + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND + (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "webAvailableUtc" + FROM inserted_read CROSS JOIN inserted_web + `); + assertEqual( + rollbackProbe.rows, + [{ readCreatedUtc: true, readAvailableUtc: true, webCreatedUtc: true, webAvailableUtc: true }], + "predecessor runtime DML compatibility" + ); + } finally { + await client.end(); + } + ' + +if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" \ + >"$work_dir/noop-deploy.log" 2>&1; then + echo "second outbox UTC-wall migration deploy failed" >&2 + exit 1 +fi +grep -Fq 'No pending migrations to apply' "$work_dir/noop-deploy.log" + +if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \ + pnpm exec prisma migrate status --schema "$prisma_dir/game.prisma" \ + >"$work_dir/status.log" 2>&1; then + echo "outbox UTC-wall migration status is not clean" >&2 + exit 1 +fi +grep -Fq 'Database schema is up to date' "$work_dir/status.log" + +SCHEMA_NAME=$schema_name \ + TARGET_MIGRATION=$target_migration \ + MIGRATION_FILE="$prisma_dir/migrations/$target_migration/migration.sql" \ + DATABASE_URL=$database_url \ + pnpm exec node --input-type=module -e ' + import { createHash } from "node:crypto"; + import { readFile } from "node:fs/promises"; + import pg from "pg"; + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + try { + const expectedChecksum = createHash("sha256") + .update(await readFile(process.env.MIGRATION_FILE)) + .digest("hex"); + const result = await client.query(` + SELECT count(*)::int AS count, + bool_and(checksum = $2) AS "checksumMatches", + bool_and(finished_at IS NOT NULL) AS finished, + bool_and(rolled_back_at IS NULL) AS "notRolledBack", + sum(applied_steps_count)::int AS steps + FROM ${`"${process.env.SCHEMA_NAME.replaceAll("\"", "\"\"")}"`}."_prisma_migrations" + WHERE migration_name = $1 + `, [process.env.TARGET_MIGRATION, expectedChecksum]); + const row = result.rows[0]; + if ( + row?.count !== 1 || + row.checksumMatches !== true || + row.finished !== true || + row.notRolledBack !== true || + row.steps !== 1 + ) { + throw new Error(`unexpected target migration history after no-op deploy: ${JSON.stringify(row)}`); + } + } finally { + await client.end(); + } + ' + +echo "Game outbox UTC-wall populated upgrade, pending requeue, delivered preservation, checksum, no-op, and predecessor DML compatibility passed" diff --git a/packages/infra/test/readModelOutboxDispatcher.integration.test.ts b/packages/infra/test/readModelOutboxDispatcher.integration.test.ts index 754acb57..10203b69 100644 --- a/packages/infra/test/readModelOutboxDispatcher.integration.test.ts +++ b/packages/infra/test/readModelOutboxDispatcher.integration.test.ts @@ -7,6 +7,7 @@ import { dispatchReadModelOutboxBatch, pruneDeliveredReadModelOutbox, } from '../src/readModelOutboxDispatcher.js'; +import { enqueueWebPushOutboxEvents } from '../src/webPushOutbox.js'; const databaseUrl = process.env.READ_MODEL_JOURNAL_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); @@ -26,7 +27,9 @@ integration('read-model outbox PostgreSQL delivery boundary', () => { afterAll(async () => disconnect?.()); beforeEach(async () => { - await prisma.$executeRaw`TRUNCATE TABLE "read_model_outbox", "read_model_revision" RESTART IDENTITY`; + await prisma.$executeRaw` + TRUNCATE TABLE "read_model_outbox", "read_model_revision", "web_push_outbox" RESTART IDENTITY + `; }); const enqueue = async (entityId: number): Promise => { @@ -47,6 +50,93 @@ integration('read-model outbox PostgreSQL delivery boundary', () => { expect(new Set(ids).size).toBe(20); }); + it('keeps both game outbox defaults claimable as current instants in a non-UTC session', async () => { + const beforeInsert = Date.now(); + await prisma.$transaction(async (transaction) => { + await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`; + await writeReadModelChangeJournal(transaction, [{ domain: 'general.content', entityId: 77 }]); + await enqueueWebPushOutboxEvents(transaction, [ + { + eventId: 'non-utc-default-instant', + eventType: 'PRIVATE_MESSAGE_RECEIVED', + userIds: ['outbox-timezone-user'], + }, + ]); + }); + const afterInsert = Date.now(); + + const readModelRow = await prisma.readModelOutbox.findFirstOrThrow(); + const webPushRow = await prisma.webPushOutbox.findFirstOrThrow(); + for (const [label, instant] of Object.entries({ + readModelAvailableAt: readModelRow.availableAt, + readModelCreatedAt: readModelRow.createdAt, + webPushAvailableAt: webPushRow.availableAt, + webPushCreatedAt: webPushRow.createdAt, + })) { + expect(instant.getTime(), label).toBeGreaterThanOrEqual(beforeInsert - 1_000); + expect(instant.getTime(), label).toBeLessThanOrEqual(afterInsert + 1_000); + } + const [storedDefaults] = await prisma.$queryRaw< + Array<{ + readAvailableMs: number; + readCreatedMs: number; + webAvailableMs: number; + webCreatedMs: number; + }> + >` + SELECT + (SELECT (EXTRACT(EPOCH FROM "available_at") * 1000)::double precision + FROM "read_model_outbox" LIMIT 1) AS "readAvailableMs", + (SELECT (EXTRACT(EPOCH FROM "created_at") * 1000)::double precision + FROM "read_model_outbox" LIMIT 1) AS "readCreatedMs", + (SELECT (EXTRACT(EPOCH FROM "available_at") * 1000)::double precision + FROM "web_push_outbox" LIMIT 1) AS "webAvailableMs", + (SELECT (EXTRACT(EPOCH FROM "created_at") * 1000)::double precision + FROM "web_push_outbox" LIMIT 1) AS "webCreatedMs" + `; + if (!storedDefaults) throw new Error('outbox UTC-wall defaults were not persisted'); + for (const [label, instantMs] of Object.entries(storedDefaults)) { + expect(instantMs, label).toBeGreaterThanOrEqual(beforeInsert - 1_000); + expect(instantMs, label).toBeLessThanOrEqual(afterInsert + 1_000); + } + + const lockAt = new Date(); + const retryAt = new Date(lockAt.getTime() + 60_000); + await prisma.webPushOutbox.update({ + where: { eventId: 'non-utc-default-instant' }, + data: { availableAt: retryAt, lockedAt: lockAt, lockOwner: 'non-utc-session-worker' }, + }); + const [webPushSchedule] = await prisma.$queryRaw< + Array<{ + availableMs: number; + lockedMs: number; + due: boolean; + leaseExpired: boolean; + }> + >` + SELECT + (EXTRACT(EPOCH FROM "available_at") * 1000)::double precision AS "availableMs", + (EXTRACT(EPOCH FROM "locked_at") * 1000)::double precision AS "lockedMs", + "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS "due", + "locked_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds' + AS "leaseExpired" + FROM "web_push_outbox" + WHERE "event_id" = 'non-utc-default-instant' + `; + if (!webPushSchedule) throw new Error('web push UTC-wall schedule was not persisted'); + expect(webPushSchedule).toMatchObject({ due: false, leaseExpired: false }); + expect(Math.abs(webPushSchedule.availableMs - retryAt.getTime())).toBeLessThanOrEqual(1); + expect(Math.abs(webPushSchedule.lockedMs - lockAt.getTime())).toBeLessThanOrEqual(1); + + await expect( + claimReadModelOutboxBatch(prisma, { + owner: 'non-utc-session-worker', + limit: 1, + now: new Date(afterInsert + 1_000), + }) + ).resolves.toHaveLength(1); + }); + it('releases a publish failure and later delivers the same row', async () => { await enqueue(7); const failedAt = new Date('2099-08-16T00:00:00.000Z'); diff --git a/release-manifest.json b/release-manifest.json index 2537fe48..cf4fb87f 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260823010000_add_web_push_notifications", - "gameSchemaHead": "20260823010000_add_web_push_outbox", + "gameSchemaHead": "20260824070000_game_outbox_utc_wall_timestamps", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }