fix: repair existing test failures

This commit is contained in:
2026-08-13 00:52:22 +00:00
parent 6da7d2f899
commit d133db32eb
24 changed files with 386 additions and 116 deletions
+9 -1
View File
@@ -26,6 +26,14 @@ export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, c
return time.dateToTick(closeAt) ?? closeAt.getTime();
};
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
if (row.status === 'FINALIZING') {
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
return time.tick ?? time.now.getTime();
}
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
};
export const seedAuctionTimers = async (
db: DatabaseClient,
redis: RedisSortedSetClient,
@@ -44,7 +52,7 @@ export const seedAuctionTimers = async (
const gameTime = await loadCurrentGameTime(db);
const payload = rows.map((row) => ({
score: resolveAuctionTimerScore(gameTime, row.closeAt, row.closeTick),
score: resolveAuctionSeedScore(gameTime, row),
value: String(row.id),
}));
await redis.zAdd(keys.timerKey, payload);
+5 -2
View File
@@ -88,8 +88,9 @@ export const processDueAuctionId = async (options: {
id: string;
nowMs: number;
nowTick?: number | null;
historyNowMs?: number;
}): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => {
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null } = options;
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
const auctionId = Number(id);
if (!Number.isSafeInteger(auctionId) || auctionId < 1) {
return 'IGNORED';
@@ -160,7 +161,8 @@ export const processDueAuctionId = async (options: {
});
if (outcome.status === 'FINALIZING') {
await redis.zAdd(historyKey, [{ score: nowMs, value: id }]);
// history retention은 운영 경과시간 기준이며 게임의 논리 시각과 분리한다.
await redis.zAdd(historyKey, [{ score: historyNowMs, value: id }]);
return 'FINALIZING';
}
if (outcome.status === 'RESCHEDULED') {
@@ -224,6 +226,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
id,
nowMs: gameNowMs,
nowTick: gameTime.tick,
historyNowMs: operationalNowMs,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown auction worker error';
+28 -4
View File
@@ -4,6 +4,7 @@ import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra';
import type { TurnDaemonTransport } from './transport.js';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
@@ -20,6 +21,18 @@ const stableJson = (value: unknown): string => {
}
return JSON.stringify(value) ?? 'null';
};
const commandIdentityJson = (value: unknown): string => {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Reflect.get(value, 'type') === 'npcPossessGeneral'
) {
const { acceptedGameAt: _acceptedGameAt, ...identity } = value as Record<string, unknown>;
return stableJson(identity);
}
return stableJson(value);
};
export class ConflictingTurnDaemonCommandError extends Error {
constructor(readonly requestId: string) {
@@ -57,6 +70,9 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
async sendCommand(command: TurnDaemonCommand): Promise<string> {
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
if (durableCommand.type === 'npcPossessGeneral') {
delete durableCommand.acceptedGameAt;
}
if (command.type === 'npcPossessGeneral') {
const existing = await this.db.inputEvent.findUnique({
where: { requestId },
@@ -65,7 +81,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
if (existing) {
if (
existing.eventType !== command.type ||
stableJson(existing.payload) !== stableJson(durableCommand)
commandIdentityJson(existing.payload) !== commandIdentityJson(durableCommand)
) {
throw new ConflictingTurnDaemonCommandError(requestId);
}
@@ -82,11 +98,12 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${command.userId}`}, 1))`
);
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
const token = await transaction.npcSelectionToken.findFirst({
where: {
ownerUserId: command.userId,
nonce: command.tokenNonce,
validUntil: { gte: acceptedAt },
validUntil: { gte: acceptedGameAt },
},
select: { pickResult: true },
});
@@ -101,7 +118,11 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
) {
return '선택한 장수가 목록에 없습니다.';
}
await this.createInputEvent(transaction, durableCommand, requestId, acceptedAt);
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = {
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
acceptedGameAt: acceptedGameAt.toISOString(),
};
await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt);
return null;
});
if (rejectionReason) {
@@ -123,7 +144,10 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
where: { requestId },
select: { eventType: true, payload: true },
});
if (existing.eventType !== command.type || stableJson(existing.payload) !== stableJson(durableCommand)) {
if (
existing.eventType !== command.type ||
commandIdentityJson(existing.payload) !== commandIdentityJson(durableCommand)
) {
throw new ConflictingTurnDaemonCommandError(requestId);
}
}
@@ -524,10 +524,12 @@ liveDescribe('auction worker durable recovery', () => {
expect(reopened).toMatchObject({ status: 'OPEN' });
expect(reopened!.closeAt.getTime()).toBeGreaterThan(extensionAuction.closeAt.getTime());
const secondCloseAt = new Date(Date.now() - 1_000);
const secondWallNow = new Date();
const secondCloseAt = new Date(secondWallNow.getTime() - 1_000);
const secondCloseTick = world.dateToGameTick(secondCloseAt);
await connector.prisma.auction.update({
where: { id: extensionAuction.id },
data: { closeAt: secondCloseAt },
data: { closeAt: secondCloseAt, closeTick: BigInt(secondCloseTick) },
});
const secondExtensionRequestId = requestIdFor({ id: extensionAuction.id, closeAt: secondCloseAt });
await processDueAuctionId({
@@ -536,7 +538,8 @@ liveDescribe('auction worker durable recovery', () => {
timerKey: 'timer',
historyKey: 'history',
id: String(extensionAuction.id),
nowMs: Date.now(),
nowMs: world.getGameNow(secondWallNow).getTime(),
nowTick: world.dateToGameTick(secondWallNow),
});
for (let attempt = 0; attempt < 200; attempt += 1) {
@@ -601,6 +604,10 @@ liveDescribe('auction worker durable recovery', () => {
{ timeout: 15_000 },
async () => {
const poisonedAuction = await createAuction('OPEN');
await connector.prisma.auction.update({
where: { id: poisonedAuction.id },
data: { closeTick: 0n },
});
const poisonedRequestId = requestIdFor(poisonedAuction);
await connector.prisma.inputEvent.create({
data: {
+49
View File
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra';
import { processDueAuctionId } from '../src/auction/worker.js';
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
const buildRedis = () => ({
zRangeByScore: vi.fn(async () => []),
@@ -52,6 +53,34 @@ const buildDb = (options: {
};
describe('auction worker clock-shift race', () => {
it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => {
const now = new Date('2026-07-30T12:00:00.000Z');
const time = {
now,
tick: 36_000_000,
mode: 'manual' as const,
dateToTick: () => 72_000_000,
};
const closeAt = new Date('2099-01-01T00:00:00.000Z');
expect(
resolveAuctionSeedScore(time, {
id: 7,
status: 'OPEN',
closeAt,
closeTick: 72_000_000n,
})
).toBe(72_000_000);
expect(
resolveAuctionSeedScore(time, {
id: 7,
status: 'FINALIZING',
closeAt,
closeTick: 72_000_000n,
})
).toBe(36_000_000);
});
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');
@@ -125,6 +154,26 @@ describe('auction worker clock-shift race', () => {
});
});
it('records operational history time separately from logical settlement time', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
const { db } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } });
const logicalNowMs = new Date('0190-01-01T00:00:00.000Z').getTime();
const operationalNowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
await processDueAuctionId({
db,
redis,
timerKey: 'timer',
historyKey: 'history',
id: '7',
nowMs: logicalNowMs,
historyNowMs: operationalNowMs,
});
expect(redis.zAdd).toHaveBeenCalledWith('history', [{ score: operationalNowMs, value: '7' }]);
});
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
@@ -345,7 +345,7 @@ integration('generic general creation through the durable turn daemon', () => {
attempts: 1,
actorUserId: userId,
});
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
const turnGridOffsetSeconds =
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
@@ -321,7 +321,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
attempts: 1,
actorUserId: userId,
});
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
const logs = await db.logEntry.findMany({
where: {
OR: [
@@ -362,7 +362,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
});
}, 45_000);
it('keeps an accepted token through wall-clock expiry until the queued ENGINE event finishes', async () => {
it('keeps a token accepted in logical time until the queued ENGINE event finishes', async () => {
const reservation = await appRouter
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
.join.listPossessCandidates({});
@@ -382,12 +382,17 @@ integration('mode 1 NPC possession through token reservation and the durable dae
).rejects.toMatchObject({ code: 'TIMEOUT' });
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
const acceptedSecond = new Date(Math.floor(event.createdAt.getTime() / 1000) * 1000);
const acceptedGameAt = new Date(
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time'
);
expect(acceptedGameAt.toString()).not.toBe('Invalid Date');
await db.npcSelectionToken.update({
where: { ownerUserId: delayedUserId },
data: { validUntil: acceptedSecond },
data: { validUntil: acceptedGameAt },
});
await db.worldState.updateMany({
data: { clockTick: { increment: 1 } },
});
await new Promise((resolve) => setTimeout(resolve, 1_100));
await appRouter
.createCaller(buildContext('npc-possession-cleanup-token', cleanupAuth))
@@ -434,6 +434,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
}, 30_000);
it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => {
const logicalNowMs = runtime!.world.getGameNow(new Date()).getTime();
const reservation = await appRouter
.createCaller(buildContext('select-pool-other-reserve', otherAuth))
.join.getSelectionPool();
@@ -449,7 +450,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await db.selectPoolEntry.update({
where: { uniqueName: candidate.uniqueName },
data: { reservedUntil: new Date(Date.now() - 60_000) },
data: { reservedUntil: new Date(logicalNowMs - 60_000) },
});
await expect(
appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({
@@ -476,7 +477,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
};
await db.selectPoolEntry.updateMany({
where: { ownerUserId: otherUserId, generalId: null },
data: { reservedUntil: new Date(Date.now() + 60_000) },
data: { reservedUntil: new Date(logicalNowMs + 60_000) },
});
const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId;
const persistedAllocatorBefore = (
+6 -5
View File
@@ -27,6 +27,7 @@ interface AuctionRow {
detail: unknown;
status: AuctionStatus;
closeAt: Date;
latestEventId: string;
}
interface AuctionBidRow {
@@ -101,7 +102,8 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
host_general_id as "hostGeneralId",
detail,
status,
close_at as "closeAt"
close_at as "closeAt",
latest_event_id as "latestEventId"
FROM auction
WHERE id = ${auctionId}
FOR UPDATE
@@ -375,6 +377,8 @@ export const createAuctionBidder = async (options: {
`
);
// 같은 논리 tick의 연속 입찰은 시각이 같으므로, UUID 정렬이 아니라
// 읽어 둔 이벤트 ID를 버전 토큰으로 사용해 경합만 거절한다.
const updated = await tx.$executeRaw(
GamePrisma.sql`
UPDATE auction
@@ -385,10 +389,7 @@ export const createAuctionBidder = async (options: {
updated_at = ${eventAt}
WHERE id = ${command.auctionId}
AND status = 'OPEN'
AND (
latest_event_at < ${eventAt}
OR (latest_event_at = ${eventAt} AND latest_event_id < ${eventId})
)
AND latest_event_id = ${auction.latestEventId}
`
);
@@ -301,6 +301,7 @@ const zNpcPossessGeneral = z
ownerLegacyPenalty: zRecord.optional(),
generalId: z.number().int().positive(),
tokenNonce: z.number().int().nonnegative(),
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
})
.strict();
@@ -460,6 +460,7 @@ export const possessNpcGeneral = async (options: {
acceptedAt: Date;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, generalId, acceptedAt } = options;
// queue 대기 중 만료된 token도 enqueue 시점에는 유효했으므로 저장된 논리 수락 시각으로 다시 검증한다.
const tokenAcceptedAt = truncateToSeconds(acceptedAt);
requireNpcPossessionWorld(worldState);
await lockNpcPossession(db, userId);
@@ -294,7 +294,9 @@ async function handleNpcPossessGeneral(
throw new Error('NPC possession world state is missing.');
}
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
const acceptedAt = command.acceptedGameAt
? new Date(command.acceptedGameAt)
: ctx.world.getGameNow(operationalAcceptedAt);
try {
return {
type: 'npcPossessGeneral',
@@ -563,5 +563,5 @@ describe('NPC 대형 시뮬레이션', () => {
}
throw error;
}
});
}, 30_000);
});
@@ -144,7 +144,7 @@ integration('unification finalization transaction', () => {
{ userId, key: 'tournament', value: 11 },
],
});
const futureCloseAt = new Date(Date.now() + 86_400_000);
const futureCloseAt = new Date('0190-07-02T00:00:00.000Z');
const uniqueAuction = await db.auction.create({
data: {
type: 'UNIQUE_ITEM',
@@ -243,11 +243,15 @@ integration('unification finalization transaction', () => {
where: { generalId_type: { generalId: fixtureId, type: 'inherit_spent_dyn' } },
})
).toMatchObject({ value: 50 });
expect(
(await db.auctionBid.findMany({ where: { auctionId: uniqueAuction.id }, orderBy: { id: 'asc' } })).map(
(bid) => bid.meta
)
).toEqual([
const persistedBids = await db.auctionBid.findMany({
where: { auctionId: uniqueAuction.id },
orderBy: { id: 'asc' },
});
expect(persistedBids.map((bid) => bid.eventAt.toISOString())).toEqual([
'0190-07-01T00:00:00.000Z',
'0190-07-01T00:00:00.000Z',
]);
expect(persistedBids.map((bid) => bid.meta)).toEqual([
expect.objectContaining({ inheritSpentTrackedAmount: 30 }),
expect.objectContaining({ inheritSpentTrackedAmount: 50 }),
]);
+1
View File
@@ -13,6 +13,7 @@ export type JsonArray = GatewayPrisma.JsonArray;
export * from './orchestrator/profileRepository.js';
export * from './orchestrator/gatewayReleaseRepository.js';
export * from './orchestrator/gatewayOrchestrator.js';
export * from './orchestrator/seedProfileDatabase.js';
export * from './orchestrator/workspaceManager.js';
export * from './orchestrator/buildRunner.js';
export * from './orchestrator/processManager.js';
@@ -41,6 +41,7 @@ integration('account icon daily PostgreSQL CAS', () => {
picture: 'old.png',
imageServer: 1,
iconUpdatedAt: new Date('2026-07-30T09:00:00.000Z'),
createdAt: new Date('2026-07-30T09:00:00.000Z'),
},
});
});