fix reserved turn daemon api serialization

This commit is contained in:
2026-07-26 19:23:25 +00:00
parent d1e9d5fc75
commit 8f1fe73bad
12 changed files with 1003 additions and 74 deletions
+39 -20
View File
@@ -229,16 +229,25 @@ const claimGeneralRevision = async (
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 },
});
const now = new Date();
let claimed = await db.generalTurnRevision.updateMany({
where: {
generalId,
revision: expectedRevision,
OR: [{ leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
},
data: {
revision: nextRevision,
leaseOwner: null,
leaseExpiresAt: null,
},
});
if (claimed.count === 0 && expectedRevision === 0) {
claimed = await db.generalTurnRevision.createMany({
data: [{ generalId, revision: nextRevision }],
skipDuplicates: true,
});
}
if (claimed.count === 1) {
return nextRevision;
}
@@ -253,16 +262,26 @@ const claimNationRevision = async (
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 },
});
const now = new Date();
let claimed = await db.nationTurnRevision.updateMany({
where: {
nationId,
officerLevel,
revision: expectedRevision,
OR: [{ leaseOwner: null }, { leaseExpiresAt: { lte: now } }],
},
data: {
revision: nextRevision,
leaseOwner: null,
leaseExpiresAt: null,
},
});
if (claimed.count === 0 && expectedRevision === 0) {
claimed = await db.nationTurnRevision.createMany({
data: [{ nationId, officerLevel, revision: nextRevision }],
skipDuplicates: true,
});
}
if (claimed.count === 1) {
return nextRevision;
}
@@ -11,6 +11,7 @@ import {
const databaseUrl = process.env.RESERVED_TURN_DATABASE_URL;
const describeIntegration = databaseUrl ? describe : describe.skip;
const GENERAL_ID = 2_147_400_001;
const REVISION_ZERO_GENERAL_ID = 2_147_400_002;
describeIntegration('reserved turn queue revision integration', () => {
const connector = databaseUrl ? createGamePostgresConnector({ url: databaseUrl }) : null;
@@ -20,16 +21,18 @@ describeIntegration('reserved turn queue revision integration', () => {
return;
}
await connector.connect();
await connector.prisma.generalTurn.deleteMany({ where: { generalId: GENERAL_ID } });
await connector.prisma.generalTurnRevision.deleteMany({ where: { generalId: GENERAL_ID } });
const generalIds = [GENERAL_ID, REVISION_ZERO_GENERAL_ID];
await connector.prisma.generalTurn.deleteMany({ where: { generalId: { in: generalIds } } });
await connector.prisma.generalTurnRevision.deleteMany({ where: { generalId: { in: generalIds } } });
});
afterAll(async () => {
if (!connector) {
return;
}
await connector.prisma.generalTurn.deleteMany({ where: { generalId: GENERAL_ID } });
await connector.prisma.generalTurnRevision.deleteMany({ where: { generalId: GENERAL_ID } });
const generalIds = [GENERAL_ID, REVISION_ZERO_GENERAL_ID];
await connector.prisma.generalTurn.deleteMany({ where: { generalId: { in: generalIds } } });
await connector.prisma.generalTurnRevision.deleteMany({ where: { generalId: { in: generalIds } } });
await connector.disconnect();
});
@@ -60,4 +63,46 @@ describeIntegration('reserved turn queue revision integration', () => {
expect(['che_훈련', 'che_사기진작']).toContain(snapshot.turns[0]?.action);
expect(await connector.prisma.generalTurn.count({ where: { generalId: GENERAL_ID } })).toBe(30);
});
it('uses an unlocked revision-zero lease row as the first API revision', async () => {
if (!connector) {
throw new Error('integration connector is unavailable');
}
await connector.prisma.generalTurnRevision.create({
data: {
generalId: REVISION_ZERO_GENERAL_ID,
revision: 0,
leaseOwner: null,
leaseExpiresAt: null,
},
});
const result = await connector.prisma.$transaction((transaction) =>
setGeneralTurn(
transaction as unknown as DatabaseClient,
REVISION_ZERO_GENERAL_ID,
0,
'che_훈련',
{},
0
)
);
expect(result.revision).toBe(1);
expect(
await connector.prisma.generalTurn.count({
where: { generalId: REVISION_ZERO_GENERAL_ID },
})
).toBe(30);
expect(
await connector.prisma.generalTurnRevision.findUnique({
where: { generalId: REVISION_ZERO_GENERAL_ID },
})
).toMatchObject({
revision: 1,
leaseOwner: null,
leaseExpiresAt: null,
});
});
});
+30 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient, GeneralTurnRow, NationTurnRow } from '../src/context.js';
import {
@@ -338,4 +338,33 @@ describe('reservedTurns', () => {
const noOpPush = await shiftNationTurns(db, 5, 12, 12, repeated.revision);
expect(noOpPush).toEqual(repeated);
});
it('rejects an API writer while the daemon holds the queue lease without touching turns', async () => {
const deleteMany = vi.fn(async () => ({}));
const createMany = vi.fn(async () => ({}));
const db = {
generalTurnRevision: {
updateMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
findUnique: vi.fn(async () => ({
generalId: 9,
revision: 0,
leaseOwner: 'daemon-1',
leaseExpiresAt: new Date(Date.now() + 60_000),
updatedAt: new Date(),
})),
},
generalTurn: {
findMany: vi.fn(async () => []),
deleteMany,
createMany,
},
} as unknown as DatabaseClient;
await expect(setGeneralTurn(db, 9, 0, 'che_훈련', {}, 0)).rejects.toBeInstanceOf(
ReservedTurnRevisionConflictError
);
expect(deleteMany).not.toHaveBeenCalled();
expect(createMany).not.toHaveBeenCalled();
});
});