feat(admin): apply durable runtime clock shifts

This commit is contained in:
2026-07-30 17:21:01 +00:00
parent d6904f2c9d
commit 7f31459385
23 changed files with 1889 additions and 93 deletions
+54 -25
View File
@@ -2,6 +2,7 @@ import {
createGamePostgresConnector,
createRedisConnector,
GamePrisma,
type GamePrismaClient,
resolvePostgresConfigFromEnv,
resolveRedisConfigFromEnv,
} from '@sammo-ts/infra';
@@ -48,6 +49,50 @@ const getNextDueMs = async (redis: RedisTimerClient, timerKey: string): Promise<
return next[0]?.score ?? null;
};
export const processDueAuctionId = async (options: {
db: GamePrismaClient;
redis: RedisTimerClient;
timerKey: string;
historyKey: string;
id: string;
nowMs: number;
sendCommand: (command: { type: 'auctionFinalize'; auctionId: number }) => Promise<unknown>;
}): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => {
const { db, redis, timerKey, historyKey, id, nowMs, sendCommand } = options;
const auctionId = Number(id);
if (!Number.isFinite(auctionId)) {
return 'IGNORED';
}
const now = new Date(nowMs);
const updated = await db.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET status = 'FINALIZING',
finalizing_at = ${now},
updated_at = ${now}
WHERE id = ${auctionId}
AND status = 'OPEN'
AND close_at <= ${now}
`
);
if (updated > 0) {
await redis.zAdd(historyKey, [{ score: nowMs, value: id }]);
await sendCommand({ type: 'auctionFinalize', auctionId });
return 'FINALIZING';
}
const current = await db.auction.findFirst({
where: { id: auctionId, status: 'OPEN' },
select: { closeAt: true },
});
if (!current) {
return 'IGNORED';
}
await redis.zAdd(timerKey, [{ score: current.closeAt.getTime(), value: String(auctionId) }]);
return 'RESCHEDULED';
};
export const runAuctionWorker = async (): Promise<void> => {
const config = resolveGameApiConfigFromEnv();
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
@@ -84,32 +129,16 @@ export const runAuctionWorker = async (): Promise<void> => {
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, nowMs, 100);
if (dueIds.length > 0) {
const now = new Date(nowMs);
await redis.client.zAdd(
keys.historyKey,
dueIds.map((id) => ({ score: nowMs, value: id }))
);
for (const id of dueIds) {
const auctionId = Number(id);
if (!Number.isFinite(auctionId)) {
continue;
}
const updated = await postgres.prisma.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET status = 'FINALIZING',
finalizing_at = ${now},
updated_at = ${now}
WHERE id = ${auctionId}
AND status = 'OPEN'
AND close_at <= ${now}
`
);
if (updated > 0) {
await daemonTransport.sendCommand({ type: 'auctionFinalize', auctionId });
}
await processDueAuctionId({
db: postgres.prisma,
redis: redis.client,
timerKey: keys.timerKey,
historyKey: keys.historyKey,
id,
nowMs,
sendCommand: (command) => daemonTransport.sendCommand(command),
});
}
continue;
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra';
import { processDueAuctionId } from '../src/auction/worker.js';
const buildRedis = () => ({
zRangeByScore: vi.fn(async () => []),
zRangeWithScores: vi.fn(async () => []),
zAdd: vi.fn(async () => 1),
zRem: vi.fn(async () => 0),
zRemRangeByScore: vi.fn(async () => 0),
});
describe('auction worker clock-shift race', () => {
it('requeues an OPEN auction at its current DB deadline when an old due score loses the race', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T12:15:00.000Z');
const db = {
$executeRaw: vi.fn(async () => 0),
auction: {
findFirst: vi.fn(async () => ({ closeAt })),
},
} as unknown as GamePrismaClient;
const sendCommand = vi.fn(async () => {});
await expect(
processDueAuctionId({
db,
redis,
timerKey: 'timer',
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
sendCommand,
})
).resolves.toBe('RESCHEDULED');
expect(redis.zAdd).toHaveBeenCalledTimes(1);
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: closeAt.getTime(), value: '7' }]);
expect(sendCommand).not.toHaveBeenCalled();
});
it('records history and finalizes only after the guarded DB transition succeeds', async () => {
const redis = buildRedis();
const db = {
$executeRaw: vi.fn(async () => 1),
auction: {
findFirst: vi.fn(),
},
} as unknown as GamePrismaClient;
const sendCommand = vi.fn(async () => {});
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
await expect(
processDueAuctionId({
db,
redis,
timerKey: 'timer',
historyKey: 'history',
id: '7',
nowMs,
sendCommand,
})
).resolves.toBe('FINALIZING');
expect(redis.zAdd).toHaveBeenCalledWith('history', [{ score: nowMs, value: '7' }]);
expect(db.auction.findFirst).not.toHaveBeenCalled();
expect(sendCommand).toHaveBeenCalledWith({ type: 'auctionFinalize', auctionId: 7 });
});
});