fix: API 입력 이벤트의 원자적 재실행을 보장한다

This commit is contained in:
2026-08-24 16:30:38 +00:00
parent 604e8fdaf2
commit cca2b3d925
9 changed files with 1141 additions and 119 deletions
+270 -57
View File
@@ -1,86 +1,299 @@
import type { GamePrisma } from '@sammo-ts/infra'; import { createHash } from 'node:crypto';
import { GamePrisma, type DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra';
import type { DatabaseClient } from './context.js'; import type { DatabaseClient } from './context.js';
const API_INPUT_PAYLOAD_VERSION = 1 as const;
const BUSINESS_SAVEPOINT = 'api_input_event_business';
export interface ApiInputPayloadIdentity {
version: typeof API_INPUT_PAYLOAD_VERSION;
digest: string;
}
interface LockedInputEvent {
target: 'API' | 'ENGINE';
eventType: string;
payload: GamePrisma.JsonValue;
actorUserId: string | null;
status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED';
result: GamePrisma.JsonValue | null;
attempts: number;
}
type InputEventOutcome<T> =
{ kind: 'executed'; value: T } | { kind: 'replayed'; value: T } | { kind: 'failed'; error: unknown };
type SavepointDatabaseClient = InfraDatabaseClient & {
$executeRawUnsafe(query: string): Promise<number>;
};
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const canonicalJson = (value: unknown): string =>
JSON.stringify(value, (_key, entry: unknown) => {
if (typeof entry === 'bigint') {
return entry.toString();
}
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
return Object.fromEntries(
Object.entries(entry as Record<string, unknown>).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0
)
);
}
return entry;
}) ?? 'null';
const canonicalJsonValue = (value: unknown): GamePrisma.InputJsonValue =>
JSON.parse(canonicalJson(value)) as GamePrisma.InputJsonValue;
export const createApiInputPayloadIdentity = (payload: unknown): ApiInputPayloadIdentity => ({
version: API_INPUT_PAYLOAD_VERSION,
digest: `sha256:${createHash('sha256').update(canonicalJson(payload)).digest('hex')}`,
});
const isLegacyEmptyPayload = (payload: GamePrisma.JsonValue): boolean =>
payload !== null && !Array.isArray(payload) && typeof payload === 'object' && Object.keys(payload).length === 0;
const sameJson = (left: unknown, right: unknown): boolean => canonicalJson(left) === canonicalJson(right);
export class DuplicateInputEventError extends Error { export class DuplicateInputEventError extends Error {
constructor(readonly requestId: string) { constructor(readonly requestId: string) {
super(`Input event ${requestId} was already accepted.`); super(`Input event ${requestId} conflicts with an existing request.`);
this.name = 'DuplicateInputEventError'; this.name = 'DuplicateInputEventError';
} }
} }
const insertPendingIfAbsent = async (
db: DatabaseClient,
options: {
requestId: string;
eventType: string;
actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity;
}
): Promise<void> => {
await db.$executeRaw(
GamePrisma.sql`
INSERT INTO input_event (
request_id,
target,
event_type,
payload,
actor_user_id,
status,
attempts,
created_at
)
VALUES (
${options.requestId},
'API'::"InputEventTarget",
${options.eventType},
CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
${options.actorUserId},
'PENDING'::"InputEventStatus",
0,
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT (request_id) DO NOTHING
`
);
};
const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise<LockedInputEvent> => {
const rows = await db.$queryRaw<LockedInputEvent[]>(
GamePrisma.sql`
SELECT
target,
event_type AS "eventType",
payload,
actor_user_id AS "actorUserId",
status,
result,
attempts
FROM input_event
WHERE request_id = ${requestId}
FOR UPDATE
`
);
const row = rows[0];
if (!row) {
throw new Error(`Input event ${requestId} disappeared while being claimed.`);
}
return row;
};
const hasMatchingBaseIdentity = (
row: LockedInputEvent,
options: { eventType: string; actorUserId: string | null }
): boolean => row.target === 'API' && row.eventType === options.eventType && row.actorUserId === options.actorUserId;
const canAdoptLegacyFailedPayload = (
row: LockedInputEvent,
options: { eventType: string; actorUserId: string | null }
): boolean =>
row.status === 'FAILED' &&
row.result === null &&
isLegacyEmptyPayload(row.payload) &&
hasMatchingBaseIdentity(row, options);
const isMatchingIdentity = (
row: LockedInputEvent,
options: {
eventType: string;
actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity;
}
): boolean => hasMatchingBaseIdentity(row, options) && sameJson(row.payload, options.payloadIdentity);
const claimInputEvent = async (
db: DatabaseClient,
requestId: string,
payloadIdentity: ApiInputPayloadIdentity
): Promise<void> => {
await db.inputEvent.update({
where: { requestId },
data: {
payload: asJson(payloadIdentity),
status: 'PROCESSING',
result: GamePrisma.DbNull,
error: null,
attempts: { increment: 1 },
lockedBy: null,
leaseUntil: null,
processingAt: new Date(),
completedAt: null,
},
});
};
const markUnexpectedFailure = async (
db: DatabaseClient,
options: {
requestId: string;
eventType: string;
actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity;
error: unknown;
}
): Promise<void> => {
if (!db.$transaction) return;
const message = options.error instanceof Error ? options.error.message : 'Unknown API input event error.';
try {
await db.$transaction(async (transaction) => {
await insertPendingIfAbsent(transaction, options);
const row = await lockInputEvent(transaction, options.requestId);
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
if (!identityMatches || row.status === 'SUCCEEDED' || row.status === 'PROCESSING') {
// A retry may have committed while the failed caller was unwinding. A
// late failure recorder must never replace its durable success.
return;
}
await transaction.inputEvent.update({
where: { requestId: options.requestId },
data: {
payload: asJson(options.payloadIdentity),
status: 'FAILED',
result: GamePrisma.DbNull,
error: message,
attempts: { increment: 1 },
lockedBy: null,
leaseUntil: null,
processingAt: new Date(),
completedAt: new Date(),
},
});
});
} catch {
// Preserve the transaction failure that the caller actually observed. If
// the database is unavailable, the prior PENDING/FAILED state (or absence
// of a newly rolled-back row) remains safely retryable.
}
};
export const executeInputEvent = async <T>(options: { export const executeInputEvent = async <T>(options: {
db: DatabaseClient; db: DatabaseClient;
requestId: string; requestId: string;
eventType: string; eventType: string;
payload: unknown;
actorUserId?: string | null; actorUserId?: string | null;
execute(db: DatabaseClient): Promise<T>; execute(db: DatabaseClient): Promise<T>;
}): Promise<T> => { }): Promise<T> => {
const { db, requestId, eventType, actorUserId, execute } = options; const { db, requestId, eventType, payload, execute } = options;
const actorUserId = options.actorUserId ?? null;
const payloadIdentity = createApiInputPayloadIdentity(payload);
if (!db.$transaction) { if (!db.$transaction) {
return execute(db); return execute(db);
} }
const processingAt = new Date(); let businessStarted = false;
let outcome: InputEventOutcome<T>;
try { try {
await db.inputEvent.create({ outcome = await db.$transaction(async (transaction) => {
data: { await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
requestId, const row = await lockInputEvent(transaction, requestId);
target: 'API', const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
eventType,
payload: asJson({}),
actorUserId: actorUserId ?? null,
status: 'PROCESSING',
processingAt,
attempts: 1,
},
});
} catch (error) {
const isUniqueConflict =
typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
if (!isUniqueConflict) {
throw error;
}
const claimedRetry = await db.inputEvent.updateMany({
where: { requestId, status: 'FAILED' },
data: {
status: 'PROCESSING',
error: null,
processingAt,
completedAt: null,
attempts: { increment: 1 },
},
});
if (claimedRetry.count === 0) {
throw new DuplicateInputEventError(requestId);
}
}
try { if (row.status === 'SUCCEEDED') {
return await db.$transaction(async (transaction) => { if (!identityMatches) throw new DuplicateInputEventError(requestId);
const result = await execute(transaction); return { kind: 'replayed', value: row.result as T };
await transaction.inputEvent.update({ }
where: { requestId }, // A visible PROCESSING row was committed by the legacy boundary. It
data: { // may still have an active business request and its {} payload cannot
status: 'SUCCEEDED', // prove identity, so automatic reclaim would risk duplicate writes.
result: asJson({ ok: true }), if (row.status === 'PROCESSING') {
completedAt: new Date(), throw new DuplicateInputEventError(requestId);
}, }
}); if (!identityMatches && !canAdoptLegacyFailedPayload(row, { eventType, actorUserId })) {
return result; throw new DuplicateInputEventError(requestId);
}
await claimInputEvent(transaction, requestId, payloadIdentity);
const savepointDb = transaction as SavepointDatabaseClient;
await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`);
businessStarted = true;
try {
const value = await execute(transaction);
const durableResult = canonicalJsonValue(value);
await transaction.inputEvent.update({
where: { requestId },
data: {
status: 'SUCCEEDED',
result: asJson(durableResult),
error: null,
completedAt: new Date(),
},
});
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
return { kind: 'executed', value };
} catch (error) {
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
await transaction.inputEvent.update({
where: { requestId },
data: {
status: 'FAILED',
result: GamePrisma.DbNull,
error: message,
completedAt: new Date(),
},
});
return { kind: 'failed', error };
}
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown API input event error.'; if (businessStarted && !(error instanceof DuplicateInputEventError)) {
await db.inputEvent.update({ await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error });
where: { requestId }, }
data: {
status: 'FAILED',
error: message,
completedAt: new Date(),
},
});
throw error; throw error;
} }
if (outcome.kind === 'failed') {
throw outcome.error;
}
return outcome.value;
}; };
+20 -5
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { initTRPC, TRPCError } from '@trpc/server'; import { initTRPC, TRPCError } from '@trpc/server';
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
import { ChangeJournal } from '@sammo-ts/common'; import { ChangeJournal } from '@sammo-ts/common';
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import { writeReadModelChangeJournal } from '@sammo-ts/infra'; import { writeReadModelChangeJournal } from '@sammo-ts/infra';
@@ -58,19 +59,25 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
return result; return result;
}); });
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) { if (type !== 'mutation' || !ctx.db.$transaction) {
return next(); return next();
} }
const requestId = `${ctx.requestId ?? randomUUID()}:${path}`; const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
const payload = await getRawInput();
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
let journalPersisted = false; let journalPersisted = false;
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
try { try {
const result = await executeInputEvent({ const response = await executeInputEvent({
db: ctx.db, db: ctx.db,
requestId, requestId,
eventType: path, eventType: path,
payload,
actorUserId: ctx.auth?.user.id, actorUserId: ctx.auth?.user.id,
execute: async (transaction) => { execute: async (transaction) => {
const result = await next({ const result = await next({
@@ -85,13 +92,21 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
throw result.error; throw result.error;
} }
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot())); journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot()));
return result; executedResult = result;
return result.data;
}, },
}); });
if (journalPersisted) { if (journalPersisted) {
ctx.readModelOutbox?.wake(); ctx.readModelOutbox?.wake();
} }
return result; if (executedResult) {
return executedResult;
}
return {
marker: middlewareMarker,
ok: true,
data: response,
};
} catch (error) { } catch (error) {
if (error instanceof DuplicateInputEventError) { if (error instanceof DuplicateInputEventError) {
throw new TRPCError({ throw new TRPCError({
+26 -14
View File
@@ -4,6 +4,7 @@ import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { z } from 'zod'; import { z } from 'zod';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import type { DatabaseClient } from '../src/context.js'; import type { DatabaseClient } from '../src/context.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
import { import {
accessAuthedProcedure, accessAuthedProcedure,
@@ -303,13 +304,35 @@ describe('general access tracking', () => {
const transactionClient = { const transactionClient = {
$queryRaw: vi.fn(async (query: unknown) => { $queryRaw: vi.fn(async (query: unknown) => {
const sql = (query as { sql?: string }).sql ?? ''; const sql = (query as { sql?: string }).sql ?? '';
if (sql.includes('FROM input_event')) {
return [
{
target: 'API',
eventType: 'board.writeArticle',
payload: createApiInputPayloadIdentity({ value: 'ok' }),
actorUserId: 'user-7',
status: 'PENDING',
result: null,
attempts: 0,
},
];
}
return sql.includes('read_model_revision') return sql.includes('read_model_revision')
? [{ domain: 'access.general', entityId: 7, revision: 1n, outboxId: 1n }] ? [{ domain: 'access.general', entityId: 7, revision: 1n, outboxId: 1n }]
: [{ id: 41 }]; : [{ id: 41 }];
}), }),
$executeRaw: vi.fn(async () => 1), $executeRaw: vi.fn(async (query: unknown) => {
if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) {
events.push('input-event-create');
}
return 1;
}),
$executeRawUnsafe: vi.fn(async () => 0),
inputEvent: { inputEvent: {
update: vi.fn(async () => ({})), update: vi.fn(async (args: { data: { status: string } }) => {
if (args.data.status === 'FAILED') events.push('input-event-failed');
return {};
}),
}, },
}; };
const db = { const db = {
@@ -338,17 +361,6 @@ describe('general access tracking', () => {
}, },
})), })),
}, },
inputEvent: {
create: vi.fn(async () => {
events.push('input-event-create');
return {};
}),
update: vi.fn(async () => {
events.push('input-event-failed');
return {};
}),
updateMany: vi.fn(async () => ({ count: 0 })),
},
$transaction: vi.fn(async (callback: (client: typeof transactionClient) => Promise<unknown>) => { $transaction: vi.fn(async (callback: (client: typeof transactionClient) => Promise<unknown>) => {
transactionCount += 1; transactionCount += 1;
events.push(transactionCount === 1 ? 'access-transaction' : 'business-transaction'); events.push(transactionCount === 1 ? 'access-transaction' : 'business-transaction');
@@ -385,8 +397,8 @@ describe('general access tracking', () => {
expect(events).toEqual([ expect(events).toEqual([
'input-parse', 'input-parse',
'access-transaction', 'access-transaction',
'input-event-create',
'business-transaction', 'business-transaction',
'input-event-create',
'resolver', 'resolver',
'input-event-failed', 'input-event-failed',
]); ]);
@@ -2,8 +2,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { z } from 'zod'; import { z } from 'zod';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import type { GameApiContext } from '../src/context.js'; import type { DatabaseClient, GameApiContext } from '../src/context.js';
import { DuplicateInputEventError, executeInputEvent } from '../src/inputEventBoundary.js'; import {
createApiInputPayloadIdentity,
DuplicateInputEventError,
executeInputEvent,
} from '../src/inputEventBoundary.js';
import { procedure, router } from '../src/trpc.js'; import { procedure, router } from '../src/trpc.js';
import { import {
ConflictingTurnDaemonCommandError, ConflictingTurnDaemonCommandError,
@@ -74,6 +78,7 @@ integration('API input event boundary', () => {
db, db,
requestId, requestId,
eventType: 'test.success', eventType: 'test.success',
payload: { markerId },
actorUserId: 'user-7', actorUserId: 'user-7',
execute: async (transaction) => { execute: async (transaction) => {
await transaction.inputEvent.create({ await transaction.inputEvent.create({
@@ -94,6 +99,8 @@ integration('API input event boundary', () => {
expect(event).toMatchObject({ expect(event).toMatchObject({
status: 'SUCCEEDED', status: 'SUCCEEDED',
actorUserId: 'user-7', actorUserId: 'user-7',
payload: createApiInputPayloadIdentity({ markerId }),
result: { ok: true },
attempts: 1, attempts: 1,
}); });
expect(event.processingAt).toBeInstanceOf(Date); expect(event.processingAt).toBeInstanceOf(Date);
@@ -168,6 +175,12 @@ integration('API input event boundary', () => {
const outboxes = await db.readModelOutbox.findMany({ select: { payload: true } }); const outboxes = await db.readModelOutbox.findMany({ select: { payload: true } });
expect(outboxes.some(({ payload }) => payloadHasGeneral(payload, journalGeneralIds[1]))).toBe(false); expect(outboxes.some(({ payload }) => payloadHasGeneral(payload, journalGeneralIds[1]))).toBe(false);
expect(wake).not.toHaveBeenCalled(); expect(wake).not.toHaveBeenCalled();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:mutate` } })).toMatchObject({
payload: createApiInputPayloadIdentity({ generalId: journalGeneralIds[1], fail: true }),
status: 'FAILED',
result: null,
attempts: 1,
});
}); });
it('rolls back business writes, records failure, and permits one explicit retry', async () => { it('rolls back business writes, records failure, and permits one explicit retry', async () => {
@@ -178,6 +191,7 @@ integration('API input event boundary', () => {
db, db,
requestId, requestId,
eventType: 'test.failure', eventType: 'test.failure',
payload: { markerId },
execute: async (transaction) => { execute: async (transaction) => {
await transaction.inputEvent.create({ await transaction.inputEvent.create({
data: { data: {
@@ -194,6 +208,8 @@ integration('API input event boundary', () => {
expect(await db.inputEvent.findUnique({ where: { requestId: markerId } })).toBeNull(); expect(await db.inputEvent.findUnique({ where: { requestId: markerId } })).toBeNull();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({ expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'FAILED', status: 'FAILED',
payload: createApiInputPayloadIdentity({ markerId }),
result: null,
attempts: 1, attempts: 1,
error: 'injected transaction failure', error: 'injected transaction failure',
}); });
@@ -202,15 +218,17 @@ integration('API input event boundary', () => {
db, db,
requestId, requestId,
eventType: 'test.failure', eventType: 'test.failure',
payload: { markerId },
execute: async () => ({ ok: true }), execute: async () => ({ ok: true }),
}); });
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({ expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED', status: 'SUCCEEDED',
result: { ok: true },
attempts: 2, attempts: 2,
}); });
}); });
it('rejects a concurrent duplicate idempotency key', async () => { it('serializes an exact concurrent retry and replays the original result without re-executing business', async () => {
const requestId = 'integration:api:duplicate'; const requestId = 'integration:api:duplicate';
let releaseFirst: (() => void) | undefined; let releaseFirst: (() => void) | undefined;
let signalStarted: (() => void) | undefined; let signalStarted: (() => void) | undefined;
@@ -224,25 +242,270 @@ integration('API input event boundary', () => {
db, db,
requestId, requestId,
eventType: 'test.duplicate', eventType: 'test.duplicate',
payload: { value: 7 },
execute: async () => { execute: async () => {
signalStarted?.(); signalStarted?.();
await release; await release;
return { ok: true }; return { ok: true, revision: 17 };
}, },
}); });
await started; await started;
const duplicateExecute = vi.fn(async () => ({ ok: true, revision: 99 }));
const duplicate = executeInputEvent({
db,
requestId,
eventType: 'test.duplicate',
payload: { value: 7 },
execute: duplicateExecute,
});
releaseFirst?.();
await expect(Promise.all([first, duplicate])).resolves.toEqual([
{ ok: true, revision: 17 },
{ ok: true, revision: 17 },
]);
expect(duplicateExecute).not.toHaveBeenCalled();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
payload: createApiInputPayloadIdentity({ value: 7 }),
result: { ok: true, revision: 17 },
status: 'SUCCEEDED',
attempts: 1,
});
});
it('rejects request-id reuse with a changed payload, event type, or actor', async () => {
const requestId = 'integration:api:identity-conflict';
const original = await executeInputEvent({
db,
requestId,
eventType: 'test.identity',
payload: { value: 1, nested: { left: true, right: false } },
actorUserId: 'user-identity',
execute: async () => ({ ok: true, revision: 4 }),
});
expect(original).toEqual({ ok: true, revision: 4 });
const conflicts = [
{
eventType: 'test.identity',
payload: { value: 2, nested: { left: true, right: false } },
actorUserId: 'user-identity',
},
{
eventType: 'test.other-identity',
payload: { value: 1, nested: { left: true, right: false } },
actorUserId: 'user-identity',
},
{
eventType: 'test.identity',
payload: { value: 1, nested: { left: true, right: false } },
actorUserId: 'other-user',
},
];
for (const conflict of conflicts) {
const conflictingExecute = vi.fn(async () => ({ ok: false }));
await expect(
executeInputEvent({ db, requestId, ...conflict, execute: conflictingExecute })
).rejects.toBeInstanceOf(DuplicateInputEventError);
expect(conflictingExecute).not.toHaveBeenCalled();
}
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
eventType: 'test.identity',
actorUserId: 'user-identity',
payload: createApiInputPayloadIdentity({ value: 1, nested: { left: true, right: false } }),
result: { ok: true, revision: 4 },
status: 'SUCCEEDED',
attempts: 1,
});
});
it('reclaims an exact PENDING row under lock and counts one execution attempt', async () => {
const requestId = 'integration:api:pending-reclaim';
const payload = { value: 'pending' };
await db.inputEvent.create({
data: {
requestId,
target: 'API',
eventType: 'test.pending',
payload: { ...createApiInputPayloadIdentity(payload) },
actorUserId: 'pending-user',
status: 'PENDING',
attempts: 3,
},
});
await expect( await expect(
executeInputEvent({ executeInputEvent({
db, db,
requestId, requestId,
eventType: 'test.duplicate', eventType: 'test.pending',
payload,
actorUserId: 'pending-user',
execute: async () => ({ ok: true, attempt: 4 }),
})
).resolves.toEqual({ ok: true, attempt: 4 });
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
result: { ok: true, attempt: 4 },
attempts: 4,
});
});
it('adopts only a matching legacy FAILED placeholder and replaces it with the canonical digest', async () => {
const requestId = 'integration:api:legacy-failed';
const payload = { value: 'legacy-retry' };
await db.inputEvent.create({
data: {
requestId,
target: 'API',
eventType: 'test.legacy-failed',
payload: {},
actorUserId: 'legacy-user',
status: 'FAILED',
attempts: 2,
error: 'legacy failure',
completedAt: new Date(),
},
});
await expect(
executeInputEvent({
db,
requestId,
eventType: 'test.legacy-failed',
payload,
actorUserId: 'legacy-user',
execute: async () => ({ ok: true }), execute: async () => ({ ok: true }),
}) })
).rejects.toBeInstanceOf(DuplicateInputEventError); ).resolves.toEqual({ ok: true });
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
payload: createApiInputPayloadIdentity(payload),
status: 'SUCCEEDED',
result: { ok: true },
error: null,
attempts: 3,
});
});
releaseFirst?.(); it('fails closed on a committed legacy PROCESSING placeholder', async () => {
await first; const requestId = 'integration:api:legacy-processing';
const processingAt = new Date(Date.now() - 60 * 60 * 1_000);
await db.inputEvent.create({
data: {
requestId,
target: 'API',
eventType: 'test.legacy-processing',
payload: {},
actorUserId: 'legacy-user',
status: 'PROCESSING',
attempts: 1,
processingAt,
},
});
const retryExecute = vi.fn(async () => ({ ok: true }));
await expect(
executeInputEvent({
db,
requestId,
eventType: 'test.legacy-processing',
payload: { value: 'cannot-prove-legacy-identity' },
actorUserId: 'legacy-user',
execute: retryExecute,
})
).rejects.toBeInstanceOf(DuplicateInputEventError);
expect(retryExecute).not.toHaveBeenCalled();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
payload: {},
status: 'PROCESSING',
attempts: 1,
processingAt,
});
});
it('commits FAILED before a blocked exact retry and preserves the exact attempt count after success', async () => {
const requestId = 'integration:api:failure-race';
const payload = { value: 'race' };
let releaseFailure: (() => void) | undefined;
let signalStarted: (() => void) | undefined;
const started = new Promise<void>((resolve) => {
signalStarted = resolve;
});
const release = new Promise<void>((resolve) => {
releaseFailure = resolve;
});
const failed = executeInputEvent({
db,
requestId,
eventType: 'test.failure-race',
payload,
execute: async () => {
signalStarted?.();
await release;
throw new Error('first attempt failed');
},
});
await started;
const retry = executeInputEvent({
db,
requestId,
eventType: 'test.failure-race',
payload,
execute: async () => ({ ok: true, attempt: 2 }),
});
releaseFailure?.();
await expect(failed).rejects.toThrow('first attempt failed');
await expect(retry).resolves.toEqual({ ok: true, attempt: 2 });
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
result: { ok: true, attempt: 2 },
error: null,
attempts: 2,
});
});
it('does not let a late unexpected-failure recorder overwrite a transaction that actually committed', async () => {
const requestId = 'integration:api:ambiguous-commit';
const payload = { value: 'committed-before-client-error' };
const ambiguousCommitDb = new Proxy(db, {
get(target, property, receiver) {
if (property !== '$transaction') return Reflect.get(target, property, receiver);
return async (callback: (transaction: DatabaseClient) => Promise<unknown>) => {
await db.$transaction(async (transaction) => callback(transaction));
throw new Error('injected post-commit transport failure');
};
},
}) as unknown as DatabaseClient;
await expect(
executeInputEvent({
db: ambiguousCommitDb,
requestId,
eventType: 'test.ambiguous-commit',
payload,
execute: async () => ({ ok: true, revision: 8 }),
})
).rejects.toThrow('injected post-commit transport failure');
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
result: { ok: true, revision: 8 },
error: null,
attempts: 1,
});
const replayExecute = vi.fn(async () => ({ ok: false }));
await expect(
executeInputEvent({
db,
requestId,
eventType: 'test.ambiguous-commit',
payload,
execute: replayExecute,
})
).resolves.toEqual({ ok: true, revision: 8 });
expect(replayExecute).not.toHaveBeenCalled();
}); });
it('reuses the same engine child event but rejects a changed retry payload', async () => { it('reuses the same engine child event but rejects a changed retry payload', async () => {
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
describe('API input-event payload identity', () => {
it('hashes canonical JSON independently of object key order', () => {
expect(
createApiInputPayloadIdentity({
second: [{ z: true, a: 1 }],
first: 'value',
})
).toEqual(
createApiInputPayloadIdentity({
first: 'value',
second: [{ a: 1, z: true }],
})
);
});
it('distinguishes changed values and array order', () => {
const original = createApiInputPayloadIdentity({ value: 1, items: ['a', 'b'] });
expect(createApiInputPayloadIdentity({ value: 2, items: ['a', 'b'] })).not.toEqual(original);
expect(createApiInputPayloadIdentity({ value: 1, items: ['b', 'a'] })).not.toEqual(original);
});
it('stores only a bounded digest envelope for a large or private payload', () => {
const privatePayload = { dataUrl: `data:image/png;base64,${'A'.repeat(100_000)}`, text: 'private-message' };
const identity = createApiInputPayloadIdentity(privatePayload);
expect(identity).toEqual({
version: 1,
digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/u),
});
expect(JSON.stringify(identity)).not.toContain('private-message');
expect(JSON.stringify(identity).length).toBeLessThan(128);
});
});
+56 -27
View File
@@ -2,46 +2,60 @@ import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod'; import { z } from 'zod';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
import { procedure, router } from '../src/trpc.js'; import { procedure, router } from '../src/trpc.js';
const testRouter = router({ const testRouter = router({
mutate: procedure mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
.input(z.object({ fail: z.boolean().optional().default(false) })) (ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
.mutation(({ ctx, input }) => { ctx.changeJournal?.mark('front.general', 7);
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler'); if (input.fail) throw new Error('injected rollback');
ctx.changeJournal?.mark('front.general', 7); return { ok: true };
if (input.fail) throw new Error('injected rollback'); }),
return { ok: true };
}),
}); });
const createContext = () => { const createContext = (payload: unknown = {}) => {
const order: string[] = []; const order: string[] = [];
const queryRaw = vi.fn(async () => { const queryRaw = vi.fn(async (query: { sql?: string }) => {
if (query.sql?.includes('FROM input_event')) {
order.push('locked');
return [
{
target: 'API',
eventType: 'mutate',
payload: createApiInputPayloadIdentity(payload),
actorUserId: null,
status: 'PENDING',
result: null,
attempts: 0,
},
];
}
order.push('journal'); order.push('journal');
return [{ domain: 'front.general', entityId: 7, revision: 1n, outboxId: 11n }]; return [{ domain: 'front.general', entityId: 7, revision: 1n, outboxId: 11n }];
}); });
const transaction = { const transaction = {
$queryRaw: queryRaw, $queryRaw: queryRaw,
$executeRaw: vi.fn(async () => {
order.push('accepted');
return 1;
}),
$executeRawUnsafe: vi.fn(async (statement: string) => {
if (statement.startsWith('SAVEPOINT ')) order.push('savepoint');
else if (statement.startsWith('ROLLBACK TO ')) order.push('savepoint-rollback');
else if (statement.startsWith('RELEASE ')) order.push('savepoint-release');
return 0;
}),
inputEvent: { inputEvent: {
update: vi.fn(async () => { update: vi.fn(async (args: { data: { status: string } }) => {
order.push('succeeded'); if (args.data.status === 'PROCESSING') order.push('processing');
else if (args.data.status === 'SUCCEEDED') order.push('succeeded');
else if (args.data.status === 'FAILED') order.push('failed');
return {}; return {};
}), }),
}, },
}; };
const db = { const db = {
inputEvent: {
create: vi.fn(async () => {
order.push('accepted');
return {};
}),
updateMany: vi.fn(async () => ({ count: 0 })),
update: vi.fn(async () => {
order.push('failed');
return {};
}),
},
$transaction: vi.fn(async (callback: (db: typeof transaction) => Promise<unknown>) => { $transaction: vi.fn(async (callback: (db: typeof transaction) => Promise<unknown>) => {
order.push('transaction-begin'); order.push('transaction-begin');
try { try {
@@ -73,11 +87,15 @@ describe('API input-event change journal boundary', () => {
await expect(testRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true }); await expect(testRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
expect(fixture.order).toEqual([ expect(fixture.order).toEqual([
'accepted',
'transaction-begin', 'transaction-begin',
'accepted',
'locked',
'processing',
'savepoint',
'handler', 'handler',
'journal', 'journal',
'succeeded', 'succeeded',
'savepoint-release',
'commit', 'commit',
'wake', 'wake',
]); ]);
@@ -86,14 +104,25 @@ describe('API input-event change journal boundary', () => {
}); });
it('rolls back a handler mark without writing or scheduling an outbox row', async () => { it('rolls back a handler mark without writing or scheduling an outbox row', async () => {
const fixture = createContext(); const fixture = createContext({ fail: true });
await expect(testRouter.createCaller(fixture.context).mutate({ fail: true })).rejects.toThrow( await expect(testRouter.createCaller(fixture.context).mutate({ fail: true })).rejects.toThrow(
'injected rollback' 'injected rollback'
); );
expect(fixture.order).toEqual(['accepted', 'transaction-begin', 'handler', 'rollback', 'failed']); expect(fixture.order).toEqual([
expect(fixture.queryRaw).not.toHaveBeenCalled(); 'transaction-begin',
'accepted',
'locked',
'processing',
'savepoint',
'handler',
'savepoint-rollback',
'savepoint-release',
'failed',
'commit',
]);
expect(fixture.queryRaw).toHaveBeenCalledTimes(1);
expect(fixture.redisPublish).not.toHaveBeenCalled(); expect(fixture.redisPublish).not.toHaveBeenCalled();
expect(fixture.wake).not.toHaveBeenCalled(); expect(fixture.wake).not.toHaveBeenCalled();
}); });
+9
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { scopeHttpIdempotencyKey } from '../src/requestId.js'; import { scopeHttpIdempotencyKey } from '../src/requestId.js';
import { scopeApiInputEventRequestId } from '../src/trpc.js';
describe('HTTP idempotency request IDs', () => { describe('HTTP idempotency request IDs', () => {
it('is stable for one principal and isolated across users and profiles', () => { it('is stable for one principal and isolated across users and profiles', () => {
@@ -24,4 +25,12 @@ describe('HTTP idempotency request IDs', () => {
expect(scoped).toMatch(/^http:[0-9a-f]{64}$/u); expect(scoped).toMatch(/^http:[0-9a-f]{64}$/u);
expect(scoped).toHaveLength(69); expect(scoped).toHaveLength(69);
}); });
it('keeps the first call compatible and isolates later calls in a same-path batch', () => {
expect(scopeApiInputEventRequestId('http:base', 'messages.send', 0)).toBe('http:base:messages.send');
expect(scopeApiInputEventRequestId('http:base', 'messages.send', 1)).toBe('http:base:messages.send:batch:1');
expect(scopeApiInputEventRequestId('http:base', 'messages.send', 2)).not.toBe(
scopeApiInputEventRequestId('http:base', 'messages.send', 1)
);
});
}); });
@@ -16,6 +16,7 @@ import {
} from '@sammo-ts/infra'; } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
import { scopeHttpIdempotencyKey } from '../src/requestId.js'; import { scopeHttpIdempotencyKey } from '../src/requestId.js';
import { createGameApiServer } from '../src/server.js'; import { createGameApiServer } from '../src/server.js';
import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js'; import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js';
@@ -46,6 +47,7 @@ const matrixApiEventTypes = [
'messages.send', 'messages.send',
'turns.reserved.setGeneral', 'turns.reserved.setGeneral',
'turns.reserved.setNation', 'turns.reserved.setNation',
'turns.reserved.setNationBulk',
] as const; ] as const;
const fixtureActorUserIds = [userId, noGeneralUserId, sameNationUserId, foreignUserId, ordinaryUserId]; const fixtureActorUserIds = [userId, noGeneralUserId, sameNationUserId, foreignUserId, ordinaryUserId];
const secret = 'security-http-e2e-secret'; const secret = 'security-http-e2e-secret';
@@ -74,6 +76,7 @@ let disconnectDb: (() => Promise<void>) | null = null;
let redis: RedisConnector | null = null; let redis: RedisConnector | null = null;
let accessTokenStore: RedisAccessTokenStore; let accessTokenStore: RedisAccessTokenStore;
let createdFixtureWorld = false; let createdFixtureWorld = false;
let reservationWorldId = fixtureWorldId;
let gatewayStatusServer: HttpServer | null = null; let gatewayStatusServer: HttpServer | null = null;
let receivedGatewayWebPushEvents: Array<{ internalToken: string | null; body: unknown }> = []; let receivedGatewayWebPushEvents: Array<{ internalToken: string | null; body: unknown }> = [];
@@ -285,6 +288,7 @@ const readReservedMutationState = async () => ({
where: { where: {
OR: [ OR: [
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } }, { domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
{ domain: 'general.content', entityId: { in: fixtureGeneralIds } },
{ domain: 'dashboard.global', entityId: 0 }, { domain: 'dashboard.global', entityId: 0 },
], ],
}, },
@@ -420,7 +424,12 @@ const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>>
const expectApiInputEvent = async ( const expectApiInputEvent = async (
idempotencyKey: string, idempotencyKey: string,
procedure: string, procedure: string,
expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED' } | null expected: {
actorUserId: string;
status: 'FAILED' | 'SUCCEEDED';
payload?: unknown;
result?: unknown;
} | null
): Promise<void> => { ): Promise<void> => {
const events = await db.inputEvent.findMany({ const events = await db.inputEvent.findMany({
// The HTTP boundary hashes the raw client key together with profile and // The HTTP boundary hashes the raw client key together with profile and
@@ -455,10 +464,16 @@ const expectApiInputEvent = async (
requestId, requestId,
target: 'API', target: 'API',
eventType: procedure, eventType: procedure,
payload: {}, payload:
expected.payload === undefined
? {
version: 1,
digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/u),
}
: createApiInputPayloadIdentity(expected.payload),
actorUserId: expected.actorUserId, actorUserId: expected.actorUserId,
status: expected.status, status: expected.status,
result: expected.status === 'SUCCEEDED' ? { ok: true } : null, result: expected.status === 'SUCCEEDED' ? (expected.result ?? expect.objectContaining({ ok: true })) : null,
error: expected.status === 'SUCCEEDED' ? null : expect.any(String), error: expected.status === 'SUCCEEDED' ? null : expect.any(String),
attempts: 1, attempts: 1,
lockedBy: null, lockedBy: null,
@@ -523,6 +538,33 @@ const requestReservedNation = (accessToken: string, idempotencyKey: string, targ
idempotencyKey, idempotencyKey,
}); });
const requestReservedNationBulk = (accessToken: string, idempotencyKey: string, targetGeneralId: number) =>
requestTrpc('turns.reserved.setNationBulk', {
method: 'POST',
input: {
generalId: targetGeneralId,
entries: [{ turnList: [0, 1], action: '휴식', args: {} }],
expectedRevision: 0,
},
accessToken,
idempotencyKey,
});
type NationReservationKind = 'single' | 'bulk';
const nationReservationProcedure = (kind: NationReservationKind) =>
kind === 'single' ? 'turns.reserved.setNation' : 'turns.reserved.setNationBulk';
const requestNationReservation = (
kind: NationReservationKind,
accessToken: string,
idempotencyKey: string,
targetGeneralId = generalId
) =>
kind === 'single'
? requestReservedNation(accessToken, idempotencyKey, targetGeneralId)
: requestReservedNationBulk(accessToken, idempotencyKey, targetGeneralId);
const ownershipDenialCases = [ const ownershipDenialCases = [
{ {
label: 'authenticated user without a general', label: 'authenticated user without a general',
@@ -652,6 +694,13 @@ integration('game API security over HTTP transport', () => {
}); });
createdFixtureWorld = true; createdFixtureWorld = true;
} }
const reservationWorlds = await db.worldState.findMany({ select: { id: true } });
if (reservationWorlds.length !== 1 || !reservationWorlds[0]) {
throw new Error(
`security transport fixture requires exactly one world row, got ${reservationWorlds.length}`
);
}
reservationWorldId = reservationWorlds[0].id;
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } }); await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
await db.readModelOutbox.deleteMany(); await db.readModelOutbox.deleteMany();
@@ -683,6 +732,7 @@ integration('game API security over HTTP transport', () => {
where: { where: {
OR: [ OR: [
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } }, { domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
{ domain: 'general.content', entityId: { in: fixtureGeneralIds } },
{ domain: 'dashboard.global', entityId: 0 }, { domain: 'dashboard.global', entityId: 0 },
], ],
}, },
@@ -704,6 +754,14 @@ integration('game API security over HTTP transport', () => {
beforeEach(async () => { beforeEach(async () => {
await deleteMatrixInputEvents(); await deleteMatrixInputEvents();
await db.general.update({
where: { id: generalId },
data: { npcState: 0, meta: {}, penalty: {} },
});
await db.worldState.update({
where: { id: reservationWorldId },
data: { meta: {} },
});
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } }); await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
@@ -715,6 +773,7 @@ integration('game API security over HTTP transport', () => {
where: { where: {
OR: [ OR: [
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } }, { domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
{ domain: 'general.content', entityId: { in: fixtureGeneralIds } },
{ domain: 'dashboard.global', entityId: 0 }, { domain: 'dashboard.global', entityId: 0 },
], ],
}, },
@@ -1051,6 +1110,258 @@ integration('game API security over HTTP transport', () => {
}); });
}); });
it.each(
(['single', 'bulk'] as const).flatMap((kind) =>
[
{ label: 'zero', value: 0 },
{ label: 'false', value: false },
{ label: 'null', value: null },
].map((penalty) => ({ kind, ...penalty }))
)
)(
'rejects $kind nation reservation when noChiefTurnInput is $label without committing queue or journal',
async ({ kind, label, value }) => {
const procedure = nationReservationProcedure(kind);
const idempotencyKey = `${mutationRequestPrefix}nation-penalty-${kind}-${label}`;
const accessToken = await createAccessToken(`matrix-nation-penalty-${kind}-${label}`, {});
await db.general.update({
where: { id: generalId },
data: {
meta: { killturn: 3, marker: 'penalty-preserved' },
penalty: { noChiefTurnInput: value },
},
});
await db.worldState.update({
where: { id: reservationWorldId },
data: { meta: { killturn: 12 } },
});
const result = await requestNationReservation(kind, accessToken, idempotencyKey);
expect(result.response.status).toBe(412);
expect(result.body).toMatchObject({
error: {
message: '수뇌 턴 입력 불가능',
data: { code: 'PRECONDITION_FAILED' },
},
});
expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(0);
expect(
await db.nationTurnRevision.findUnique({
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
})
).toBeNull();
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
meta: { killturn: 3, marker: 'penalty-preserved' },
penalty: { noChiefTurnInput: value },
});
expect(await db.readModelRevision.count()).toBe(0);
expect(await db.readModelOutbox.count()).toBe(0);
await expectApiInputEvent(idempotencyKey, procedure, {
actorUserId: userId,
status: 'FAILED',
});
}
);
it.each([
{ kind: 'single' as const, label: 'single' },
{ kind: 'bulk' as const, label: 'bulk' },
])('commits $label nation queue, JSONB killturn refill, and read-model journal together', async ({ kind }) => {
const procedure = nationReservationProcedure(kind);
const idempotencyKey = `${mutationRequestPrefix}nation-refill-${kind}`;
const accessToken = await createAccessToken(`matrix-nation-refill-${kind}`, {});
await db.general.update({
where: { id: generalId },
data: {
npcState: 0,
meta: { killturn: 3, marker: 'preserved-by-jsonb-set' },
penalty: {},
},
});
await db.worldState.update({
where: { id: reservationWorldId },
data: { meta: { killturn: 12, marker: 'world-preserved' } },
});
const result = await requestNationReservation(kind, accessToken, idempotencyKey);
expect(result.response.status).toBe(200);
expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
expect(
await db.nationTurn.findMany({
where: { nationId: ownerNationId, officerLevel: 12 },
select: { turnIdx: true, actionCode: true, arg: true },
orderBy: { turnIdx: 'asc' },
})
).toEqual(
Array.from({ length: 12 }, (_, turnIdx) => ({
turnIdx,
actionCode: '휴식',
arg: {},
}))
);
expect(
await db.nationTurnRevision.findUniqueOrThrow({
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
})
).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null });
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
npcState: 0,
meta: { killturn: 12, marker: 'preserved-by-jsonb-set' },
});
expect(await db.worldState.findUniqueOrThrow({ where: { id: reservationWorldId } })).toMatchObject({
meta: { killturn: 12, marker: 'world-preserved' },
});
expect(
await db.readModelRevision.findMany({
select: { domain: true, entityId: true, revision: true },
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
})
).toEqual([
{ domain: 'dashboard.global', entityId: 0, revision: 1n },
{ domain: 'general.content', entityId: generalId, revision: 1n },
]);
expect(await db.readModelOutbox.findMany({ select: { payload: true } })).toEqual([
{
payload: {
version: 1,
changes: [
['dashboard.global', 0, '1'],
['general.content', generalId, '1'],
],
},
},
]);
await expectApiInputEvent(idempotencyKey, procedure, {
actorUserId: userId,
status: 'SUCCEEDED',
});
});
it.each([
{
kind: 'single' as const,
label: 'already-higher user killturn',
npcState: 0,
currentKillturn: 20,
},
{
kind: 'bulk' as const,
label: 'NPC actor',
npcState: 2,
currentKillturn: 3,
},
])('keeps $label unchanged while committing its nation queue', async ({ kind, npcState, currentKillturn }) => {
const procedure = nationReservationProcedure(kind);
const idempotencyKey = `${mutationRequestPrefix}nation-refill-noop-${kind}`;
const accessToken = await createAccessToken(`matrix-nation-refill-noop-${kind}`, {});
await db.general.update({
where: { id: generalId },
data: {
npcState,
meta: { killturn: currentKillturn, marker: 'no-op-preserved' },
penalty: {},
},
});
await db.worldState.update({
where: { id: reservationWorldId },
data: { meta: { killturn: 12 } },
});
const result = await requestNationReservation(kind, accessToken, idempotencyKey);
expect(result.response.status).toBe(200);
expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(12);
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
npcState,
meta: { killturn: currentKillturn, marker: 'no-op-preserved' },
});
expect(await db.readModelRevision.count()).toBe(0);
expect(await db.readModelOutbox.count()).toBe(0);
await expectApiInputEvent(idempotencyKey, procedure, {
actorUserId: userId,
status: 'SUCCEEDED',
});
});
it.each([
{ kind: 'single' as const, label: 'single' },
{ kind: 'bulk' as const, label: 'bulk' },
])(
'rolls back $label queue, killturn, and read-model revisions when journal persistence fails',
async ({ kind }) => {
const procedure = nationReservationProcedure(kind);
const idempotencyKey = `${mutationRequestPrefix}nation-refill-rollback-${kind}`;
const accessToken = await createAccessToken(`matrix-nation-refill-rollback-${kind}`, {});
await db.general.update({
where: { id: generalId },
data: {
npcState: 0,
meta: { killturn: 3, marker: 'rollback-preserved' },
penalty: {},
},
});
await db.worldState.update({
where: { id: reservationWorldId },
data: { meta: { killturn: 12 } },
});
await db.$executeRawUnsafe(`
CREATE FUNCTION security_transport_fail_read_model_outbox()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'forced security transport read-model journal failure';
END;
$$
`);
await db.$executeRawUnsafe(`
CREATE TRIGGER security_transport_fail_read_model_outbox
BEFORE INSERT ON read_model_outbox
FOR EACH ROW
EXECUTE FUNCTION security_transport_fail_read_model_outbox()
`);
const result = await (async () => {
try {
return await requestNationReservation(kind, accessToken, idempotencyKey);
} finally {
await db.$executeRawUnsafe(
'DROP TRIGGER IF EXISTS security_transport_fail_read_model_outbox ON read_model_outbox'
);
await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS security_transport_fail_read_model_outbox()');
}
})();
expect(result.response.status).toBe(500);
expect(result.body).toMatchObject({ error: { data: { code: 'INTERNAL_SERVER_ERROR' } } });
expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(0);
expect(
await db.nationTurnRevision.findUnique({
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
})
).toBeNull();
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
npcState: 0,
meta: { killturn: 3, marker: 'rollback-preserved' },
});
expect(await db.readModelRevision.count()).toBe(0);
expect(await db.readModelOutbox.count()).toBe(0);
await expectApiInputEvent(idempotencyKey, procedure, {
actorUserId: userId,
status: 'FAILED',
});
expect(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: resolveScopedApiRequestId(idempotencyKey, procedure, userId) },
select: { error: true },
})
).toEqual({ error: expect.stringContaining('forced security transport read-model journal failure') });
}
);
it('commits an owned general reservation once with an authenticated actor and durable journal', async () => { it('commits an owned general reservation once with an authenticated actor and durable journal', async () => {
const idempotencyKey = `${mutationRequestPrefix}general-success`; const idempotencyKey = `${mutationRequestPrefix}general-success`;
const accessToken = await createAccessToken('matrix-general-success', {}); const accessToken = await createAccessToken('matrix-general-success', {});
@@ -1409,8 +1720,15 @@ integration('game API security over HTTP transport', () => {
expect(await readRealtimeRedisState()).toEqual(redisBefore); expect(await readRealtimeRedisState()).toEqual(redisBefore);
}, 15_000); }, 15_000);
it('commits an owned officer nation reservation and rejects duplicate idempotency replay without a second queue mutation', async () => { it('commits an owned officer nation reservation and replays the durable response without a second queue mutation', async () => {
const idempotencyKey = `${mutationRequestPrefix}nation-success`; const idempotencyKey = `${mutationRequestPrefix}nation-success`;
const inputPayload = {
generalId,
turnIndex: 0,
action: '휴식',
args: {},
expectedRevision: 0,
};
const accessToken = await createAccessToken('matrix-nation-success', {}); const accessToken = await createAccessToken('matrix-nation-success', {});
const databaseBefore = await readReservedMutationState(); const databaseBefore = await readReservedMutationState();
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
@@ -1420,6 +1738,7 @@ integration('game API security over HTTP transport', () => {
const first = await requestReservedNation(accessToken, idempotencyKey, generalId); const first = await requestReservedNation(accessToken, idempotencyKey, generalId);
expect(first.response.status).toBe(200); expect(first.response.status).toBe(200);
expect(first.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } }); expect(first.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
const firstResult = (first.body as { result: { data: unknown } }).result.data;
expect( expect(
await db.nationTurn.findMany({ await db.nationTurn.findMany({
where: { nationId: ownerNationId, officerLevel: 12 }, where: { nationId: ownerNationId, officerLevel: 12 },
@@ -1443,6 +1762,8 @@ integration('game API security over HTTP transport', () => {
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
actorUserId: userId, actorUserId: userId,
status: 'SUCCEEDED', status: 'SUCCEEDED',
payload: inputPayload,
result: firstResult,
}); });
const committed = await readReservedMutationState(); const committed = await readReservedMutationState();
@@ -1551,10 +1872,14 @@ integration('game API security over HTTP transport', () => {
where: { requestId: replayRequestId }, where: { requestId: replayRequestId },
}); });
const replay = await requestReservedNation(accessToken, idempotencyKey, generalId); const replay = await requestReservedNation(accessToken, idempotencyKey, generalId);
expect(replay.response.status).toBe(409); expect(replay.response.status).toBe(200);
expect(replay.body).toMatchObject({ error: { data: { code: 'CONFLICT' } } }); expect(replay.body).toEqual(first.body);
expect(await readReservedMutationState()).toEqual(committed); const replayState = await readReservedMutationState();
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(replayDurableBefore); expect({ ...replayState, generalAccessLogs: committed.generalAccessLogs }).toEqual(committed);
expectSingleActorActivity(replayState.generalAccessLogs);
expect(
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), ['general_access_log'])
).toEqual(withoutDurableTables(replayDurableBefore, ['general_access_log']));
expect(await readRealtimeRedisState()).toEqual(replayRedisBefore); expect(await readRealtimeRedisState()).toEqual(replayRedisBefore);
expect( expect(
await db.inputEvent.findUniqueOrThrow({ await db.inputEvent.findUniqueOrThrow({
@@ -1565,6 +1890,8 @@ integration('game API security over HTTP transport', () => {
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
actorUserId: userId, actorUserId: userId,
status: 'SUCCEEDED', status: 'SUCCEEDED',
payload: inputPayload,
result: firstResult,
}); });
}); });
+117
View File
@@ -0,0 +1,117 @@
# API input-event 재실행·복구 계약
## 목적과 범위
game-api mutation은 HTTP `Idempotency-Key`를 profile·인증 actor와 함께 scope한 base ID,
tRPC procedure path, batch index에서 만든 operation key를 `input_event.request_id`
사용한다. 이 원장은 동일 요청이 네트워크
재시도나 응답 유실 때문에 다시 도착했을 때 업무 mutation을 두 번 실행하지 않고,
이미 성공한 응답을 그대로 재생하기 위한 durable 경계다.
이 계약은 `target = 'API'`인 tRPC mutation에만 적용한다. turn daemon의
`target = 'ENGINE'` 처리와 gameplay 계산·RNG 순서는 바꾸지 않는다.
## 요청 identity와 저장 경계
요청 identity는 다음 네 요소가 모두 같은 경우에만 일치한다.
- scoped request ID
- tRPC procedure path인 `event_type`
- 인증된 서버-side `actor_user_id`
- HTTP JSON decoding 뒤 `getRawInput()`이 돌려준 raw tRPC input의 canonical SHA-256 digest
raw input을 사용하므로 parser가 strip·transform하는 field도 operation identity에는 포함된다.
`payload`에는 원문 대신 다음처럼 고정 크기 identity envelope만 저장한다.
```json
{
"version": 1,
"digest": "sha256:<64 hexadecimal characters>"
}
```
object key 순서는 digest에 영향을 주지 않지만 배열 순서와 값은 영향을 준다.
따라서 `board.uploadImage`의 data URL, 토큰, 자유 입력처럼 크거나 민감할 수 있는
필드를 `input_event`에 한 번 더 영구 복제하지 않는다. digest만으로 원래 입력을
복원하거나 사후 감사할 수는 없다. 입력 원문 보존이 필요한 별도 기능은 목적에 맞는
접근 제어와 retention을 가진 저장소를 사용해야 한다.
성공한 업무의 실제 JSON 응답은 canonical JSON으로 `result`에 저장한다. 정확히 같은
재요청은 업무 code를 다시 호출하지 않고 이 값을 200 응답으로 재생한다. 응답 자체에
개인정보나 큰 payload가 들어갈 수 있으므로 `input_event` DB 접근 권한과 retention은
별도로 제한해야 한다. 이 변경은 result retention 정책을 새로 정하지 않는다.
## transaction과 상태 전이
각 요청은 하나의 PostgreSQL transaction에서 해당 row를 `FOR UPDATE`로 잠근다.
```text
없는 row -> PENDING 삽입 -> PROCESSING(attempts + 1)
-> SAVEPOINT
-> 업무 mutation + read-model journal
-> SUCCEEDED + 실제 result -> COMMIT
-> 업무 오류: ROLLBACK TO SAVEPOINT
-> FAILED + error -> COMMIT -> 오류 반환
```
`PROCESSING` 표시, 업무 mutation, `SUCCEEDED`와 result 저장은 같은 transaction에
있다. process나 DB connection이 commit 전에 사라지면 모두 rollback되어 새로 만든
row는 없어지거나 기존 PENDING/FAILED 상태로 되돌아간다. 업무 오류는 savepoint까지만
rollback해 업무 write를 남기지 않으면서 FAILED와 증가한 attempts를 durable하게
남긴다.
transaction 결과를 client가 받지 못한 ambiguous failure도 고려한다. 별도 실패
기록기는 row lock 아래 현재 상태를 다시 확인하며, 이미 commit된 SUCCEEDED나 다른
실행의 PROCESSING을 FAILED로 덮지 않는다.
상태별 처리 계약은 다음과 같다.
| 기존 상태 | identity 일치 | 처리 |
| ----------------------- | ------------- | ------------------------------------------------------------ |
| `SUCCEEDED` | 예 | 저장된 `result`를 200으로 재생, attempts 불변 |
| `SUCCEEDED` | 아니오 | `CONFLICT`(HTTP 409), 업무 미실행 |
| `PENDING` 또는 `FAILED` | 예 | row lock 아래 claim하고 attempts를 정확히 1 증가한 뒤 재실행 |
| `PENDING` 또는 `FAILED` | 아니오 | `CONFLICT`, 업무 미실행 |
| `PROCESSING` | 무관 | fail-closed `CONFLICT`, 자동 reclaim 금지 |
구버전이 `FAILED`, `payload = {}`, `result IS NULL`로 남긴 row는 event type과 actor가
같을 때만 현재 digest를 최초 1회 채택해 retry할 수 있다. 반면 구버전
`PROCESSING + payload = {}`는 identity와 활성 transaction 종료 여부를 증명할 수
없어 age나 lease를 기준으로 자동 reclaim하지 않는다. 구버전 SUCCEEDED placeholder도
원 응답을 복원할 수 없으므로 현재 digest와 일치하는 replay로 간주하지 않는다.
동일 HTTP batch 안에서 같은 procedure path가 여러 번 호출될 수 있다. index 0은 기존
호환 key인 `<base-request-id>:<path>`를 유지하고, 이후 호출은
`<base-request-id>:<path>:batch:<index>`를 사용해 서로 충돌하지 않게 한다.
## rolling deployment 전 확인
새 binary를 투입하기 전에 구 binary로 들어오는 mutation을 drain하고, 각 profile
schema에서 다음 read-only query 결과가 0인지 확인한다.
```sql
SELECT count(*)
FROM input_event
WHERE target = 'API'
AND status = 'PROCESSING';
```
0이 아니면 새 binary가 해당 row를 자동 복구하도록 두지 않는다. 구 process와 traffic을
먼저 완전히 drain한 뒤, request별 업무 commit 여부를 확인할 수 있는 offline
reconciliation 절차를 별도로 수행한다. 생성 시각이나 processing age만 보고 status를
바꾸거나 요청을 재실행하면 오래 실행 중인 구 transaction과 중복 mutation이 생길 수
있다.
## 검증 위치와 남은 경계
- `app/game-api/test/inputEventBoundary.test.ts`: canonical digest와 원문 비저장
- `app/game-api/test/inputEventBoundary.integration.test.ts`: 실제 PostgreSQL row
lock, replay, conflict, retry/attempts, legacy row, concurrent race와 commit ambiguity
- `app/game-api/test/securityTransport.integration.test.ts`: 실제 HTTP/tRPC 응답 replay,
durable payload identity/result와 업무 DB/Redis side effect 불변
- `app/game-api/test/requestId.test.ts`: 동일 path batch index 분리
현재 frontend가 사용자 동작별 stable idempotency key를 발급·재사용하는 계약은 이
범위에 포함되지 않는다. 따라서 client가 재시도 때 새 base request ID를 만들면 server
원장은 두 요청을 같은 operation으로 묶을 수 없다.