Files
core2026/app/game-engine/test/turnDaemonLease.integration.test.ts
T

119 lines
4.7 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
const databaseUrl = process.env.TURN_DAEMON_LEASE_DATABASE_URL ?? process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const profilePrefix = 'integration:turn-lease:';
integration('database turn daemon lease and fencing', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
const leases: DatabaseTurnDaemonLease[] = [];
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await db.turnDaemonLease.deleteMany({
where: { profile: { startsWith: profilePrefix } },
});
});
afterAll(async () => {
await Promise.allSettled(leases.map((lease) => lease.close()));
await db.turnDaemonLease.deleteMany({
where: { profile: { startsWith: profilePrefix } },
});
await disconnect?.();
});
const createLease = async (profile: string, ownerId: string) => {
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, {
profile,
ownerId,
leaseDurationMs: 60_000,
heartbeat: false,
});
leases.push(lease);
return lease;
};
it('allows only one owner to acquire an active profile lease', async () => {
const profile = `${profilePrefix}exclusive`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
const [firstToken, secondToken] = await Promise.all([first.acquire(), second.acquire()]);
expect([firstToken, secondToken].filter(Boolean)).toHaveLength(1);
const row = await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } });
expect(row.fencingEpoch).toBe(1n);
expect(row.ownerId).toBe(firstToken ? 'owner-a' : 'owner-b');
});
it('increments the epoch on expiry takeover and fences the stale owner', async () => {
const profile = `${profilePrefix}takeover`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
const firstToken = await first.acquire();
expect(firstToken?.fencingEpoch).toBe(1n);
await db.turnDaemonLease.update({
where: { profile },
data: { leaseUntil: new Date(Date.now() - 1_000) },
});
const secondToken = await second.acquire();
expect(secondToken).toMatchObject({
profile,
ownerId: 'owner-b',
fencingEpoch: 2n,
});
await expect(first.assertActive()).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
await expect(second.assertActive()).resolves.toBeUndefined();
});
it('rolls back a stale fenced transaction without writing its completion marker', async () => {
const profile = `${profilePrefix}rollback`;
const requestId = `${profilePrefix}stale-write`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
await first.acquire();
await db.turnDaemonLease.update({
where: { profile },
data: { leaseUntil: new Date(Date.now() - 1_000) },
});
await second.acquire();
await expect(
db.$transaction(async (transaction) => {
await first.assertActive(transaction);
await transaction.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'fenced-test',
payload: {} as GamePrisma.InputJsonValue,
},
});
})
).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
expect(await db.inputEvent.findUnique({ where: { requestId } })).toBeNull();
});
it('permits a clean successor after release while fencing a resumed old token', async () => {
const profile = `${profilePrefix}release`;
const first = await createLease(profile, 'owner-a');
const second = await createLease(profile, 'owner-b');
expect((await first.acquire())?.fencingEpoch).toBe(1n);
await first.release();
expect((await second.acquire())?.fencingEpoch).toBe(2n);
await expect(first.assertActive()).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
});
});