fix: 설문 투표의 API 트랜잭션 교착과 완료 알림 경계 수정
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -454,8 +454,16 @@ const markIds = (journal: ChangeJournal, domain: ReadModelDomain, ids: readonly
|
||||
* actor-targeted. General name/nation changes affect the global online list,
|
||||
* while frontStatusActorIds is the private actor projection.
|
||||
*/
|
||||
export const createReadModelChangeJournal = (changes: RealtimeReadModelChanges): ChangeJournal => {
|
||||
export const createReadModelChangeJournal = (
|
||||
changes: RealtimeReadModelChanges,
|
||||
commandResult?: TurnDaemonCommandResult
|
||||
): ChangeJournal => {
|
||||
const journal = new ChangeJournal();
|
||||
// 설문 완료 여부는 투표한 actor만의 projection이다. 보상과 같은 ENGINE
|
||||
// transaction에 기록해야 API 응답 실패·재시도에도 commit 뒤 알림이 보존된다.
|
||||
if (commandResult?.type === 'voteReward' && commandResult.ok) {
|
||||
journal.mark('front.general', commandResult.generalId);
|
||||
}
|
||||
markIds(journal, 'general.content', changes.generalIds);
|
||||
markIds(journal, 'city.content', changes.cityIds);
|
||||
markIds(journal, 'nation.content', changes.nationIds);
|
||||
@@ -1981,7 +1989,7 @@ export const createDatabaseTurnHooks = async (
|
||||
if (worldReadModelSignature !== worldReadModelBaseline) {
|
||||
readModelChanges.worldChanged = true;
|
||||
}
|
||||
const journal = createReadModelChangeJournal(readModelChanges);
|
||||
const journal = createReadModelChangeJournal(readModelChanges, commandCompletion?.result);
|
||||
if (hasDashboardSourceMutation(changes, readModelChanges)) {
|
||||
journal.mark('dashboard.global');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommandResult, TurnRunResult } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { LogCategory, LogFormat, LogScope, type MapDefinition, type ScenarioConfig } from '@sammo-ts/logic';
|
||||
|
||||
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||
@@ -137,7 +137,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
||||
await db.$executeRawUnsafe(`
|
||||
ALTER TABLE read_model_outbox
|
||||
ADD CONSTRAINT ${rollbackConstraint}
|
||||
CHECK ((payload->>'version')::integer <> 1)
|
||||
CHECK ((payload->>'version')::integer <> 1) NOT VALID
|
||||
`);
|
||||
world.updateWorldMeta({ durableFixture: 'must-rollback' });
|
||||
world.pushLog({
|
||||
@@ -270,5 +270,41 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
||||
expect(secondQueuedReceipt?.changes.worldChanged).toBe(true);
|
||||
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
|
||||
await expect(db.readModelOutbox.count()).resolves.toBe(5);
|
||||
|
||||
await db.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: { eventType: 'voteReward', status: 'PROCESSING', result: GamePrisma.DbNull },
|
||||
});
|
||||
const voteResult: TurnDaemonCommandResult = {
|
||||
type: 'voteReward',
|
||||
ok: true,
|
||||
voteId: 1,
|
||||
generalId: directLogGeneralId,
|
||||
awardedUnique: false,
|
||||
};
|
||||
await db.$executeRawUnsafe(`
|
||||
ALTER TABLE read_model_outbox ADD CONSTRAINT ${rollbackConstraint}
|
||||
CHECK ((payload->>'version')::integer <> 1) NOT VALID
|
||||
`);
|
||||
await expect(hooks.hooks.executeCommand?.(requestId, async () => voteResult)).rejects.toThrow(
|
||||
rollbackConstraint
|
||||
);
|
||||
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
status: 'PROCESSING',
|
||||
result: null,
|
||||
});
|
||||
await expect(db.readModelOutbox.count()).resolves.toBe(5);
|
||||
|
||||
await db.$executeRawUnsafe(`ALTER TABLE read_model_outbox DROP CONSTRAINT ${rollbackConstraint}`);
|
||||
await hooks.hooks.executeCommand?.(requestId, async () => voteResult);
|
||||
expect(hooks.takeCommittedReadModelChangeReceipt()?.invalidation.revisions).toEqual([
|
||||
{ domain: 'front.general', entityId: directLogGeneralId, revision: 1n },
|
||||
]);
|
||||
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
result: voteResult,
|
||||
});
|
||||
await expect(db.readModelOutbox.count()).resolves.toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,34 @@ import type { TurnWorldChanges } from '../src/turn/inMemoryWorld.js';
|
||||
import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
|
||||
|
||||
describe('durable read-model change journal mapping', () => {
|
||||
it.each([false, true])(
|
||||
'invalidates only the voting actor on successful vote completion (replay=%s)',
|
||||
(alreadyApplied) => {
|
||||
expect(
|
||||
createReadModelChangeJournal(createEmptyRealtimeReadModelChanges(), {
|
||||
type: 'voteReward',
|
||||
ok: true,
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
awardedUnique: false,
|
||||
alreadyApplied,
|
||||
}).snapshot()
|
||||
).toEqual([{ domain: 'front.general', entityId: 7 }]);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not publish vote completion for a rejected vote', () => {
|
||||
expect(
|
||||
createReadModelChangeJournal(createEmptyRealtimeReadModelChanges(), {
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
reason: 'closed',
|
||||
}).snapshot()
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps every final engine invalidation to its precise durable domain', () => {
|
||||
const changes = {
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
|
||||
Reference in New Issue
Block a user