fix reserved turn queue concurrency
This commit is contained in:
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { resolveSecretPermission } from '../../shared/secretPermission.js';
|
||||
import { MAX_NATION_TURNS, listNationTurns } from '../../../turns/reservedTurns.js';
|
||||
import { MAX_NATION_TURNS, getNationTurnSnapshot } from '../../../turns/reservedTurns.js';
|
||||
import { assertNationAccess } from '../shared.js';
|
||||
|
||||
export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
|
||||
@@ -56,9 +56,7 @@ export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
|
||||
const chiefLevels = [12, 10, 8, 6, 11, 9, 7, 5];
|
||||
const generalByLevel = new Map(nationGenerals.map((general) => [general.officerLevel, general]));
|
||||
|
||||
const turnsByLevel = await Promise.all(
|
||||
chiefLevels.map((level) => listNationTurns(ctx.db, nation.id, level))
|
||||
);
|
||||
const turnsByLevel = await Promise.all(chiefLevels.map((level) => getNationTurnSnapshot(ctx.db, nation.id, level)));
|
||||
|
||||
const chiefs = chiefLevels.map((level, idx) => {
|
||||
const entry = generalByLevel.get(level);
|
||||
@@ -67,7 +65,8 @@ export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
|
||||
name: entry?.name ?? null,
|
||||
npcState: entry?.npcState ?? null,
|
||||
turnTime: entry?.turnTime ? entry.turnTime.toISOString() : null,
|
||||
turns: turnsByLevel[idx],
|
||||
revision: turnsByLevel[idx]?.revision ?? 0,
|
||||
turns: turnsByLevel[idx]?.turns ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
import {
|
||||
MAX_GENERAL_TURNS,
|
||||
MAX_NATION_TURNS,
|
||||
listGeneralTurns,
|
||||
listNationTurns,
|
||||
ReservedTurnRevisionConflictError,
|
||||
getGeneralTurnSnapshot,
|
||||
getNationTurnSnapshot,
|
||||
setGeneralTurn,
|
||||
setNationTurn,
|
||||
shiftGeneralTurns,
|
||||
@@ -45,6 +46,21 @@ const parseCommandArgs = async (scope: 'general' | 'nation', action: string, arg
|
||||
}
|
||||
};
|
||||
|
||||
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await mutation();
|
||||
} catch (error) {
|
||||
if (error instanceof ReservedTurnRevisionConflictError) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Reserved turn queue changed. Reload and retry.',
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const turnsRouter = router({
|
||||
getCommandTable: authedProcedure
|
||||
.input(
|
||||
@@ -164,7 +180,7 @@ export const turnsRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
await getOwnedGeneral(ctx, input.generalId);
|
||||
|
||||
return listGeneralTurns(ctx.db, input.generalId);
|
||||
return getGeneralTurnSnapshot(ctx.db, input.generalId);
|
||||
}),
|
||||
getNation: authedProcedure
|
||||
.input(
|
||||
@@ -187,7 +203,7 @@ export const turnsRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
return listNationTurns(ctx.db, general.nationId, general.officerLevel);
|
||||
return getNationTurnSnapshot(ctx.db, general.nationId, general.officerLevel);
|
||||
}),
|
||||
setGeneral: authedProcedure
|
||||
.input(
|
||||
@@ -200,33 +216,33 @@ export const turnsRouter = router({
|
||||
.max(MAX_GENERAL_TURNS - 1),
|
||||
action: z.string().min(1),
|
||||
args: z.unknown().optional(),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await getOwnedGeneral(ctx, input.generalId);
|
||||
const args = await parseCommandArgs('general', input.action, input.args);
|
||||
|
||||
const turns = await setGeneralTurn(
|
||||
ctx.db,
|
||||
input.generalId,
|
||||
input.turnIndex,
|
||||
input.action,
|
||||
args
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
|
||||
);
|
||||
return { ok: true, turns };
|
||||
return { ok: true, ...snapshot };
|
||||
}),
|
||||
shiftGeneral: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
amount: buildShiftAmountSchema(MAX_GENERAL_TURNS),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await getOwnedGeneral(ctx, input.generalId);
|
||||
|
||||
const turns = await shiftGeneralTurns(ctx.db, input.generalId, input.amount);
|
||||
return { ok: true, turns };
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
||||
);
|
||||
return { ok: true, ...snapshot };
|
||||
}),
|
||||
setNation: authedProcedure
|
||||
.input(
|
||||
@@ -239,6 +255,7 @@ export const turnsRouter = router({
|
||||
.max(MAX_NATION_TURNS - 1),
|
||||
action: z.string().min(1),
|
||||
args: z.unknown().optional(),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -257,21 +274,25 @@ export const turnsRouter = router({
|
||||
}
|
||||
const args = await parseCommandArgs('nation', input.action, input.args);
|
||||
|
||||
const turns = await setNationTurn(
|
||||
ctx.db,
|
||||
general.nationId,
|
||||
general.officerLevel,
|
||||
input.turnIndex,
|
||||
input.action,
|
||||
args
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
setNationTurn(
|
||||
ctx.db,
|
||||
general.nationId,
|
||||
general.officerLevel,
|
||||
input.turnIndex,
|
||||
input.action,
|
||||
args,
|
||||
input.expectedRevision
|
||||
)
|
||||
);
|
||||
return { ok: true, turns };
|
||||
return { ok: true, ...snapshot };
|
||||
}),
|
||||
shiftNation: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
amount: buildShiftAmountSchema(MAX_NATION_TURNS),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -289,8 +310,16 @@ export const turnsRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const turns = await shiftNationTurns(ctx.db, general.nationId, general.officerLevel, input.amount);
|
||||
return { ok: true, turns };
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
shiftNationTurns(
|
||||
ctx.db,
|
||||
general.nationId,
|
||||
general.officerLevel,
|
||||
input.amount,
|
||||
input.expectedRevision
|
||||
)
|
||||
);
|
||||
return { ok: true, ...snapshot };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -16,6 +16,21 @@ export interface ReservedTurnView {
|
||||
args: InputJsonValue;
|
||||
}
|
||||
|
||||
export interface ReservedTurnSnapshot {
|
||||
revision: number;
|
||||
turns: ReservedTurnView[];
|
||||
}
|
||||
|
||||
export class ReservedTurnRevisionConflictError extends Error {
|
||||
constructor(
|
||||
readonly expectedRevision: number,
|
||||
readonly currentRevision: number
|
||||
) {
|
||||
super(`Reserved turn queue revision conflict: expected ${expectedRevision}, current ${currentRevision}.`);
|
||||
this.name = 'ReservedTurnRevisionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeAction = (action: string | null | undefined): string =>
|
||||
action && action.length > 0 ? action : DEFAULT_TURN_ACTION;
|
||||
|
||||
@@ -111,6 +126,17 @@ export const listGeneralTurns = async (db: DatabaseClient, generalId: number): P
|
||||
return serializeTurnList(turns);
|
||||
};
|
||||
|
||||
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
|
||||
const [turns, revisionRow] = await Promise.all([
|
||||
loadGeneralTurns(db, generalId),
|
||||
db.generalTurnRevision.findUnique({ where: { generalId } }),
|
||||
]);
|
||||
return {
|
||||
revision: revisionRow?.revision ?? 0,
|
||||
turns: serializeTurnList(turns),
|
||||
};
|
||||
};
|
||||
|
||||
export const loadNationTurns = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
@@ -132,31 +158,111 @@ export const listNationTurns = async (
|
||||
return serializeTurnList(turns);
|
||||
};
|
||||
|
||||
export const getNationTurnSnapshot = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
officerLevel: number
|
||||
): Promise<ReservedTurnSnapshot> => {
|
||||
const [turns, revisionRow] = await Promise.all([
|
||||
loadNationTurns(db, nationId, officerLevel),
|
||||
db.nationTurnRevision.findUnique({
|
||||
where: {
|
||||
nationId_officerLevel: {
|
||||
nationId,
|
||||
officerLevel,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
revision: revisionRow?.revision ?? 0,
|
||||
turns: serializeTurnList(turns),
|
||||
};
|
||||
};
|
||||
|
||||
const claimGeneralRevision = async (
|
||||
db: DatabaseClient,
|
||||
generalId: number,
|
||||
expectedRevision: number
|
||||
): Promise<number> => {
|
||||
const nextRevision = expectedRevision + 1;
|
||||
const claimed =
|
||||
expectedRevision === 0
|
||||
? await db.generalTurnRevision.createMany({
|
||||
data: [{ generalId, revision: nextRevision }],
|
||||
skipDuplicates: true,
|
||||
})
|
||||
: await db.generalTurnRevision.updateMany({
|
||||
where: { generalId, revision: expectedRevision },
|
||||
data: { revision: nextRevision },
|
||||
});
|
||||
if (claimed.count === 1) {
|
||||
return nextRevision;
|
||||
}
|
||||
const current = await db.generalTurnRevision.findUnique({ where: { generalId } });
|
||||
throw new ReservedTurnRevisionConflictError(expectedRevision, current?.revision ?? 0);
|
||||
};
|
||||
|
||||
const claimNationRevision = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
officerLevel: number,
|
||||
expectedRevision: number
|
||||
): Promise<number> => {
|
||||
const nextRevision = expectedRevision + 1;
|
||||
const claimed =
|
||||
expectedRevision === 0
|
||||
? await db.nationTurnRevision.createMany({
|
||||
data: [{ nationId, officerLevel, revision: nextRevision }],
|
||||
skipDuplicates: true,
|
||||
})
|
||||
: await db.nationTurnRevision.updateMany({
|
||||
where: { nationId, officerLevel, revision: expectedRevision },
|
||||
data: { revision: nextRevision },
|
||||
});
|
||||
if (claimed.count === 1) {
|
||||
return nextRevision;
|
||||
}
|
||||
const current = await db.nationTurnRevision.findUnique({
|
||||
where: {
|
||||
nationId_officerLevel: {
|
||||
nationId,
|
||||
officerLevel,
|
||||
},
|
||||
},
|
||||
});
|
||||
throw new ReservedTurnRevisionConflictError(expectedRevision, current?.revision ?? 0);
|
||||
};
|
||||
|
||||
export const setGeneralTurn = async (
|
||||
db: DatabaseClient,
|
||||
generalId: number,
|
||||
turnIndex: number,
|
||||
action: string,
|
||||
args: unknown
|
||||
): Promise<ReservedTurnView[]> => {
|
||||
args: unknown,
|
||||
expectedRevision: number
|
||||
): Promise<ReservedTurnSnapshot> => {
|
||||
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
|
||||
const turns = await loadGeneralTurns(db, generalId);
|
||||
turns[turnIndex] = {
|
||||
action: normalizeAction(action),
|
||||
args: normalizeArgs(args),
|
||||
};
|
||||
await persistGeneralTurns(db, generalId, turns);
|
||||
return serializeTurnList(turns);
|
||||
return { revision, turns: serializeTurnList(turns) };
|
||||
};
|
||||
|
||||
export const shiftGeneralTurns = async (
|
||||
db: DatabaseClient,
|
||||
generalId: number,
|
||||
amount: number
|
||||
): Promise<ReservedTurnView[]> => {
|
||||
amount: number,
|
||||
expectedRevision: number
|
||||
): Promise<ReservedTurnSnapshot> => {
|
||||
const revision = await claimGeneralRevision(db, generalId, expectedRevision);
|
||||
const turns = await loadGeneralTurns(db, generalId);
|
||||
const shifted = applyShift(turns, amount);
|
||||
await persistGeneralTurns(db, generalId, shifted);
|
||||
return serializeTurnList(shifted);
|
||||
return { revision, turns: serializeTurnList(shifted) };
|
||||
};
|
||||
|
||||
export const setNationTurn = async (
|
||||
@@ -165,25 +271,29 @@ export const setNationTurn = async (
|
||||
officerLevel: number,
|
||||
turnIndex: number,
|
||||
action: string,
|
||||
args: unknown
|
||||
): Promise<ReservedTurnView[]> => {
|
||||
args: unknown,
|
||||
expectedRevision: number
|
||||
): Promise<ReservedTurnSnapshot> => {
|
||||
const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision);
|
||||
const turns = await loadNationTurns(db, nationId, officerLevel);
|
||||
turns[turnIndex] = {
|
||||
action: normalizeAction(action),
|
||||
args: normalizeArgs(args),
|
||||
};
|
||||
await persistNationTurns(db, nationId, officerLevel, turns);
|
||||
return serializeTurnList(turns);
|
||||
return { revision, turns: serializeTurnList(turns) };
|
||||
};
|
||||
|
||||
export const shiftNationTurns = async (
|
||||
db: DatabaseClient,
|
||||
nationId: number,
|
||||
officerLevel: number,
|
||||
amount: number
|
||||
): Promise<ReservedTurnView[]> => {
|
||||
amount: number,
|
||||
expectedRevision: number
|
||||
): Promise<ReservedTurnSnapshot> => {
|
||||
const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision);
|
||||
const turns = await loadNationTurns(db, nationId, officerLevel);
|
||||
const shifted = applyShift(turns, amount);
|
||||
await persistNationTurns(db, nationId, officerLevel, shifted);
|
||||
return serializeTurnList(shifted);
|
||||
return { revision, turns: serializeTurnList(shifted) };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user