fix: 설문 투표의 API 트랜잭션 교착과 완료 알림 경계 수정

This commit is contained in:
2026-09-06 15:39:30 +00:00
parent 09dfe96ff1
commit 27049dae1f
7 changed files with 194 additions and 25 deletions
+5 -7
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
@@ -102,10 +102,7 @@ export const hasPollEnded = (
time: CurrentGameTime
): boolean =>
Boolean(poll.closed_at) ||
Boolean(
poll.end_at &&
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
);
Boolean(poll.end_at && (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick)));
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null;
@@ -290,7 +287,9 @@ export const voteRouter = router({
userCnt,
};
}),
submitVote: authedProcedure
// 투표·보상은 ENGINE transaction이 소유한다. API가 clock fence를 잡고
// 결과를 기다리면 같은 fence가 필요한 데몬이 진행하지 못한다.
submitVote: engineAuthedProcedure
.input(
z.object({
voteId: z.number().int().positive(),
@@ -366,7 +365,6 @@ export const voteRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
}
ctx.changeJournal?.mark('front.general', general.id);
return { ok: true, wonLottery: rewardResult.awardedUnique };
}),
addComment: authedProcedure
@@ -1,9 +1,15 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector,
tryGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
import type { GameApiContext } from '../src/context.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { appRouter } from '../src/router.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
@@ -79,6 +85,63 @@ integration('vote comment operational timestamp', () => {
await closeDb?.();
});
it('lets a separate ENGINE transaction claim the clock fence while submitVote waits', async () => {
const requestId = 'integration:vote-comment-timestamp:submit';
const engineRequestId = `${requestId}:vote.submitVote:engine:0:voteReward`;
const transport = new DatabaseTurnDaemonTransport(db, 2_000);
const context: Partial<GameApiContext> = {
requestId,
db,
auth,
profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' },
turnDaemon: {
sendCommand: transport.sendCommand.bind(transport),
requestStatus: transport.requestStatus.bind(transport),
requestCommand: async (command) => {
// 실제 DB transport의 durable 접수와 별도 connection의 clock fence를
// 검증한다. 보상 계산 자체는 voteReward suite가 검증한다.
const acceptedId = await transport.sendCommand(command);
expect(acceptedId).toBe(engineRequestId);
await db.$transaction(async (transaction) => {
expect(await tryGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK)).toBe(
true
);
const event = await transaction.inputEvent.findUniqueOrThrow({
where: { requestId: acceptedId },
});
expect(event).toMatchObject({
target: 'ENGINE',
status: 'PENDING',
actorUserId: fixtureUserId,
});
await transaction.inputEvent.update({
where: { requestId: acceptedId },
data: {
status: 'SUCCEEDED',
result: {
type: 'voteReward',
ok: true,
voteId: fixtureId,
generalId: fixtureId,
awardedUnique: false,
},
},
});
});
return transport.requestCommand(command);
},
},
};
const caller = appRouter.createCaller(context as GameApiContext);
await expect(caller.vote.submitVote({ voteId: fixtureId, selection: [0] })).resolves.toEqual({
ok: true,
wonLottery: false,
});
expect(await db.inputEvent.count({ where: { requestId: `${requestId}:vote.submitVote` } })).toBe(0);
expect(await db.inputEvent.count({ where: { requestId: engineRequestId } })).toBe(1);
});
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
SELECT current_setting('TIMEZONE') AS "timeZone"
+35 -1
View File
@@ -216,6 +216,40 @@ const buildContext = (options: {
};
describe('vote router actor and permission boundaries', () => {
it('waits for the ENGINE vote without holding an outer API transaction', async () => {
const fixture = buildContext({ requestId: 'vote-boundary' });
const transaction = vi.fn(async () => {
throw new Error('API transaction blocks the vote ENGINE clock fence');
});
Object.assign(fixture.context.db, { $transaction: transaction });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 'vote-boundary:vote.submitVote:engine:0:voteReward',
userId: 'user-1',
generalId: 7,
})
);
});
it.each([
{ auth: null, code: 'UNAUTHORIZED' },
{ auth: { ...buildAuth(), sanctions: { bannedUntil: '2999-01-01T00:00:00Z' } }, code: 'FORBIDDEN' },
])(
'rejects voting before dispatch when authentication or sanctions disallow access: %j',
async ({ auth, code }) => {
const fixture = buildContext({ auth });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).rejects.toMatchObject({ code });
expect(fixture.requestCommand).not.toHaveBeenCalled();
}
);
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
const now = new Date('2026-07-26T00:00:00Z');
const time = {
@@ -259,7 +293,7 @@ describe('vote router actor and permission boundaries', () => {
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
false
);
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]);
expect(fixture.changeJournal.snapshot()).toEqual([]);
expect(fixture.redisIncr).not.toHaveBeenCalled();
expect(fixture.redisPublish).not.toHaveBeenCalled();
});