perf: NPC 생명주기 메모리 프로파일과 큐 정리를 추가한다

This commit is contained in:
2026-08-24 13:02:19 +00:00
parent 8ef93ecadc
commit f9cb60a50f
15 changed files with 997 additions and 2 deletions
@@ -474,6 +474,12 @@ integration('general turn lifecycle persistence', () => {
events: [],
initialEvents: [],
};
const reservedTurns = new InMemoryReservedTurnStore(db, {
maxGeneralTurns: 3,
maxNationTurns: 3,
});
reservedTurns.ensureGeneralTurns(general.id);
expect(reservedTurns.getQueueCounts().generalQueues).toBe(1);
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: {
@@ -517,7 +523,7 @@ integration('general turn lifecycle persistence', () => {
});
world.executeGeneralTurn(world.getGeneralById(general.id)!);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
try {
await hooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
@@ -529,6 +535,10 @@ integration('general turn lifecycle persistence', () => {
} finally {
await hooks.close();
}
expect(reservedTurns.getQueueCounts()).toMatchObject({
generalQueues: 0,
pendingGeneralInitializations: 0,
});
const archived = await db.oldGeneral.findUniqueOrThrow({
where: { by_no: { serverId, generalNo: general.id } },
@@ -0,0 +1,210 @@
import { performance } from 'node:perf_hooks';
import { serialize } from 'node:v8';
import type { InMemoryTurnWorld } from '../../src/turn/inMemoryWorld.js';
import type {
InMemoryReservedTurnStore,
ReservedTurnQueueCounts,
} from '../../src/turn/reservedTurnStore.js';
export type NpcLifecycleMemoryScenario =
| 'steady-state'
| 'growth'
| 'death-drain'
| 'balanced-churn'
| 'rollback-churn';
export interface ProcessMemorySnapshot {
rssBytes: number;
heapTotalBytes: number;
heapUsedBytes: number;
externalBytes: number;
arrayBuffersBytes: number;
}
export interface NpcLifecycleMemorySample {
cycle: number;
phase: 'initial' | 'in-transaction' | 'post-flush';
elapsedMs: number;
liveGeneralCount: number;
queueCounts: ReservedTurnQueueCounts;
process: ProcessMemorySnapshot;
pending?: {
createdGenerals: number;
deletedGenerals: number;
lifecycleEvents: number;
reservedGeneralQueues: number;
};
snapshot?: {
worldBytes: number;
reservedTurnBytes: number;
totalBytes: number;
cloneAndSerializeMs: number;
heapUsedAfterReleaseBytes: number;
};
}
export const readProcessMemory = (): ProcessMemorySnapshot => {
const usage = process.memoryUsage();
return {
rssBytes: usage.rss,
heapTotalBytes: usage.heapTotal,
heapUsedBytes: usage.heapUsed,
externalBytes: usage.external,
arrayBuffersBytes: usage.arrayBuffers,
};
};
export const linearRegressionSlope = (points: ReadonlyArray<{ x: number; y: number }>): number => {
if (points.length < 2) {
return 0;
}
const meanX = points.reduce((sum, point) => sum + point.x, 0) / points.length;
const meanY = points.reduce((sum, point) => sum + point.y, 0) / points.length;
let numerator = 0;
let denominator = 0;
for (const point of points) {
const xDelta = point.x - meanX;
numerator += xDelta * (point.y - meanY);
denominator += xDelta * xDelta;
}
return denominator === 0 ? 0 : numerator / denominator;
};
export const captureLifecycleMemorySample = (input: {
world: InMemoryTurnWorld;
reservedTurns: InMemoryReservedTurnStore;
startedAtMs: number;
cycle: number;
phase: NpcLifecycleMemorySample['phase'];
includePending: boolean;
includeSnapshot: boolean;
}): NpcLifecycleMemorySample => {
globalThis.gc?.();
const processSnapshot = readProcessMemory();
const pending = input.includePending
? (() => {
const worldChanges = input.world.peekDirtyState();
const reservedChanges = input.reservedTurns.peekDirtyState();
return {
createdGenerals: worldChanges.createdGenerals.length,
deletedGenerals: worldChanges.deletedGenerals.length,
lifecycleEvents: worldChanges.lifecycleEvents.length,
reservedGeneralQueues: reservedChanges.generalIds.length,
};
})()
: undefined;
const sample: NpcLifecycleMemorySample = {
cycle: input.cycle,
phase: input.phase,
elapsedMs: performance.now() - input.startedAtMs,
liveGeneralCount: input.world.getEntityCounts().generals,
queueCounts: input.reservedTurns.getQueueCounts(),
process: processSnapshot,
...(pending ? { pending } : {}),
};
if (input.includeSnapshot) {
const snapshotMetrics = (() => {
const snapshotStartedAt = performance.now();
const worldSnapshot = input.world.captureState();
const reservedSnapshot = input.reservedTurns.captureTransactionState();
const worldBytes = serialize(worldSnapshot).byteLength;
const reservedTurnBytes = serialize(reservedSnapshot).byteLength;
return {
worldBytes,
reservedTurnBytes,
cloneAndSerializeMs: performance.now() - snapshotStartedAt,
};
})();
globalThis.gc?.();
sample.snapshot = {
...snapshotMetrics,
totalBytes: snapshotMetrics.worldBytes + snapshotMetrics.reservedTurnBytes,
heapUsedAfterReleaseBytes: readProcessMemory().heapUsedBytes,
};
}
return sample;
};
const maxValue = (values: readonly number[]): number => Math.max(0, ...values);
export const buildNpcLifecycleMemoryReport = (input: {
scenario: NpcLifecycleMemoryScenario;
pruneDeletedQueues: boolean;
initialGeneralCount: number;
cycles: number;
batchSize: number;
sampleEvery: number;
createdTotal: number;
deletedTotal: number;
rolledBackCycles: number;
startedAtMs: number;
samples: NpcLifecycleMemorySample[];
}) => {
const retained = input.samples.filter(
(sample) => sample.phase === 'initial' || sample.phase === 'post-flush'
);
const warmSampleIndex = Math.floor(retained.length / 3);
const trendSamples = retained.slice(warmSampleIndex);
const first = retained[0];
const final = retained.at(-1);
const heapSlope = linearRegressionSlope(
trendSamples.map((sample) => ({ x: sample.cycle, y: sample.process.heapUsedBytes }))
);
const snapshotSlope = linearRegressionSlope(
trendSamples.flatMap((sample) =>
sample.snapshot ? [{ x: sample.cycle, y: sample.snapshot.totalBytes }] : []
)
);
const queueSlope = linearRegressionSlope(
trendSamples.map((sample) => ({ x: sample.cycle, y: sample.queueCounts.generalQueues }))
);
const lifecycleOperations = input.createdTotal + input.deletedTotal;
return {
schemaVersion: 1,
runtime: {
node: process.version,
platform: process.platform,
arch: process.arch,
explicitGc: typeof globalThis.gc === 'function',
},
scenario: {
name: input.scenario,
pruneDeletedQueues: input.pruneDeletedQueues,
initialGeneralCount: input.initialGeneralCount,
cycles: input.cycles,
batchSize: input.batchSize,
sampleEvery: input.sampleEvery,
},
result: {
createdTotal: input.createdTotal,
deletedTotal: input.deletedTotal,
rolledBackCycles: input.rolledBackCycles,
finalGeneralCount: final?.liveGeneralCount ?? 0,
finalGeneralQueueCount: final?.queueCounts.generalQueues ?? 0,
deadQueueRetentionCount:
(final?.queueCounts.generalQueues ?? 0) - (final?.liveGeneralCount ?? 0),
wallDurationMs: performance.now() - input.startedAtMs,
},
memory: {
retainedHeapStartBytes: first?.process.heapUsedBytes ?? 0,
retainedHeapFinalBytes: final?.process.heapUsedBytes ?? 0,
retainedHeapDeltaBytes:
(final?.process.heapUsedBytes ?? 0) - (first?.process.heapUsedBytes ?? 0),
retainedHeapSlopeBytesPerCycle: heapSlope,
retainedHeapSlopeBytesPerLifecycleOperation:
lifecycleOperations === 0 ? 0 : (heapSlope * input.cycles) / lifecycleOperations,
retainedSnapshotStartBytes: first?.snapshot?.totalBytes ?? 0,
retainedSnapshotFinalBytes: final?.snapshot?.totalBytes ?? 0,
retainedSnapshotDeltaBytes:
(final?.snapshot?.totalBytes ?? 0) - (first?.snapshot?.totalBytes ?? 0),
retainedSnapshotSlopeBytesPerCycle: snapshotSlope,
generalQueueSlopePerCycle: queueSlope,
maxObservedHeapUsedBytes: maxValue(input.samples.map((sample) => sample.process.heapUsedBytes)),
maxObservedRssBytes: maxValue(input.samples.map((sample) => sample.process.rssBytes)),
processResourceMaxRssBytes: process.resourceUsage().maxRSS * 1024,
},
samples: input.samples,
};
};
@@ -0,0 +1,388 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
import { buildScenarioBootstrap, type TurnSchedule } from '@sammo-ts/logic';
import { describe, expect, it } from 'vitest';
import { loadMapDefinitionByName } from '../src/scenario/mapLoader.js';
import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js';
import { loadUnitSetDefinitionByName } from '../src/scenario/unitSetLoader.js';
import { EngineStateManager } from '../src/turn/engineStateManager.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import {
buildNpcLifecycleMemoryReport,
captureLifecycleMemorySample,
type NpcLifecycleMemorySample,
type NpcLifecycleMemoryScenario,
} from './helpers/npcLifecycleMemoryProfiler.js';
const profileEnabled = process.env.NPC_LIFECYCLE_MEMORY_PROFILE === '1';
const profileDescribe = describe.runIf(profileEnabled);
const SCENARIO_ID = 2601;
const HIDDEN_SEED = 'scenario-2601-npc-lifecycle-memory-v1';
const ROLLBACK_SENTINEL = new Error('npc-lifecycle-memory-rollback');
const VALID_SCENARIOS = new Set<NpcLifecycleMemoryScenario>([
'steady-state',
'growth',
'death-drain',
'balanced-churn',
'rollback-churn',
]);
const readPositiveInteger = (name: string, fallback: number): number => {
const raw = process.env[name];
if (raw === undefined) {
return fallback;
}
const value = Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer: ${raw}`);
}
return value;
};
const createGameDate = (year: number, month: number): Date => {
const date = new Date(0);
date.setUTCFullYear(year, month - 1, 1);
date.setUTCHours(0, 0, 0, 0);
return date;
};
const buildTurnGeneral = (
domainGeneral: ReturnType<typeof buildScenarioBootstrap>['snapshot']['generals'][number],
seedGeneral: ReturnType<typeof buildScenarioBootstrap>['seed']['generals'][number],
startTime: Date,
startYear: number,
startMonth: number
): TurnGeneral => {
const deathMonthRaw = seedGeneral.meta.deathMonth;
const deathMonth =
typeof deathMonthRaw === 'number' && Number.isInteger(deathMonthRaw) ? deathMonthRaw : startMonth;
const killturn = Math.max(0, (seedGeneral.deathYear - startYear) * 12 + deathMonth - startMonth);
return {
...domainGeneral,
userId: null,
bornYear: seedGeneral.birthYear,
deadYear: seedGeneral.deathYear,
affinity: seedGeneral.affinity,
picture: seedGeneral.picture === null ? null : String(seedGeneral.picture),
startAge: 20,
turnTime: new Date(startTime),
recentWarTime: null,
lastTurn: { command: '휴식' },
penalty: {},
inheritancePoints: {},
meta: {
...domainGeneral.meta,
...seedGeneral.meta,
killturn,
npcType: seedGeneral.npcType,
crewTypeId: seedGeneral.crewTypeId,
},
};
};
const cloneNpcGeneral = (source: TurnGeneral, id: number): TurnGeneral => {
const cloned = structuredClone(source);
return {
...cloned,
id,
name: `${source.name}#M${id}`,
userId: null,
npcState: Math.max(2, source.npcState),
nationId: 0,
cityId: 0,
troopId: 0,
officerLevel: 0,
meta: {
...cloned.meta,
lifecycleMemoryFixture: true,
},
};
};
const createProfileWorld = async (initialGeneralCount: number) => {
const scenario = await loadScenarioDefinitionById(SCENARIO_ID);
const map = await loadMapDefinitionByName(scenario.config.environment.mapName);
const unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet);
const startYear = scenario.startYear ?? 180;
const startMonth = 1;
const startTime = createGameDate(startYear, startMonth);
const bootstrap = buildScenarioBootstrap({
scenario,
map,
unitSet,
options: {
hiddenSeed: HIDDEN_SEED,
initialYear: startYear,
initialMonth: startMonth,
turnTermMinutes: 10,
includeNeutralNationInSeed: true,
},
});
if (bootstrap.warnings.length > 0) {
throw new Error(`scenario bootstrap warnings: ${bootstrap.warnings.join(', ')}`);
}
const domainGeneralById = new Map(bootstrap.snapshot.generals.map((general) => [general.id, general]));
const scenarioGenerals = bootstrap.seed.generals.map((seedGeneral) => {
const domainGeneral = domainGeneralById.get(seedGeneral.id);
if (!domainGeneral) {
throw new Error(`missing scenario general: ${seedGeneral.id}`);
}
return buildTurnGeneral(domainGeneral, seedGeneral, startTime, startYear, startMonth);
});
const generals = Array.from({ length: initialGeneralCount }, (_, index) =>
cloneNpcGeneral(scenarioGenerals[index % scenarioGenerals.length]!, index + 1)
);
const snapshot: TurnWorldSnapshot = {
scenarioConfig: bootstrap.snapshot.scenarioConfig,
scenarioMeta: bootstrap.snapshot.scenarioMeta,
worldConfig: {
fiction: scenario.fiction,
npcMode: 2,
turnTermMinutes: 10,
tournamentTrig: false,
},
map,
unitSet,
generals,
cities: bootstrap.snapshot.cities,
nations: bootstrap.snapshot.nations,
troops: bootstrap.snapshot.troops,
diplomacy: bootstrap.snapshot.diplomacy.map((entry) => ({
fromNationId: entry.fromNationId,
toNationId: entry.toNationId,
state: entry.state,
term: entry.durationMonths,
dead: 0,
meta: {},
})),
events: [],
initialEvents: [],
};
const state: TurnWorldState = {
id: 1,
currentYear: startYear,
currentMonth: startMonth,
tickSeconds: 600,
lastTurnTime: startTime,
clockBaseTime: startTime,
clockTick: 0,
clockMode: 'manual',
clockWallAnchor: startTime,
lastTurnTick: 0,
meta: {
scenarioId: SCENARIO_ID,
hiddenSeed: HIDDEN_SEED,
killturn: 480,
lastGeneralId: initialGeneralCount,
serverId: 'npc-lifecycle-memory-profile',
},
};
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const reservedTurns = new InMemoryReservedTurnStore({} as never, {
maxGeneralTurns: 30,
maxNationTurns: 12,
leaseOwner: 'npc-lifecycle-memory-profile',
});
for (const general of generals) {
reservedTurns.getGeneralTurns(general.id);
}
return { world, reservedTurns, templateGenerals: scenarioGenerals };
};
profileDescribe('NPC 생성·사망 장기 구동 메모리 프로파일', () => {
it('격리 시나리오의 GC 안정 heap, rollback snapshot과 예약 큐 보유량을 기록한다', async () => {
expect(typeof globalThis.gc).toBe('function');
const rawScenario = process.env.NPC_LIFECYCLE_MEMORY_SCENARIO ?? 'balanced-churn';
if (!VALID_SCENARIOS.has(rawScenario as NpcLifecycleMemoryScenario)) {
throw new Error(`unknown NPC lifecycle memory scenario: ${rawScenario}`);
}
const scenario = rawScenario as NpcLifecycleMemoryScenario;
const cycles = readPositiveInteger('NPC_LIFECYCLE_MEMORY_CYCLES', 80);
const batchSize = readPositiveInteger('NPC_LIFECYCLE_MEMORY_BATCH_SIZE', 100);
const sampleEvery = readPositiveInteger('NPC_LIFECYCLE_MEMORY_SAMPLE_EVERY', 5);
const baseGeneralCount = readPositiveInteger('NPC_LIFECYCLE_MEMORY_BASE_GENERALS', 1_200);
const pruneDeletedQueues = process.env.NPC_LIFECYCLE_MEMORY_PRUNE_DELETED === '1';
const initialGeneralCount =
scenario === 'death-drain' ? baseGeneralCount + cycles * batchSize : baseGeneralCount;
const { world, reservedTurns, templateGenerals } = await createProfileWorld(initialGeneralCount);
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (snapshot) => world.restoreState(snapshot),
});
stateManager.register('reservedTurns', {
capture: () => reservedTurns.captureTransactionState(),
restore: (snapshot) => reservedTurns.restoreState(snapshot),
});
const startedAtMs = performance.now();
const samples: NpcLifecycleMemorySample[] = [
captureLifecycleMemorySample({
world,
reservedTurns,
startedAtMs,
cycle: 0,
phase: 'initial',
includePending: false,
includeSnapshot: true,
}),
];
const activeGeneralIds = world
.listGenerals()
.map((general) => general.id)
.sort((left, right) => left - right);
let nextGeneralId = Math.max(...activeGeneralIds) + 1;
let createdTotal = 0;
let deletedTotal = 0;
let rolledBackCycles = 0;
const addGenerals = (count: number): void => {
for (let index = 0; index < count; index += 1) {
const generalId = nextGeneralId++;
const source = templateGenerals[(generalId - 1) % templateGenerals.length]!;
if (!world.addGeneral(cloneNpcGeneral(source, generalId))) {
throw new Error(`failed to add profile general ${generalId}`);
}
reservedTurns.ensureGeneralTurns(generalId);
activeGeneralIds.push(generalId);
createdTotal += 1;
}
};
const deleteGenerals = (count: number): void => {
const targetIds = activeGeneralIds.splice(0, count);
for (const generalId of targetIds) {
if (!world.deleteGeneralWithLifecycle(generalId, 180, 1)) {
throw new Error(`failed to delete profile general ${generalId}`);
}
deletedTotal += 1;
}
};
for (let cycle = 1; cycle <= cycles; cycle += 1) {
const sampledCycle = cycle % sampleEvery === 0 || cycle === cycles;
try {
await stateManager.transaction(() => {
if (scenario === 'steady-state') {
for (let index = 0; index < batchSize; index += 1) {
const generalId = activeGeneralIds[((cycle - 1) * batchSize + index) % activeGeneralIds.length]!;
const current = world.getGeneralById(generalId);
if (!current) {
throw new Error(`missing steady-state general ${generalId}`);
}
world.updateGeneral(generalId, { experience: current.experience + 1 });
reservedTurns.shiftGeneralTurns(generalId, -1);
}
} else if (scenario === 'growth') {
addGenerals(batchSize);
} else if (scenario === 'death-drain') {
deleteGenerals(batchSize);
} else if (scenario === 'balanced-churn') {
deleteGenerals(batchSize);
addGenerals(batchSize);
} else {
addGenerals(batchSize);
deleteGenerals(batchSize);
}
if (sampledCycle) {
samples.push(
captureLifecycleMemorySample({
world,
reservedTurns,
startedAtMs,
cycle,
phase: 'in-transaction',
includePending: true,
includeSnapshot: false,
})
);
}
if (scenario === 'rollback-churn') {
throw ROLLBACK_SENTINEL;
}
const worldChanges = world.peekDirtyState();
const reservedChanges = reservedTurns.peekDirtyState();
world.acknowledgeDirtyState(worldChanges);
reservedTurns.acknowledgeDirtyState(reservedChanges);
if (pruneDeletedQueues) {
reservedTurns.pruneDeletedEntityQueues(
worldChanges.deletedGenerals,
worldChanges.deletedNations
);
}
});
} catch (error) {
if (scenario !== 'rollback-churn' || error !== ROLLBACK_SENTINEL) {
throw error;
}
rolledBackCycles += 1;
activeGeneralIds.splice(0, activeGeneralIds.length, ...world.listGenerals().map((general) => general.id));
}
if (sampledCycle) {
samples.push(
captureLifecycleMemorySample({
world,
reservedTurns,
startedAtMs,
cycle,
phase: 'post-flush',
includePending: true,
includeSnapshot: true,
})
);
}
}
const report = buildNpcLifecycleMemoryReport({
scenario,
pruneDeletedQueues,
initialGeneralCount,
cycles,
batchSize,
sampleEvery,
createdTotal,
deletedTotal,
rolledBackCycles,
startedAtMs,
samples,
});
const reportPath = resolve(
process.env.NPC_LIFECYCLE_MEMORY_CHILD_REPORT_PATH ??
`test-results/npc-lifecycle-memory-${scenario}.json`
);
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
console.log(
`[NPC_LIFECYCLE_MEMORY_REPORT]${JSON.stringify({
reportPath,
scenario: report.scenario,
result: report.result,
memory: report.memory,
})}`
);
const expectedFinalGeneralCount =
scenario === 'growth'
? initialGeneralCount + cycles * batchSize
: scenario === 'death-drain'
? baseGeneralCount
: initialGeneralCount;
expect(report.result.finalGeneralCount).toBe(expectedFinalGeneralCount);
expect(report.result.rolledBackCycles).toBe(scenario === 'rollback-churn' ? cycles : 0);
if (pruneDeletedQueues || !['death-drain', 'balanced-churn'].includes(scenario)) {
expect(report.result.deadQueueRetentionCount).toBe(0);
} else {
expect(report.result.deadQueueRetentionCount).toBe(cycles * batchSize);
}
expect(world.peekDirtyState().lifecycleEvents).toHaveLength(0);
expect(reservedTurns.peekDirtyState().generalIds).toHaveLength(0);
}, 600_000);
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { linearRegressionSlope } from './helpers/npcLifecycleMemoryProfiler.js';
describe('NPC lifecycle memory profiler metrics', () => {
it('calculates the retained-byte slope from unevenly spaced samples', () => {
expect(
linearRegressionSlope([
{ x: 0, y: 100 },
{ x: 2, y: 140 },
{ x: 5, y: 200 },
])
).toBeCloseTo(20, 8);
});
it('returns zero when a trend cannot be established', () => {
expect(linearRegressionSlope([])).toBe(0);
expect(linearRegressionSlope([{ x: 1, y: 10 }])).toBe(0);
});
});
@@ -169,6 +169,51 @@ const buildHarness = (initialRevision: RevisionRow | null = null) => {
};
describe('reserved turn daemon lease', () => {
it('prunes deleted general and nation queues together with their journal state', () => {
const harness = buildHarness();
harness.store.ensureGeneralTurns(7);
harness.store.ensureGeneralTurns(8);
harness.store.ensureNationTurns(3, 12);
harness.store.ensureNationTurns(4, 12);
expect(harness.store.getQueueCounts()).toMatchObject({
generalQueues: 2,
nationQueues: 2,
pendingGeneralInitializations: 2,
pendingNationInitializations: 2,
});
expect(harness.store.pruneDeletedEntityQueues([7], [3])).toEqual({
generalQueues: 1,
nationQueues: 1,
});
expect(harness.store.getQueueCounts()).toMatchObject({
generalQueues: 1,
nationQueues: 1,
pendingGeneralInitializations: 1,
pendingNationInitializations: 1,
});
expect(harness.store.getGeneralTurns(8)).toHaveLength(2);
expect(harness.store.getNationTurns(4, 12)).toHaveLength(1);
});
it('restores pruned queues from the transaction savepoint', () => {
const harness = buildHarness();
harness.store.ensureGeneralTurns(7);
harness.store.ensureNationTurns(3, 12);
const savepoint = harness.store.captureTransactionState();
harness.store.pruneDeletedEntityQueues([7], [3]);
expect(harness.store.getQueueCounts()).toMatchObject({ generalQueues: 0, nationQueues: 0 });
harness.store.restoreState(savepoint);
expect(harness.store.getQueueCounts()).toMatchObject({
generalQueues: 1,
nationQueues: 1,
pendingGeneralInitializations: 1,
pendingNationInitializations: 1,
});
});
it('holds the queue lease from refresh through shift and releases it with the revision increment', async () => {
const harness = buildHarness();
@@ -10,6 +10,8 @@ const context = {
nations: 3,
troops: 15,
events: 4,
generalTurnQueues: 2461,
nationTurnQueues: 18,
lifecycleState: 'paused',
};
@@ -33,6 +35,7 @@ describe('turn daemon memory reporting', () => {
expect(result.message).toContain('profile=hwe reason=interval');
expect(result.message).toContain('heapLimitMiB=3072');
expect(result.message).toContain('year=214 month=12 generals=2461');
expect(result.message).toContain('generalTurnQueues=2461 nationTurnQueues=18');
expect(result.message).toContain('lifecycle=paused');
});