서버가 권위 환경과 반복 seed를 준비하고 공용 logic 프로세서를 브라우저와 기존 서버 fallback이 함께 사용하도록 변경한다. production Chromium에서 고정 seed와 1000회 Node 결과 동등성을 검증한다.
8.9 KiB
game-api 직접 mutation / change journal inventory
범위와 판정 규칙
app/game-api/src/router/**에서 .mutation()으로 선언한 86개 route를 2026-08-16
기준으로 전수 분류한다. 이 목록은 “mutation transport를 사용한다”와 “game DB를
변경한다”를 구분한다. 신규 route가 추가되면
app/game-api/test/directMutationJournalInventory.test.ts가 실패하므로 소유권과
실시간 소비자를 먼저 정해야 한다.
분류 기준은 다음과 같다.
durable journal: API가 성공한 DB mutation의 dashboard/message dependency를 request-localChangeJournal에 표시하고 같은 API transaction에서 revision/outbox를 쓴다.separate access journal: gameplay input-event와 분리된 접속 계측 transaction이access.general을 직접 쓴다. public fan-out은 없다.engine owned: 실제 game mutation은 ENGINEinput_eventtransaction이 소유한다. API에서 같은 표식을 중복 생성하지 않는다.mixed saga: ENGINE DB 변경과 API DB/Redis 변경이 하나의 atomic transaction이 아니다. 현 상태를 atomic journal coverage로 오인하지 않는다.explicit no realtime consumer: 저장값은 바뀌지만 현재 SSE 자동 갱신 consumer가 없다. 존재하지 않는 browser fan-out을 만들지 않는다.Redis projection: 토너먼트의 authoritative state가 현재 Redis이고 PostgreSQL journal과 원자적이지 않다.
Migration 기본 coverageVersion은 계속 0이다. 현재 binary는 ENGINE의 보수적
dashboard.global, API direct writer, engine message mailbox와 모든 tournament Redis
writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
coverage:activate:game one-off가 shared head를 seed하고 version 1을 CAS로 활성화한다.
전수 목록
| 분류 | 수 | route |
|---|---|---|
| durable journal | 22 | betting.bet; messages.delete, messages.respond, messages.send; nation.setBill, nation.setBlockScout, nation.setBlockWar, nation.setNotice, nation.setRate, nation.setScoutMsg, nation.setSecretLimit; npc.setGeneralPriority, npc.setNationPolicy, npc.setNationPriority; turns.repeatGeneral, turns.setGeneral, turns.setGeneralBulk, turns.shiftGeneral; vote.closePoll, vote.createPoll, vote.submitVote, vote.updatePoll |
| separate access journal | 1 | public.recordAccess |
| explicit no realtime consumer | 15 | board.writeArticle, board.writeComment; diplomacy.destroyLetter, diplomacy.respondLetter, diplomacy.rollbackLetter, diplomacy.sendLetter; inherit.checkOwner; join.getSelectionPool, join.listPossessCandidates; messages.readLatest; turns.repeatNation, turns.setNation, turns.setNationBulk, turns.shiftNation; vote.addComment |
| engine owned | 27 | auction.bidBuyRice, auction.bidSellRice, auction.bidUnique, auction.openBuyRice, auction.openSellRice, auction.openUnique; general.adjustIcon, general.buildNationCandidate, general.dieOnPrestart, general.dropItem, general.ensureDieOnPrestartStatus, general.instantRetreat, general.setMySetting, general.vacation; inherit.openUniqueAuction; join.createGeneral, join.possessGeneral, join.reselectPoolGeneral, join.selectPoolGeneral; nation.appoint, nation.changePermission, nation.kick; troop.create, troop.exit, troop.join, troop.kick, troop.rename |
| mixed saga | 9 | inherit.buyHiddenBuff, inherit.buyRandomUnique, inherit.resetSpecialWar, inherit.resetStat, inherit.resetTurnTime, inherit.setNextSpecialWar; tournament.cancel, tournament.join, tournament.placeBet |
| Redis projection | 6 | tournament.patchState, tournament.seedParticipants, tournament.setBettingEntries, tournament.setMatches, tournament.setParticipants, tournament.setState |
| operational | 3 | turnDaemon.pause, turnDaemon.resume, turnDaemon.run |
| external upload | 1 | board.uploadImage |
| read-only mutation transport | 2 | battle.prepareSimulation, battle.simulate |
| session only | 1 | auth.exchangeGatewayToken |
합계는 87개다.
durable journal dependency 매핑
| writer | durable key | public wake-up | 근거와 경계 |
|---|---|---|---|
betting.bet |
general.content:<actor>, betting:0 |
없음 | 본인 베팅/유산 지출과 베팅 aggregate source가 바뀐다. betting은 현재 별도 화면 source이고 main dashboard fan-out을 만들지 않는다. |
messages.send |
생성된 수신/송신 복사본의 messages.mailbox:<mailbox> |
해당 mailbox viewer에게 ID 없는 messagesInvalidated |
기존 pre-commit Redis messageCreated를 제거했다. outbox publish 뒤에도 browser에는 mailbox/message/sender/time/revision이 노출되지 않는다. |
messages.delete |
실제로 만료한 송신/수신 mailbox | 동일 | sender copy만 지우는 수동 외교 메시지는 그 mailbox만 표시한다. |
messages.respond |
영향 mailbox, records.general, 실제 외교 변경 국가의 nation.content, front-state patch 도시의 city.content, 필요 시 map.world, transitive aggregate용 dashboard.global |
mailbox boolean 및 해당 dashboard slice | 실패 로그도 commit되면 actor 개인 기록을 표시한다. 외교 수락이 실제 diplomacy/city/nation dependency를 바꿀 때만 broad source key를 표시한다. |
| nation metadata 7개 및 NPC policy 3개 | nation.content:<nation>, dashboard.global:0; notice만 추가로 front.nation:<nation> |
해당 국가 context/command/board, notice front status | updateNationMeta() 성공 뒤 API 표식을 사용하고, ENGINE 소유 transaction도 nation 변화와 dashboard.global을 함께 기록한다. 두 input-event의 업무 원자성은 기존 saga지만 source coverage는 ENGINE commit으로 보존된다. |
| general reserved turn 4개 | reserved.general:<general>, dashboard.global:0 |
본인 reserved-turn slice | queue row와 CAS revision을 쓴 같은 API transaction에서 표시한다. global key는 troop leader 첫 예약턴에 의존하는 다른 장수 context를 위한 source-only 표식이다. nation reserved turns는 main SSE consumer가 없어 명시적 no-op이다. |
| vote 4개 | 기존 front.general/front.global |
front-status boolean | vote producer 작업에서 pre-commit publish를 journal로 이미 치환했다. 댓글은 active survey 제목을 바꾸지 않아 별도 화면 no-op이다. |
public.recordAccess |
access.general:<general> |
없음 | Ref 순서상 gameplay transaction 밖의 별도 access transaction에 저장한다. |
dashboard.global은 context/command source vector가 general/city/nation/troop/
diplomacy aggregate에 간접 의존하는 점을 위한 DB/source-only key다. dispatcher는 이를
public dashboard event로 내보내지 않는다. browser wake-up은 정밀 entity/domain key가
담당하고, source equality 검사만 이 보수적 key를 사용한다.
의도적으로 fan-out하지 않는 저장값
- 게시글/댓글은 현재 게시판 화면에서 사용자 action 뒤 직접 다시 읽으며 main SSE
listener가 없다.
board.*domain을 임의로 추가하지 않는다. - 외교 문서(
diplomacyLetter)는 외교 문서 화면 전용이고 현재 SSE consumer가 없다. 전쟁/불가침 상태를 실제 변경하는messages.respond와 구분한다. messages.readLatest는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고 있으므로 자기 자신에게 다시 wake-up을 보내지 않는다.- nation reserved turn, selection-pool reservation, possession 후보, inheritance owner 확인은 각각 전용 화면/request response가 최신 상태를 소유한다.
- image upload는 외부 content store write이며 game PostgreSQL read model이 아니다.
- battle simulation 준비와 서버 fallback은 호환상 mutation transport를 쓰지만 read-only 계산이며 input event transaction을 열지 않는다.
남은 업무 원자성 gap과 coverage 판정
- nation metadata/NPC policy의 API input-event와 ENGINE input-event는 여전히 하나의
업무 transaction이 아니다. 다만 실제 state 소유 ENGINE commit이 precise entity와
dashboard.global을 기록하므로 revision-first source coverage는 빠지지 않는다. - inheritance/tournament command 일부는 보상 가능한 saga다. tournament payload와 profile source revision 자체는 API store, 월 자동 개막, runtime clock shift 모두 공통 Lua writer 한 번으로 원자화했다.
- ENGINE 일반 메시지와 통일 경매 취소 메시지는 실제 insert callback에서 mailbox를 모아 같은 PostgreSQL transaction의 journal/outbox에 쓴다.
- 따라서 dashboard/map coverage v1 코드 조건은 충족했지만 migration은 rolling deploy 안전을 위해 0을 유지한다. 전체 writer binary 배포가 확인된 뒤에만 activation command를 실행하며, 문제 시 meta를 0으로 내려 즉시 full-compute fallback한다.