Files
core2026/packages/infra/scripts/verify-game-outbox-utc-wall-migration.sh
T
Hide_D fa9cee36cf 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을 자동 검증한다.
2026-08-24 07:40:45 +00:00

597 lines
27 KiB
Bash
Executable File

#!/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"