feat(admin): apply durable runtime clock shifts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user