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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user