시간 도메인과 정지 중 메시지·베팅 경계 정리

This commit is contained in:
2026-09-03 16:01:19 +00:00
parent 10cddbb565
commit abceee8315
108 changed files with 3504 additions and 1473 deletions
+2
View File
@@ -13,6 +13,7 @@ export interface DatabaseClient {
trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate;
messageReadState: GamePrisma.MessageReadStateDelegate;
message: GamePrisma.MessageDelegate;
messageAction: GamePrisma.MessageActionDelegate;
city: GamePrisma.CityDelegate;
nation: GamePrisma.NationDelegate;
diplomacy: GamePrisma.DiplomacyDelegate;
@@ -38,6 +39,7 @@ export interface DatabaseClient {
nationBetting: GamePrisma.NationBettingDelegate;
nationBet: GamePrisma.NationBetDelegate;
inheritanceLog: GamePrisma.InheritanceLogDelegate;
inheritanceLedger: GamePrisma.InheritanceLedgerDelegate;
inheritanceResult: GamePrisma.InheritanceResultDelegate;
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
boardPost: GamePrisma.BoardPostDelegate;
+1
View File
@@ -11,4 +11,5 @@ export * from './readModelOutboxDispatcher.js';
export * from './readModelCoverageActivation.js';
export * from './gameSchemaAdvisoryLock.js';
export * from './inputEventClock.js';
export * from './messageEnvelope.js';
export * from './webPushOutbox.js';
+98
View File
@@ -0,0 +1,98 @@
import type { MessageRecordDraft } from '@sammo-ts/logic';
import { GamePrisma } from './gamePrisma.js';
export interface MessageGameContext {
occurredGameTick: bigint;
clockRevision: bigint;
deadlineGeneration: bigint;
expiresGameTick: bigint | null;
}
export type MessageEnvelopeDatabase = Pick<GamePrisma.TransactionClient, '$queryRaw'>;
const resolveActionType = (draft: MessageRecordDraft): string | null => {
const option = draft.payload.option;
if (!option || typeof option !== 'object' || Array.isArray(option)) return null;
const action = Reflect.get(option, 'action');
return typeof action === 'string' && action.trim() !== '' ? action : null;
};
/**
* Persists a WALL_TIME message envelope and, only for an explicit actionable
* payload, a separate GAME_TIME action row. PostgreSQL supplies the envelope
* occurrence and delete deadline; caller clocks are compatibility projections.
*/
export const persistMessageEnvelope = async (
db: MessageEnvelopeDatabase,
draft: MessageRecordDraft,
gameContext: MessageGameContext | null = null
): Promise<number> => {
const actionType = resolveActionType(draft);
if (actionType !== null && gameContext === null) {
throw new Error(`Actionable message ${actionType} requires an authoritative game clock context.`);
}
const occurredGameTick = gameContext?.occurredGameTick ?? null;
const legacyValidUntilTick = actionType === null ? null : gameContext!.expiresGameTick;
const rows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
WITH wall AS (
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
), inserted AS (
INSERT INTO message (
mailbox,
type,
src,
dest,
time,
time_tick,
valid_until,
valid_until_tick,
created_at_wall,
delete_until_wall,
occurred_game_tick,
message
)
SELECT
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${occurredGameTick},
${draft.validUntil},
${legacyValidUntilTick},
wall.now_wall,
wall.now_wall + INTERVAL '5 minutes',
${occurredGameTick},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
FROM wall
RETURNING id
), action AS (
INSERT INTO message_action (
message_id,
action_type,
status,
created_game_tick,
expires_game_tick,
clock_revision,
deadline_generation
)
SELECT
inserted.id,
${actionType},
'PENDING',
${gameContext?.occurredGameTick ?? 0n},
${gameContext?.expiresGameTick ?? null},
${gameContext?.clockRevision ?? 0n},
${gameContext?.deadlineGeneration ?? 0n}
FROM inserted
WHERE ${actionType} IS NOT NULL
RETURNING message_id
)
SELECT id FROM inserted
`);
const id = rows[0]?.id;
if (!id) throw new Error('Failed to persist message envelope.');
return id;
};
+51 -31
View File
@@ -2,7 +2,7 @@ import { parseReadModelOutboxPayload, type ReadModelOutboxPayloadV1 } from '@sam
import { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
export interface ReadModelOutboxDatabase extends Pick<GamePrismaClient, '$queryRaw'> {
export interface ReadModelOutboxDatabase extends Pick<GamePrismaClient, '$queryRaw' | '$executeRaw'> {
readModelOutbox: GamePrisma.ReadModelOutboxDelegate;
}
@@ -58,15 +58,16 @@ export const claimReadModelOutboxBatch = async (
}
const limit = normalizeLimit(options.limit);
const leaseMs = normalizeDuration(options.leaseMs, 30_000);
const now = options.now ?? new Date();
const leaseExpiredBefore = new Date(now.getTime() - leaseMs);
const nowSql = options.now
? GamePrisma.sql`${options.now}`
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
const rows = await db.$queryRaw<ClaimedRow[]>(GamePrisma.sql`
WITH candidates AS (
SELECT "id"
FROM "read_model_outbox"
WHERE "delivered_at" IS NULL
AND "available_at" <= ${now}
AND ("locked_at" IS NULL OR "locked_at" < ${leaseExpiredBefore})
AND "available_at" <= ${nowSql}
AND ("locked_at" IS NULL OR "locked_at" < ${nowSql} - ${leaseMs} * INTERVAL '1 millisecond')
ORDER BY "id"
LIMIT ${limit}
FOR UPDATE SKIP LOCKED
@@ -74,7 +75,7 @@ export const claimReadModelOutboxBatch = async (
UPDATE "read_model_outbox" AS outbox
SET
"attempts" = outbox."attempts" + 1,
"locked_at" = ${now},
"locked_at" = ${nowSql},
"lock_owner" = ${options.owner},
"last_error" = NULL
FROM candidates
@@ -89,32 +90,43 @@ export const markReadModelOutboxDelivered = async (
db: ReadModelOutboxDatabase,
input: { id: bigint; owner: string; deliveredAt?: Date }
): Promise<boolean> => {
const result = await db.readModelOutbox.updateMany({
where: { id: input.id, lockOwner: input.owner, deliveredAt: null },
data: {
deliveredAt: input.deliveredAt ?? new Date(),
lockedAt: null,
lockOwner: null,
lastError: null,
},
});
return result.count === 1;
const deliveredAtSql = input.deliveredAt
? GamePrisma.sql`${input.deliveredAt}`
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
return (
(await db.$executeRaw(GamePrisma.sql`
UPDATE "read_model_outbox"
SET "delivered_at" = ${deliveredAtSql},
"locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = NULL
WHERE "id" = ${input.id}
AND "lock_owner" = ${input.owner}
AND "delivered_at" IS NULL
`)) === 1
);
};
export const releaseReadModelOutbox = async (
db: ReadModelOutboxDatabase,
input: { id: bigint; owner: string; error: unknown; availableAt: Date }
input: { id: bigint; owner: string; error: unknown; availableAt?: Date; availableAfterMs?: number }
): Promise<boolean> => {
const result = await db.readModelOutbox.updateMany({
where: { id: input.id, lockOwner: input.owner, deliveredAt: null },
data: {
availableAt: input.availableAt,
lockedAt: null,
lockOwner: null,
lastError: formatDispatchError(input.error),
},
});
return result.count === 1;
const availableAtSql = input.availableAt
? GamePrisma.sql`${input.availableAt}`
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
+ ${normalizeDuration(input.availableAfterMs, 1_000)} * INTERVAL '1 millisecond'`;
return (
(await db.$executeRaw(GamePrisma.sql`
UPDATE "read_model_outbox"
SET "available_at" = ${availableAtSql},
"locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = ${formatDispatchError(input.error)}
WHERE "id" = ${input.id}
AND "lock_owner" = ${input.owner}
AND "delivered_at" IS NULL
`)) === 1
);
};
export const dispatchReadModelOutboxBatch = async (
@@ -122,14 +134,14 @@ export const dispatchReadModelOutboxBatch = async (
publish: (payload: ReadModelOutboxPayloadV1, outboxId: bigint) => Promise<void>,
options: ReadModelOutboxDispatchOptions
): Promise<ReadModelOutboxDispatchResult> => {
const now = options.now ?? (() => new Date());
const testNow = options.now;
const retryBaseMs = normalizeDuration(options.retryBaseMs, 1_000);
const retryMaxMs = Math.max(retryBaseMs, normalizeDuration(options.retryMaxMs, 60_000));
const claimed = await claimReadModelOutboxBatch(db, {
owner: options.owner,
limit: options.limit,
leaseMs: options.leaseMs,
now: now(),
...(testNow ? { now: testNow() } : {}),
});
let delivered = 0;
let failed = 0;
@@ -141,7 +153,13 @@ export const dispatchReadModelOutboxBatch = async (
throw new Error(`Read-model outbox ${item.id.toString()} has an invalid payload.`);
}
await publish(payload, item.id);
if (!(await markReadModelOutboxDelivered(db, { id: item.id, owner: options.owner, deliveredAt: now() }))) {
if (
!(await markReadModelOutboxDelivered(db, {
id: item.id,
owner: options.owner,
...(testNow ? { deliveredAt: testNow() } : {}),
}))
) {
throw new Error(`Read-model outbox ${item.id.toString()} lost its delivery lease.`);
}
delivered += 1;
@@ -151,7 +169,9 @@ export const dispatchReadModelOutboxBatch = async (
id: item.id,
owner: options.owner,
error,
availableAt: new Date(now().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)),
...(testNow
? { availableAt: new Date(testNow().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)) }
: { availableAfterMs: retryDelayMs(item.attempts, retryBaseMs, retryMaxMs) }),
});
}
}