diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index a02fa42..c739b0d 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -241,12 +241,20 @@ export class InMemoryReservedTurnStore { return new Date(Date.now() + this.leaseDurationMs); } - private async acquireGeneralLease(generalId: number): Promise { + private async acquireGeneralLease(generalId: number): Promise { const revisionStore = this.prisma.generalTurnRevision; if (!revisionStore) { - return; + return false; } const now = new Date(); + const previous = (await revisionStore.findUnique({ where: { generalId } })) as { + leaseOwner: string | null; + leaseExpiresAt: Date | null; + } | null; + const retainedExistingLease = + previous?.leaseOwner === this.leaseOwner && + previous.leaseExpiresAt !== null && + previous.leaseExpiresAt.getTime() > now.getTime(); const leaseExpiresAt = this.getLeaseExpiresAt(); let claimed = await revisionStore.updateMany({ where: { @@ -280,14 +288,22 @@ export class InMemoryReservedTurnStore { throw new ReservedTurnLeaseConflictError(`general:${generalId}`); } this.leasedGeneralIds.add(generalId); + return !retainedExistingLease; } - private async acquireNationLease(nationId: number, officerLevel: number): Promise { + private async acquireNationLease(nationId: number, officerLevel: number): Promise { const revisionStore = this.prisma.nationTurnRevision; if (!revisionStore) { - return; + return false; } const now = new Date(); + const previous = (await revisionStore.findUnique({ + where: { nationId_officerLevel: { nationId, officerLevel } }, + })) as { leaseOwner: string | null; leaseExpiresAt: Date | null } | null; + const retainedExistingLease = + previous?.leaseOwner === this.leaseOwner && + previous.leaseExpiresAt !== null && + previous.leaseExpiresAt.getTime() > now.getTime(); const leaseExpiresAt = this.getLeaseExpiresAt(); let claimed = await revisionStore.updateMany({ where: { @@ -323,6 +339,7 @@ export class InMemoryReservedTurnStore { throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`); } this.leasedNationKeys.add(buildNationKey(nationId, officerLevel)); + return !retainedExistingLease; } private async releaseGeneralLease(generalId: number): Promise { @@ -348,23 +365,32 @@ export class InMemoryReservedTurnStore { const hadGeneralLease = this.leasedGeneralIds.has(generalId); const nationKey = nation ? buildNationKey(nation.nationId, nation.officerLevel) : null; const hadNationLease = nationKey ? this.leasedNationKeys.has(nationKey) : false; + let acquiredFreshGeneralLease = false; + let acquiredFreshNationLease = false; try { - await this.acquireGeneralLease(generalId); + acquiredFreshGeneralLease = await this.acquireGeneralLease(generalId); if (nation) { - await this.acquireNationLease(nation.nationId, nation.officerLevel); + acquiredFreshNationLease = await this.acquireNationLease(nation.nationId, nation.officerLevel); } await Promise.all([ // A newly acquired lease starts a fresh API/daemon ownership boundary. // Re-read PostgreSQL even if a prior run left a stale dirty marker; // repeated access under the same held lease keeps local mutations. - this.refreshGeneralTurns(generalId, !hadGeneralLease), - nation ? this.refreshNationTurns(nation.nationId, nation.officerLevel) : Promise.resolve(), + this.refreshGeneralTurns(generalId, acquiredFreshGeneralLease), + nation + ? this.refreshNationTurns(nation.nationId, nation.officerLevel, acquiredFreshNationLease) + : Promise.resolve(), ]); } catch (error) { - if (nation && nationKey !== null && !hadNationLease && this.leasedNationKeys.has(nationKey)) { + if ( + nation && + nationKey !== null && + (!hadNationLease || acquiredFreshNationLease) && + this.leasedNationKeys.has(nationKey) + ) { await this.releaseNationLease(nation.nationId, nation.officerLevel); } - if (!hadGeneralLease && this.leasedGeneralIds.has(generalId)) { + if ((!hadGeneralLease || acquiredFreshGeneralLease) && this.leasedGeneralIds.has(generalId)) { await this.releaseGeneralLease(generalId); } throw error; @@ -406,9 +432,9 @@ export class InMemoryReservedTurnStore { } } - async refreshNationTurns(nationId: number, officerLevel: number): Promise { + async refreshNationTurns(nationId: number, officerLevel: number, force = false): Promise { const key = buildNationKey(nationId, officerLevel); - if (this.dirtyNationKeys.has(key) || this.pendingNationInitializationKeys.has(key)) { + if (!force && (this.dirtyNationKeys.has(key) || this.pendingNationInitializationKeys.has(key))) { return; } const rows = await this.prisma.nationTurn.findMany({ diff --git a/app/game-engine/test/reservedTurnLease.test.ts b/app/game-engine/test/reservedTurnLease.test.ts index 129f699..8dc8b44 100644 --- a/app/game-engine/test/reservedTurnLease.test.ts +++ b/app/game-engine/test/reservedTurnLease.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { asRecord } from '@sammo-ts/common'; -import { - InMemoryReservedTurnStore, - ReservedTurnLeaseConflictError, -} from '../src/turn/reservedTurnStore.js'; +import { InMemoryReservedTurnStore, ReservedTurnLeaseConflictError } from '../src/turn/reservedTurnStore.js'; interface RevisionRow { revision: number; @@ -202,17 +199,20 @@ describe('reserved turn daemon lease', () => { }); }); - it('refreshes a stale dirty cache after acquiring a fresh lease but preserves mutations under the held lease', async () => { + it('refreshes after a durable flush whose in-memory lease acknowledgement was interrupted', async () => { const harness = buildHarness(); - harness.store.setGeneralTurn(7, 0, { action: '휴식', args: {} }); + await harness.store.prepareTurnsForExecution(7); + harness.store.shiftGeneralTurns(7, -1); + const changes = harness.store.peekDirtyState(); + await harness.store.persistChanges(harness.prisma, changes); + + expect(harness.store.getGeneralTurn(7, 0).action).toBe('휴식'); + expect(harness.store.peekDirtyState()).toMatchObject({ generalIds: [7], generalLeaseIds: [7] }); + expect(harness.getRevision()).toMatchObject({ revision: 1, leaseOwner: null }); await harness.store.prepareTurnsForExecution(7); expect(harness.store.getGeneralTurn(7, 0).action).toBe('che_훈련'); - - harness.store.setGeneralTurn(7, 0, { action: 'che_사기진작', args: {} }); - await harness.store.prepareTurnsForExecution(7); - expect(harness.store.getGeneralTurn(7, 0).action).toBe('che_사기진작'); - expect(harness.generalFindMany).toHaveBeenCalledOnce(); + expect(harness.generalFindMany).toHaveBeenCalledTimes(2); }); it('rejects an active foreign lease before reading the queue', async () => { @@ -222,9 +222,7 @@ describe('reserved turn daemon lease', () => { leaseExpiresAt: new Date(Date.now() + 60_000), }); - await expect(harness.store.prepareTurnsForExecution(7)).rejects.toBeInstanceOf( - ReservedTurnLeaseConflictError - ); + await expect(harness.store.prepareTurnsForExecution(7)).rejects.toBeInstanceOf(ReservedTurnLeaseConflictError); expect(harness.generalFindMany).not.toHaveBeenCalled(); });