refactor(game): refresh committed read models selectively
This commit is contained in:
@@ -351,6 +351,14 @@ export class TurnDaemonLifecycle {
|
||||
await this.resolveNextRunTime();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.hooks?.publishCommandEvents?.(result);
|
||||
} catch (error) {
|
||||
// The command is already durable. Realtime publication is a
|
||||
// best-effort read-model invalidation and must not reject it.
|
||||
this.status.lastError = error instanceof Error ? error.message : 'Unknown command event publication error.';
|
||||
}
|
||||
|
||||
if (this.commandResponder && command.requestId) {
|
||||
await this.commandResponder.publishCommandResult(command.requestId, result);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface TurnDaemonHooks {
|
||||
requestId: string,
|
||||
execute: (context: TurnDaemonCommandExecutionContext) => Promise<TurnDaemonCommandResult>
|
||||
): Promise<TurnDaemonCommandResult>;
|
||||
publishCommandEvents?(result: TurnDaemonCommandResult): Promise<void>;
|
||||
publishEvents?(result: TurnRunResult): Promise<void>;
|
||||
onRunError?(error: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
type LogEntryDraft,
|
||||
type MessageRecordDraft,
|
||||
} from '@sammo-ts/logic';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, type RealtimeReadModelChanges } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import type { InMemoryTurnWorld, TurnWorldChanges } from './inMemoryWorld.js';
|
||||
import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js';
|
||||
import { buildDiplomacyMeta } from '@sammo-ts/logic';
|
||||
import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js';
|
||||
@@ -40,9 +40,85 @@ import { persistYearbookSnapshot } from './yearbookPersistence.js';
|
||||
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const uniqueSortedIds = (values: Iterable<number>): number[] =>
|
||||
[...new Set(values)].filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => left - right);
|
||||
|
||||
export const summarizeRealtimeReadModelChanges = (
|
||||
changes: TurnWorldChanges,
|
||||
reservedTurnChanges?: ReservedTurnChanges
|
||||
): RealtimeReadModelChanges => {
|
||||
const generalIds = uniqueSortedIds([
|
||||
...changes.generals.map((general) => general.id),
|
||||
...changes.createdGenerals.map((general) => general.id),
|
||||
...changes.deletedGenerals,
|
||||
...changes.lifecycleEvents.map((event) => event.generalId),
|
||||
]);
|
||||
const cityIds = uniqueSortedIds(changes.cities.map((city) => city.id));
|
||||
const nationIds = uniqueSortedIds([
|
||||
...changes.nations.map((nation) => nation.id),
|
||||
...changes.createdNations.map((nation) => nation.id),
|
||||
...changes.deletedNations,
|
||||
...changes.deletedNationSnapshots.map((snapshot) => snapshot.nation.id),
|
||||
]);
|
||||
const reservedGeneralIds = uniqueSortedIds(
|
||||
reservedTurnChanges
|
||||
? [
|
||||
...reservedTurnChanges.generalIds,
|
||||
...reservedTurnChanges.generalInitializationIds,
|
||||
...reservedTurnChanges.generalLeaseIds,
|
||||
]
|
||||
: []
|
||||
);
|
||||
const recordGeneralIds = uniqueSortedIds(
|
||||
changes.logs.flatMap((entry) =>
|
||||
entry.scope === LogScope.GENERAL && entry.category === LogCategory.ACTION && entry.generalId
|
||||
? [entry.generalId]
|
||||
: []
|
||||
)
|
||||
);
|
||||
const globalRecordsChanged = changes.logs.some(
|
||||
(entry) =>
|
||||
entry.scope === LogScope.SYSTEM &&
|
||||
(entry.category === LogCategory.SUMMARY || entry.category === LogCategory.ACTION)
|
||||
);
|
||||
const worldHistoryChanged = changes.logs.some(
|
||||
(entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY
|
||||
);
|
||||
const contactsChanged =
|
||||
changes.createdGenerals.length > 0 ||
|
||||
changes.deletedGenerals.length > 0 ||
|
||||
changes.createdNations.length > 0 ||
|
||||
changes.deletedNations.length > 0 ||
|
||||
changes.lifecycleEvents.some((event) => {
|
||||
const after = event.after;
|
||||
const beforePermission = asRecord(event.before.meta).permission;
|
||||
const afterPermission = after ? asRecord(after.meta).permission : undefined;
|
||||
return (
|
||||
!after ||
|
||||
event.before.name !== after.name ||
|
||||
event.before.nationId !== after.nationId ||
|
||||
event.before.officerLevel !== after.officerLevel ||
|
||||
beforePermission !== afterPermission
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
generalIds,
|
||||
cityIds,
|
||||
nationIds,
|
||||
reservedGeneralIds,
|
||||
recordGeneralIds,
|
||||
worldChanged: false,
|
||||
globalRecordsChanged,
|
||||
worldHistoryChanged,
|
||||
contactsChanged,
|
||||
};
|
||||
};
|
||||
|
||||
export const excludeDeletedReservedTurnQueues = (
|
||||
changes: ReservedTurnChanges,
|
||||
deletedGeneralIds: readonly number[],
|
||||
@@ -588,11 +664,12 @@ export const createDatabaseTurnHooks = async (
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
await connector.connect();
|
||||
const prisma = connector.prisma;
|
||||
let committedReadModelChanges: RealtimeReadModelChanges | null = null;
|
||||
|
||||
const persistChanges = async (
|
||||
transaction?: GamePrisma.TransactionClient,
|
||||
commandCompletion?: { requestId: string; result: TurnDaemonCommandResult }
|
||||
): Promise<() => void> => {
|
||||
): Promise<{ acknowledge: () => void; readModelChanges: RealtimeReadModelChanges }> => {
|
||||
const state = world.getState();
|
||||
const changes = world.peekDirtyState();
|
||||
const {
|
||||
@@ -1059,39 +1136,50 @@ export const createDatabaseTurnHooks = async (
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
world.acknowledgeDirtyState(changes);
|
||||
if (options?.reservedTurns && reservedTurnChanges) {
|
||||
options.reservedTurns.acknowledgeDirtyState(reservedTurnChanges);
|
||||
}
|
||||
return {
|
||||
acknowledge: () => {
|
||||
world.acknowledgeDirtyState(changes);
|
||||
if (options?.reservedTurns && reservedTurnChanges) {
|
||||
options.reservedTurns.acknowledgeDirtyState(reservedTurnChanges);
|
||||
}
|
||||
},
|
||||
readModelChanges: summarizeRealtimeReadModelChanges(changes, persistedReservedTurnChanges),
|
||||
};
|
||||
};
|
||||
|
||||
const hooks: TurnDaemonHooks = {
|
||||
flushChanges: async () => {
|
||||
const acknowledge = await persistChanges();
|
||||
acknowledge();
|
||||
const committed = await persistChanges();
|
||||
committed.acknowledge();
|
||||
committedReadModelChanges = committed.readModelChanges;
|
||||
},
|
||||
commitCommand: async (requestId, result) => {
|
||||
const acknowledge = await persistChanges(undefined, { requestId, result });
|
||||
acknowledge();
|
||||
const committed = await persistChanges(undefined, { requestId, result });
|
||||
committed.acknowledge();
|
||||
committedReadModelChanges = committed.readModelChanges;
|
||||
},
|
||||
executeCommand: async (requestId, execute) => {
|
||||
const committed = await prisma.$transaction(
|
||||
async (transaction) => {
|
||||
const result = await execute({ db: transaction });
|
||||
const acknowledge = await persistChanges(transaction, { requestId, result });
|
||||
return { result, acknowledge };
|
||||
const persisted = await persistChanges(transaction, { requestId, result });
|
||||
return { result, persisted };
|
||||
},
|
||||
options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined
|
||||
);
|
||||
committed.acknowledge();
|
||||
committed.persisted.acknowledge();
|
||||
committedReadModelChanges = committed.persisted.readModelChanges;
|
||||
return committed.result;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
hooks,
|
||||
takeCommittedReadModelChanges: () => {
|
||||
const changes = committedReadModelChanges;
|
||||
committedReadModelChanges = null;
|
||||
return changes;
|
||||
},
|
||||
close: () => connector.disconnect(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
|
||||
import { buildGameEventChannel, GameClock, type GameClockMode, type RealtimeEvent } from '@sammo-ts/common';
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
buildGameReadModelDomainRevisionKey,
|
||||
buildGameReadModelRevisionKey,
|
||||
createEmptyRealtimeReadModelChanges,
|
||||
GameClock,
|
||||
hasRealtimeReadModelChanges,
|
||||
type GameClockMode,
|
||||
type RealtimeEvent,
|
||||
type RealtimeReadModelChanges,
|
||||
} from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
|
||||
|
||||
@@ -633,6 +643,8 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
|
||||
let hooks: TurnDaemonHooks | undefined;
|
||||
let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | null = null;
|
||||
let publishReadModelChanges: ((changes: RealtimeReadModelChanges) => Promise<number>) | null = null;
|
||||
let takeCommittedReadModelChanges: (() => RealtimeReadModelChanges | null) | null = null;
|
||||
let close = async () => {};
|
||||
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
|
||||
let auctionBidder: Awaited<ReturnType<typeof createAuctionBidder>> | null = null;
|
||||
@@ -676,6 +688,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
await gatewayGate?.markPaused(error);
|
||||
},
|
||||
};
|
||||
takeCommittedReadModelChanges = dbHooks.takeCommittedReadModelChanges;
|
||||
close = async () => {
|
||||
if (auctionBidder) {
|
||||
await auctionBidder.close();
|
||||
@@ -730,25 +743,66 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
publishRealtimeEvent = async (event: RealtimeEvent) => {
|
||||
await redisClient.publish(realtimeChannel, JSON.stringify(event));
|
||||
};
|
||||
const revisionKey = buildGameReadModelRevisionKey(options.profileName ?? options.profile);
|
||||
const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName ?? options.profile);
|
||||
publishReadModelChanges = async (changes) => {
|
||||
if (changes.worldChanged || changes.cityIds.length > 0 || changes.nationIds.length > 0) {
|
||||
await redisClient.hIncrBy(domainRevisionKey, 'world', 1);
|
||||
}
|
||||
return redisClient.incr(revisionKey);
|
||||
};
|
||||
}
|
||||
|
||||
if (publishRealtimeEvent) {
|
||||
const basePublishEvents = hooks?.publishEvents;
|
||||
// 턴 처리 완료 이벤트를 실시간 채널로 전파한다.
|
||||
const basePublishCommandEvents = hooks?.publishCommandEvents;
|
||||
const publishCommittedChanges = async (changes: RealtimeReadModelChanges): Promise<number | undefined> => {
|
||||
if (!hasRealtimeReadModelChanges(changes)) {
|
||||
return undefined;
|
||||
}
|
||||
return publishReadModelChanges?.(changes);
|
||||
};
|
||||
// Durable mutation summaries invalidate only the affected read models.
|
||||
hooks = {
|
||||
...hooks,
|
||||
publishEvents: async (result) => {
|
||||
try {
|
||||
const changes = takeCommittedReadModelChanges?.() ?? createEmptyRealtimeReadModelChanges();
|
||||
if (result.processedTurns > 0) {
|
||||
changes.worldChanged = true;
|
||||
}
|
||||
const revision = await publishCommittedChanges(changes);
|
||||
await publishRealtimeEvent({
|
||||
type: 'turnCompleted',
|
||||
at: new Date().toISOString(),
|
||||
lastTurnTime: result.lastTurnTime,
|
||||
changes,
|
||||
revision,
|
||||
});
|
||||
} catch {
|
||||
// 실시간 이벤트 전송 실패는 턴 처리 결과에 영향을 주지 않는다.
|
||||
}
|
||||
await basePublishEvents?.(result);
|
||||
},
|
||||
publishCommandEvents: async (result) => {
|
||||
try {
|
||||
const changes = takeCommittedReadModelChanges?.();
|
||||
if (changes && hasRealtimeReadModelChanges(changes)) {
|
||||
const revision = await publishCommittedChanges(changes);
|
||||
if (revision !== undefined) {
|
||||
await publishRealtimeEvent({
|
||||
type: 'readModelChanged',
|
||||
at: new Date().toISOString(),
|
||||
changes,
|
||||
revision,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 명령은 이미 commit되었으므로 이벤트 실패로 되돌리지 않는다.
|
||||
}
|
||||
await basePublishCommandEvents?.(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,9 @@ describe('input event atomicity', () => {
|
||||
commitCommand: async (requestId, committedResult) => {
|
||||
order.push(`commit:${requestId}:${committedResult.type}`);
|
||||
},
|
||||
publishCommandEvents: async (committedResult) => {
|
||||
order.push(`publish:${committedResult.type}`);
|
||||
},
|
||||
},
|
||||
commandResponder: {
|
||||
publishStatus: async () => {},
|
||||
@@ -181,7 +184,12 @@ describe('input event atomicity', () => {
|
||||
const loop = lifecycle.start();
|
||||
await responded;
|
||||
|
||||
expect(order).toEqual(['handle:auctionBid', 'commit:event-1:auctionBid', 'respond:event-1:auctionBid']);
|
||||
expect(order).toEqual([
|
||||
'handle:auctionBid',
|
||||
'commit:event-1:auctionBid',
|
||||
'publish:auctionBid',
|
||||
'respond:event-1:auctionBid',
|
||||
]);
|
||||
|
||||
await lifecycle.stop('done');
|
||||
await loop;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||
|
||||
import { summarizeRealtimeReadModelChanges } from '../src/turn/databaseHooks.js';
|
||||
import type { TurnWorldChanges } from '../src/turn/inMemoryWorld.js';
|
||||
import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
|
||||
|
||||
describe('summarizeRealtimeReadModelChanges', () => {
|
||||
it('emits deterministic entity and record invalidations from committed changes', () => {
|
||||
const worldChanges = {
|
||||
generals: [{ id: 9 }, { id: 7 }],
|
||||
createdGenerals: [{ id: 8 }],
|
||||
deletedGenerals: [9],
|
||||
cities: [{ id: 4 }],
|
||||
nations: [{ id: 3 }],
|
||||
createdNations: [],
|
||||
deletedNations: [],
|
||||
deletedNationSnapshots: [],
|
||||
lifecycleEvents: [],
|
||||
logs: [
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: 7,
|
||||
format: LogFormat.PLAIN,
|
||||
text: 'general',
|
||||
},
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.PLAIN,
|
||||
text: 'summary',
|
||||
},
|
||||
{
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.PLAIN,
|
||||
text: 'history',
|
||||
},
|
||||
],
|
||||
} as unknown as TurnWorldChanges;
|
||||
const reservedChanges: ReservedTurnChanges = {
|
||||
generalIds: [9],
|
||||
generalInitializationIds: [7],
|
||||
generalLeaseIds: [9, 8],
|
||||
nationKeys: [],
|
||||
nationInitializationKeys: [],
|
||||
nationLeaseKeys: [],
|
||||
};
|
||||
|
||||
expect(summarizeRealtimeReadModelChanges(worldChanges, reservedChanges)).toEqual({
|
||||
generalIds: [7, 8, 9],
|
||||
cityIds: [4],
|
||||
nationIds: [3],
|
||||
reservedGeneralIds: [7, 8, 9],
|
||||
recordGeneralIds: [7],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: true,
|
||||
worldHistoryChanged: true,
|
||||
contactsChanged: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user