fix: 게임 outbox 운영 시각을 UTC wall로 통일한다
KST 세션에서 ReadModel과 WebPush outbox의 producer provenance를 분리하고 due, lease, retention 비교를 UTC wall 계약으로 맞춘다. 기존 TIMESTAMP(3) 타입과 이전 DML 형태를 유지하면서 populated upgrade, pending requeue, delivered 보존과 두 번째 deploy no-op을 자동 검증한다.
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
+40
@@ -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;
|
||||
@@ -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` 중복을 발견하면
|
||||
|
||||
@@ -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"
|
||||
@@ -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<void> => {
|
||||
@@ -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');
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user