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

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
+33 -4
View File
@@ -1,4 +1,5 @@
import type { CurrentGameTime } from './gameClock.js';
import type { GameClockPhase } from '@sammo-ts/common';
interface ClockFenceRedis {
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
@@ -26,30 +27,58 @@ export interface ActiveRedisClockFence {
phaseKey: string;
revision: number;
generation: number;
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
}
export const ensureActiveRedisClockFence = async (
type MutableProjectionPhase = ActiveRedisClockFence['phase'];
const ensureRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime
gameTime: CurrentGameTime,
allowedPhases: readonly GameClockPhase[]
): Promise<ActiveRedisClockFence | null> => {
if (
gameTime.phase !== 'RUNNING' ||
!gameTime.phase ||
!allowedPhases.includes(gameTime.phase) ||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
!Number.isSafeInteger(gameTime.revision) ||
!Number.isSafeInteger(gameTime.deadlineGeneration)
) {
return null;
}
const phase: MutableProjectionPhase = gameTime.phase;
const fence: ActiveRedisClockFence = {
activeRevisionKey: `sammo:${profileName}:clock:active-revision`,
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
phaseKey: `sammo:${profileName}:clock:phase`,
revision: gameTime.revision!,
generation: gameTime.deadlineGeneration!,
phase,
};
const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, {
keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey],
arguments: [String(fence.revision), String(fence.generation), 'RUNNING'],
arguments: [String(fence.revision), String(fence.generation), phase],
});
return Number(result) === 1 || Number(result) === 2 ? fence : null;
};
export const ensureActiveRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> => {
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
};
/**
* User betting is allowed against a frozen tournament deadline while the game
* clock is suspended. Stage progression and settlement continue to use the
* RUNNING-only helper above.
*/
export const ensureBettingRedisClockFence = async (
redis: ClockFenceRedis,
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> =>
ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING', 'MANUAL', 'SUSPENDED']);
+18 -13
View File
@@ -1,32 +1,37 @@
import { performance } from 'node:perf_hooks';
import { gatewayProfileCapabilities } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
interface TurnDaemonLeaseSource {
turnDaemonLease: {
findUnique(input: {
where: { profile: string };
select: { leaseUntil: true };
}): Promise<{ leaseUntil: Date } | null>;
};
$queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
}
export const loadTurnEngineRunning = async (
source: ProfileStatusSource | undefined,
db: TurnDaemonLeaseSource,
profileName: string,
now = new Date()
now?: Date
): Promise<boolean | null> => {
if (!source) return null;
try {
const status = await source.get(profileName);
if (status === null) return null;
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
const lease = await db.turnDaemonLease.findUnique({
where: { profile: profileName },
select: { leaseUntil: true },
});
return lease !== null && lease.leaseUntil.getTime() > now.getTime();
const wallNow = now
? GamePrisma.sql`${now}`
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
SELECT EXISTS (
SELECT 1
FROM turn_daemon_lease
WHERE profile = ${profileName}
AND lease_until > ${wallNow}
) AS running
`);
return rows[0]?.running ?? false;
} catch {
return null;
}
@@ -42,7 +47,7 @@ export class CachedTurnEngineStatus {
private readonly db: TurnDaemonLeaseSource,
private readonly profileName: string,
private readonly cacheMs = 2_000,
private readonly now = () => Date.now()
private readonly now = () => performance.now()
) {}
get(): Promise<boolean | null> {
+13
View File
@@ -0,0 +1,13 @@
import { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient } from '../context.js';
/** Reads the authoritative PostgreSQL UTC wall instant for business rules. */
export const readDatabaseWallTime = async (db: Pick<DatabaseClient, '$queryRaw'>): Promise<Date> => {
const rows = await db.$queryRaw<Array<{ wallNow: Date }>>(GamePrisma.sql`
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow"
`);
const wallNow = rows[0]?.wallNow;
if (!wallNow) throw new Error('Failed to read PostgreSQL wall time.');
return new Date(wallNow);
};
@@ -1,4 +1,5 @@
import { createHmac, randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -55,10 +56,13 @@ export class WebPushOutboxWorker {
`);
if (rows.length === 0) return [];
const ids = rows.map((row) => row.id);
await tx.webPushOutbox.updateMany({
where: { id: { in: ids } },
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
});
await tx.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
SET "locked_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"lock_owner" = ${this.owner},
"attempts" = "attempts" + 1
WHERE "id" IN (${GamePrisma.join(ids)})
`);
return tx.webPushOutbox.findMany({
where: { id: { in: ids }, lockOwner: this.owner },
orderBy: { id: 'asc' },
@@ -66,11 +70,18 @@ export class WebPushOutboxWorker {
});
for (const event of claimed) {
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
});
const expired = await this.db.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = NULL
WHERE "id" = ${event.id}
AND "lock_owner" = ${this.owner}
AND "created_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
- ${MAX_EVENT_AGE_MS} * INTERVAL '1 millisecond'
`);
if (expired > 0) {
continue;
}
try {
@@ -94,27 +105,32 @@ export class WebPushOutboxWorker {
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
});
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = NULL
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
`);
} catch (error) {
const attempts = event.attempts;
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: {
availableAt: new Date(Date.now() + delaySeconds * 1_000),
lockedAt: null,
lockOwner: null,
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
},
});
const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
await this.db.$executeRaw(GamePrisma.sql`
UPDATE "web_push_outbox"
SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
"locked_at" = NULL,
"lock_owner" = NULL,
"last_error" = ${errorText}
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
`);
this.onError(error);
}
}
if (Date.now() >= this.nextPruneAt) {
this.nextPruneAt = Date.now() + 60_000;
if (performance.now() >= this.nextPruneAt) {
this.nextPruneAt = performance.now() + 60_000;
await this.db.$executeRaw(GamePrisma.sql`
WITH expired AS (
SELECT "id"