feat: add logical game clock
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
import { buildAuctionTimerKeys } from './keys.js';
|
||||
import { resolveAuctionTimerScore } from './scheduler.js';
|
||||
|
||||
export type OpenAuctionInput =
|
||||
| {
|
||||
@@ -36,7 +38,10 @@ export const openAuctionWithDaemon = async (
|
||||
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const closeAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: closeAt.getTime(), value: String(result.auctionId) }]);
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||
{ score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) },
|
||||
]);
|
||||
return {
|
||||
auctionId: result.auctionId,
|
||||
closeAt: result.closeAt,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import type { AuctionTimerRow } from './types.js';
|
||||
import type { AuctionTimerKeys } from './keys.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
||||
|
||||
interface RedisSortedSetClient {
|
||||
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
|
||||
@@ -16,6 +17,15 @@ export interface AuctionEventUpdate {
|
||||
eventAt: Date;
|
||||
}
|
||||
|
||||
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
|
||||
if (closeTick !== null && closeTick !== undefined) {
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
return value;
|
||||
}
|
||||
return time.dateToTick(closeAt) ?? closeAt.getTime();
|
||||
};
|
||||
|
||||
export const seedAuctionTimers = async (
|
||||
db: DatabaseClient,
|
||||
redis: RedisSortedSetClient,
|
||||
@@ -23,7 +33,7 @@ export const seedAuctionTimers = async (
|
||||
): Promise<number> => {
|
||||
const rows = await db.$queryRaw<AuctionTimerRow[]>(
|
||||
GamePrisma.sql`
|
||||
SELECT id, close_at as "closeAt", status
|
||||
SELECT id, close_at as "closeAt", close_tick as "closeTick", status
|
||||
FROM auction
|
||||
WHERE status IN ('OPEN', 'FINALIZING')
|
||||
`
|
||||
@@ -32,7 +42,11 @@ export const seedAuctionTimers = async (
|
||||
return 0;
|
||||
}
|
||||
|
||||
const payload = rows.map((row) => ({ score: row.closeAt.getTime(), value: String(row.id) }));
|
||||
const gameTime = await loadCurrentGameTime(db);
|
||||
const payload = rows.map((row) => ({
|
||||
score: resolveAuctionTimerScore(gameTime, row.closeAt, row.closeTick),
|
||||
value: String(row.id),
|
||||
}));
|
||||
await redis.zAdd(keys.timerKey, payload);
|
||||
return payload.length;
|
||||
};
|
||||
@@ -44,10 +58,13 @@ export const applyAuctionEvent = async (
|
||||
event: AuctionEventUpdate
|
||||
): Promise<boolean> => {
|
||||
const now = new Date();
|
||||
const gameTime = await loadCurrentGameTime(db, now);
|
||||
const closeTick = gameTime.dateToTick(event.closeAt);
|
||||
const updated = await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET close_at = ${event.closeAt},
|
||||
close_tick = ${closeTick === null ? null : BigInt(closeTick)},
|
||||
latest_event_id = ${event.eventId},
|
||||
latest_event_at = ${event.eventAt},
|
||||
updated_at = ${now}
|
||||
@@ -61,7 +78,12 @@ export const applyAuctionEvent = async (
|
||||
);
|
||||
|
||||
if (updated > 0) {
|
||||
await redis.zAdd(keys.timerKey, [{ score: event.closeAt.getTime(), value: String(event.auctionId) }]);
|
||||
await redis.zAdd(keys.timerKey, [
|
||||
{
|
||||
score: resolveAuctionTimerScore(gameTime, event.closeAt, closeTick === null ? null : BigInt(closeTick)),
|
||||
value: String(event.auctionId),
|
||||
},
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
|
||||
export interface AuctionTimerRow {
|
||||
id: number;
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
status: AuctionStatus;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
|
||||
import { resolveGameApiConfigFromEnv } from '../config.js';
|
||||
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
|
||||
import { buildAuctionTimerKeys } from './keys.js';
|
||||
import { seedAuctionTimers } from './scheduler.js';
|
||||
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
|
||||
|
||||
interface RedisTimerClient {
|
||||
zRangeByScore(
|
||||
@@ -86,8 +87,9 @@ export const processDueAuctionId = async (options: {
|
||||
historyKey: string;
|
||||
id: string;
|
||||
nowMs: number;
|
||||
nowTick?: number | null;
|
||||
}): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => {
|
||||
const { db, redis, timerKey, historyKey, id, nowMs } = options;
|
||||
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null } = options;
|
||||
const auctionId = Number(id);
|
||||
if (!Number.isSafeInteger(auctionId) || auctionId < 1) {
|
||||
return 'IGNORED';
|
||||
@@ -102,13 +104,16 @@ export const processDueAuctionId = async (options: {
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
AND status = 'OPEN'
|
||||
AND close_at <= ${now}
|
||||
AND (
|
||||
(close_tick IS NOT NULL AND close_tick <= ${nowTick === null ? null : BigInt(nowTick)})
|
||||
OR (close_tick IS NULL AND close_at <= ${now})
|
||||
)
|
||||
`
|
||||
);
|
||||
|
||||
const current = await transaction.auction.findUnique({
|
||||
where: { id: auctionId },
|
||||
select: { status: true, closeAt: true },
|
||||
select: { status: true, closeAt: true, closeTick: true },
|
||||
});
|
||||
if (!current) {
|
||||
if (updated > 0) {
|
||||
@@ -117,7 +122,7 @@ export const processDueAuctionId = async (options: {
|
||||
return { status: 'IGNORED' as const };
|
||||
}
|
||||
if (current.status === 'OPEN') {
|
||||
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt };
|
||||
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
|
||||
}
|
||||
if (current.status !== 'FINALIZING') {
|
||||
return { status: 'IGNORED' as const };
|
||||
@@ -159,7 +164,13 @@ export const processDueAuctionId = async (options: {
|
||||
return 'FINALIZING';
|
||||
}
|
||||
if (outcome.status === 'RESCHEDULED') {
|
||||
await redis.zAdd(timerKey, [{ score: outcome.closeAt.getTime(), value: String(auctionId) }]);
|
||||
const gameTime = await loadCurrentGameTime(db, now);
|
||||
await redis.zAdd(timerKey, [
|
||||
{
|
||||
score: resolveAuctionTimerScore(gameTime, outcome.closeAt, outcome.closeTick),
|
||||
value: String(auctionId),
|
||||
},
|
||||
]);
|
||||
return 'RESCHEDULED';
|
||||
}
|
||||
return 'IGNORED';
|
||||
@@ -188,17 +199,20 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
|
||||
try {
|
||||
while (!control.signal.aborted) {
|
||||
const nowMs = Date.now();
|
||||
const historyTrimBefore = nowMs - config.auctionTimerRetentionSeconds * 1000;
|
||||
const operationalNowMs = Date.now();
|
||||
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
|
||||
const gameNowMs = gameTime.now.getTime();
|
||||
const dueScore = gameTime.tick ?? gameNowMs;
|
||||
const historyTrimBefore = operationalNowMs - config.auctionTimerRetentionSeconds * 1000;
|
||||
if (historyTrimBefore > 0) {
|
||||
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
|
||||
}
|
||||
if (nowMs >= nextResyncAt) {
|
||||
if (operationalNowMs >= nextResyncAt) {
|
||||
await seedAuctionTimers(postgres.prisma, redis.client, keys);
|
||||
nextResyncAt = nowMs + config.auctionTimerResyncMs;
|
||||
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
|
||||
}
|
||||
|
||||
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, nowMs, 100);
|
||||
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100);
|
||||
if (dueIds.length > 0) {
|
||||
for (const id of dueIds) {
|
||||
try {
|
||||
@@ -208,7 +222,8 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
timerKey: keys.timerKey,
|
||||
historyKey: keys.historyKey,
|
||||
id,
|
||||
nowMs,
|
||||
nowMs: gameNowMs,
|
||||
nowTick: gameTime.tick,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown auction worker error';
|
||||
@@ -233,9 +248,9 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
|
||||
const nextDueMs = await getNextDueMs(redis.client, keys.timerKey);
|
||||
const waitMs =
|
||||
nextDueMs === null
|
||||
? config.auctionTimerPollMs
|
||||
: Math.max(0, Math.min(config.auctionTimerPollMs, nextDueMs - Date.now()));
|
||||
gameTime.tick === null && nextDueMs !== null
|
||||
? Math.max(0, Math.min(config.auctionTimerPollMs, nextDueMs - gameNowMs))
|
||||
: config.auctionTimerPollMs;
|
||||
await waitForWorkerPoll(control.signal, waitMs);
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user