fix: 토너먼트 단계의 메인 실시간 갱신을 연결

토너먼트 state의 stage 전이만 공용 실시간 채널에 발행하고 공개 invalidation으로 변환한다.
메인 dashboard store가 단계 값을 소유해 상단 문구와 메뉴 강조를 단일/다중 탭에서 함께 갱신한다.
Redis 통합 테스트와 production Chromium 회귀 검증을 추가한다.
This commit is contained in:
2026-08-17 01:41:37 +00:00
parent 1a4cd1a53f
commit 50068224c8
17 changed files with 392 additions and 31 deletions
+18
View File
@@ -69,6 +69,24 @@ export const toPublicRealtimeEvent = (
: null;
}
if (event.type === 'tournamentChanged') {
return {
type: 'readModelInvalidated',
invalidation: {
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
tournament: true,
},
};
}
if (event.type === 'turnCompleted' && !event.changes) {
return {
type: 'readModelInvalidated',
+4
View File
@@ -1,3 +1,5 @@
import { buildGameEventChannel } from '@sammo-ts/common';
export interface TournamentKeys {
stateKey: string;
participantsKey: string;
@@ -5,6 +7,7 @@ export interface TournamentKeys {
bettingKey: string;
sourceRevisionKey: string;
sourceRevisionChannel: string;
realtimeEventChannel: string;
}
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
@@ -14,4 +17,5 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
bettingKey: `sammo:${profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName),
});
@@ -52,6 +52,7 @@ describe('public realtime event privacy boundary', () => {
reservedTurns: true,
records: true,
frontStatus: false,
tournament: false,
},
});
const serialized = JSON.stringify(publicEvent);
@@ -108,10 +109,33 @@ describe('public realtime event privacy boundary', () => {
reservedTurns: true,
records: true,
frontStatus: true,
tournament: true,
},
});
});
it('redacts tournament state changes to one global boolean invalidation', () => {
const publicEvent = toPublicRealtimeEvent({ type: 'tournamentChanged' }, [viewer]);
expect(publicEvent).toEqual({
type: 'readModelInvalidated',
invalidation: {
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
tournament: true,
},
});
expect(JSON.stringify(publicEvent)).not.toMatch(/revision|source|channel|time|generalId/u);
expect(shouldReloadRealtimeViewerIdentity({ type: 'tournamentChanged' }, viewer)).toBe(false);
});
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
const event: RealtimeEvent = {
type: 'messageCreated',
+6
View File
@@ -42,6 +42,12 @@ describe('parseRealtimeEvent', () => {
expect(parseRealtimeEvent('not-json')).toBeNull();
expect(parseRealtimeEvent(JSON.stringify({}))).toBeNull();
});
it('accepts the minimal tournament state wake-up', () => {
expect(parseRealtimeEvent(JSON.stringify({ type: 'tournamentChanged' }))).toEqual({
type: 'tournamentChanged',
});
});
});
describe('buildGameEventChannel', () => {
@@ -10,12 +10,27 @@ const integration = describe.skipIf(!process.env.REDIS_URL);
integration('TournamentStore Redis source revision', () => {
let connector: RedisConnector;
let subscriber: RedisConnector;
const profile = `test:tournament-revision:${randomUUID()}`;
const keys = buildTournamentKeys(profile);
const sourceMessages: string[] = [];
const realtimeMessages: string[] = [];
const waitForLength = async (values: readonly string[], length: number): Promise<void> => {
const deadline = Date.now() + 1_000;
while (values.length < length && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(values).toHaveLength(length);
};
beforeAll(async () => {
connector = createRedisConnector(resolveRedisConfigFromEnv());
subscriber = createRedisConnector(resolveRedisConfigFromEnv());
await connector.connect();
await subscriber.connect();
await subscriber.client.subscribe(keys.sourceRevisionChannel, (message) => sourceMessages.push(message));
await subscriber.client.subscribe(keys.realtimeEventChannel, (message) => realtimeMessages.push(message));
});
afterAll(async () => {
@@ -27,6 +42,11 @@ integration('TournamentStore Redis source revision', () => {
keys.bettingKey,
keys.sourceRevisionKey,
]);
if (subscriber) {
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
await subscriber.client.unsubscribe(keys.realtimeEventChannel);
await subscriber.disconnect();
}
await connector.disconnect();
});
@@ -44,4 +64,37 @@ integration('TournamentStore Redis source revision', () => {
await expect(store.getSourceRevision()).resolves.toBe('20');
await expect(store.getMatches()).resolves.toHaveLength(1);
});
it('publishes the main wake-up only when the atomic state write changes stage', async () => {
const store = new TournamentStore(connector.client, keys);
const sourceBefore = sourceMessages.length;
const realtimeBefore = realtimeMessages.length;
const baseState = {
stage: 1,
phase: 0,
type: 0 as const,
auto: true,
openYear: 185,
openMonth: 2,
termSeconds: 10,
nextAt: '2026-08-17T00:00:00.000Z',
};
await store.setState(baseState);
await waitForLength(sourceMessages, sourceBefore + 1);
await waitForLength(realtimeMessages, realtimeBefore + 1);
await store.setState({ ...baseState, phase: 1 });
await waitForLength(sourceMessages, sourceBefore + 2);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(realtimeMessages).toHaveLength(realtimeBefore + 1);
await store.setState({ ...baseState, stage: 2, phase: 0 });
await waitForLength(sourceMessages, sourceBefore + 3);
await waitForLength(realtimeMessages, realtimeBefore + 2);
expect(realtimeMessages.slice(realtimeBefore)).toEqual([
JSON.stringify({ type: 'tournamentChanged' }),
JSON.stringify({ type: 'tournamentChanged' }),
]);
});
});
@@ -35,7 +35,8 @@ class AtomicMemoryRedis {
}
async publish(channel: string, message: string): Promise<number> {
this.events.push(`publish:${JSON.parse(message).sourceRevision as string}`);
const payload = JSON.parse(message) as { sourceRevision?: string; type?: string };
this.events.push(`publish:${payload.sourceRevision ?? payload.type ?? 'unknown'}`);
this.published.push({ channel, message });
return 1;
}
@@ -71,6 +72,29 @@ describe('TournamentStore source revision', () => {
expect(redis.published).toEqual([]);
});
it('wakes the main realtime channel only for shared state writes', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('che:default');
const store = new TournamentStore(redis, keys);
await store.setState({
stage: 1,
phase: 0,
type: 0,
auto: true,
openYear: 185,
openMonth: 2,
termSeconds: 10,
nextAt: '2026-08-17T00:00:00.000Z',
});
expect(redis.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentChanged']);
expect(redis.published).toEqual([
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
{ channel: keys.realtimeEventChannel, message: JSON.stringify({ type: 'tournamentChanged' }) },
]);
});
it('serializes concurrent writes into monotonic per-profile revisions', async () => {
const redis = new AtomicMemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('pwe:default'));
@@ -1,7 +1,12 @@
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
import { randomUUID } from 'node:crypto';
import { writeTournamentProjection, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
import {
buildGameEventChannel,
writeTournamentProjection,
type TurnDaemonCommand,
type TurnDaemonCommandResult,
} from '@sammo-ts/common';
import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js';
@@ -74,8 +79,10 @@ const shiftTournamentClock = async (
): Promise<boolean> => {
const stateKey = `sammo:${profileName}:tournament:state`;
const sourceKeys = {
stateKey,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName),
};
const lockKey = `${stateKey}:mutation-lock`;
const token = randomUUID();
@@ -1,4 +1,10 @@
import { asRecord, LiteHashDRBG, RandUtil, writeTournamentProjection } from '@sammo-ts/common';
import {
asRecord,
buildGameEventChannel,
LiteHashDRBG,
RandUtil,
writeTournamentProjection,
} from '@sammo-ts/common';
import type { RedisConnector } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
@@ -69,12 +75,13 @@ export const createTournamentAutoStartHandler = (options: {
now?: () => Date;
}): TurnCalendarHandler => {
const keys = {
state: `sammo:${options.profileName}:tournament:state`,
stateKey: `sammo:${options.profileName}:tournament:state`,
participants: `sammo:${options.profileName}:tournament:participants`,
matches: `sammo:${options.profileName}:tournament:matches`,
betting: `sammo:${options.profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(options.profileName),
};
return {
onMonthChanged: async (context) => {
@@ -85,7 +92,7 @@ export const createTournamentAutoStartHandler = (options: {
if (!world || !redis || config.tournamentTrig !== true) {
return;
}
const previousState = safeJsonParse<TournamentState>(await redis.get(keys.state));
const previousState = safeJsonParse<TournamentState>(await redis.get(keys.stateKey));
if (previousState && previousState.stage > 0) {
return;
}
@@ -145,7 +152,7 @@ export const createTournamentAutoStartHandler = (options: {
{ key: keys.participants, value: [] },
{ key: keys.matches, value: [] },
{ key: keys.betting, value: [] },
{ key: keys.state, value: nextState },
{ key: keys.stateKey, value: nextState },
]);
const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0];
@@ -97,6 +97,7 @@ const readModelInvalidation = (
reservedTurns: boolean;
records: boolean;
frontStatus: boolean;
tournament: boolean;
}>
) => ({
context: false,
@@ -108,6 +109,7 @@ const readModelInvalidation = (
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
...overrides,
});
@@ -2300,6 +2302,22 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
await expect(page.locator('.general-title')).toContainText('메뉴검증장수');
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
const operationsBeforeTournament = state.operations.length;
state.stage = 1;
await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true }));
await expect
.poll(() => state.operations.slice(operationsBeforeTournament), { timeout: 3_000 })
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u);
await page.evaluate(() => {
const general = document.querySelector('[data-main-target="general"]');
@@ -2352,6 +2370,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
});
}
@@ -2420,6 +2439,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
reservedTurns: false,
records: false,
frontStatus: true,
tournament: false,
},
}
);
@@ -2577,6 +2597,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
}
);
@@ -2751,6 +2772,23 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
const followerPage = pages[followerIndex];
if (!leaderPage || !followerPage) throw new Error('realtime leader election failed');
const operationsBeforeTournament = state.operations.length;
state.stage = 1;
await emitReadModelInvalidation(leaderPage, readModelInvalidation({ tournament: true }));
await expect
.poll(() => state.operations.slice(operationsBeforeTournament), { timeout: 3_000 })
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
await Promise.all(
pages.map((currentPage) =>
expect(currentPage.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중')
)
);
await Promise.all(
pages.map((currentPage) =>
expect(currentPage.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u)
)
);
const callsBeforeSharedRefresh = state.generalMeCalls;
state.generalName = '탭공유갱신장수';
await leaderPage.evaluate(() => {
@@ -2767,6 +2805,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
}
);
@@ -2794,6 +2833,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
}
);
+20 -2
View File
@@ -46,6 +46,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>['turns'][number];
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
type TournamentState = Awaited<ReturnType<typeof trpc.tournament.getState.query>>;
type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
type DashboardReadModelPatch = {
contextSnapshot?: GeneralContext;
@@ -72,6 +73,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalRecords?: RecentRecord[];
worldHistory?: RecentRecord[];
frontStatus?: FrontStatus | null;
tournamentStage?: number;
};
type DashboardTabMessage =
{ kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' };
@@ -112,6 +114,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const generalRecords = ref<RecentRecord[]>([]);
const worldHistory = ref<RecentRecord[]>([]);
const frontStatus = ref<FrontStatus | null>(null);
const tournamentStage = ref(0);
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
let lastGeneralRecordId = 0;
let lastWorldHistoryId = 0;
@@ -430,6 +433,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} else if (patch.frontStatus !== undefined) {
updateFrontStatus(patch.frontStatus);
}
if (patch.tournamentStage !== undefined) {
tournamentStage.value = patch.tournamentStage;
}
if (patch.contextRevision !== undefined) {
contextRevision = patch.contextRevision;
contextSourceRevision = patch.contextSourceRevision ?? null;
@@ -476,6 +482,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.generalRecords = toRaw(generalRecords.value);
patch.worldHistory = toRaw(worldHistory.value);
patch.frontStatus = toRaw(frontStatus.value);
patch.tournamentStage = tournamentStage.value;
return patch;
};
@@ -585,7 +592,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
frontStatusError.value = resolveErrorMessage(err);
return null;
});
const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus] =
const tournamentPromise = trpc.tournament.getState.query().catch(() => undefined);
const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus, tournamentState] =
await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
@@ -595,6 +603,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalTurnsPromise,
recordsPromise,
frontStatusPromise,
tournamentPromise,
]);
general.value = structurallyShare(general.value, context.general);
@@ -617,6 +626,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (nextFrontStatus) {
updateFrontStatus(nextFrontStatus);
}
if (tournamentState !== undefined) {
tournamentStage.value = tournamentState?.stage ?? 0;
}
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id;
@@ -705,14 +717,18 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
return null;
})
: Promise.resolve(undefined);
const tournamentPromise: Promise<TournamentState | undefined> = plan.tournament
? trpc.tournament.getState.query().catch(() => undefined)
: Promise.resolve(undefined);
const [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
const [lobby, map, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([
lobbyPromise,
mapPromise,
contactsPromise,
reservedPromise,
recordsPromise,
frontPromise,
tournamentPromise,
]);
const patch: DashboardReadModelPatch = { ...contextPatch };
@@ -733,6 +749,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.worldHistory = nextWorldHistory;
}
if (nextFrontStatus) patch.frontStatus = nextFrontStatus;
if (tournamentState !== undefined) patch.tournamentStage = tournamentState?.stage ?? 0;
applyDashboardPatch(patch);
publishDashboardPatch(patch);
} catch (err) {
@@ -1241,6 +1258,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalRecords,
worldHistory,
frontStatus,
tournamentStage,
surveyNotice,
messageDraftText,
targetMailbox,
+2 -4
View File
@@ -27,7 +27,6 @@ const session = useSessionStore();
const dashboard = useMainDashboardStore();
const isMobile = useMediaQuery('(max-width: 939.98px)');
const tournamentStage = ref(0);
const npcMode = ref(0);
const {
@@ -52,6 +51,7 @@ const {
generalRecords,
worldHistory,
frontStatus,
tournamentStage,
surveyNotice,
messageDraftText,
targetMailbox,
@@ -107,12 +107,10 @@ const repeatGeneralTurns = (amount: number) => {
};
const loadMainData = async () => {
const [, state, worldState] = await Promise.all([
const [, worldState] = await Promise.all([
dashboard.loadMainData(),
trpc.tournament.getState.query().catch(() => null),
trpc.world.getState.query().catch(() => null),
]);
tournamentStage.value = state?.stage ?? 0;
npcMode.value = worldState?.config.npcMode ?? 0;
};
@@ -25,6 +25,7 @@ void test('last-turn-time-only events do not schedule any dashboard query', () =
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
});
@@ -46,6 +47,7 @@ void test('selects only the read models affected by the current identity', () =>
reservedTurns: true,
records: true,
frontStatus: false,
tournament: false,
});
});
@@ -79,6 +81,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
const taxRate = resolveDashboardRefreshPlan(
@@ -100,6 +103,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
const cityState = resolveDashboardRefreshPlan(
@@ -161,6 +165,7 @@ void test('refreshes only front status for a global survey projection change', (
reservedTurns: false,
records: false,
frontStatus: true,
tournament: false,
});
});
@@ -174,8 +179,8 @@ void test('targets a submitted survey projection to its own general', () => {
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false);
});
void test('keeps the access bundle projection-free for map, records, and front-status-only plans', () => {
for (const slice of ['map', 'records', 'frontStatus'] as const) {
void test('keeps the access bundle projection-free for independent read-model plans', () => {
for (const slice of ['map', 'records', 'frontStatus', 'tournament'] as const) {
const plan = { ...createEmptyRealtimeReadModelInvalidation(), [slice]: true };
assert.deepEqual(resolveDashboardContextBundleInclude(plan), {
context: false,
+20 -6
View File
@@ -272,9 +272,14 @@ transaction을 잡은 채 `turnDaemon.requestCommand()`의 별도 ENGINE transac
토너먼트 state/participants/matches/bets는 현재 Redis가 원본이므로 PostgreSQL
revision과 원자적으로 묶을 수 없다. `TournamentStore`가 state write와 Redis domain
revision 증가를 같은 Redis transaction 또는 Lua script로 수행한다. 저장 뒤 별도
`publish()` 두 호출로 끝내지 않는다. 장기 durability 요구가 생기면 tournament state
자체를 PostgreSQL 소유로 옮기는 별도 migration으로 다룬다.
revision 증가를 같은 Lua script로 수행한다. source revision은 참가자·대진·베팅을
포함한 모든 projection write에서 증가하지만 메인 화면 wake-up은 Lua 안에서 이전/다음
`state.stage`를 비교해 실제 단계가 바뀔 때만 결정한다. commit 뒤
`tournamentChanged`를 공용 game event channel에 best-effort publish하며, API는 이를
식별자·revision 없는 public `tournament: true` invalidation으로 바꾼다. 참가 등록,
대진 결과, 같은 stage 안의 phase/timer/정산 flag write는 300 viewer를 깨우지 않는다.
장기 durability 요구가 생기면 tournament state 자체를 PostgreSQL 소유로 옮기는 별도
migration으로 다룬다.
### 국가 베팅과 direct writer
@@ -352,7 +357,9 @@ test가 완료되어 post-deploy one-off로 안전하게 활성화할 수 있다
meta/head 누락, DB/Redis 오류에는 shared cache를 완전히 우회해 full compute한다.
- 개인 `spyList`, `shownByGeneralList`, `myCity`, `myNation`은 request에서 계속 조합한다.
- tournament는 API store, 월 자동 개막과 runtime clock shift 모두 payload와 profile source
revision을 같은 Lua invocation으로 갱신하고 commit 뒤에만 best-effort publish한다.
revision을 같은 Lua invocation으로 갱신한다. stage transition만 main realtime channel에
best-effort publish하고, 같은 stage의 phase/participant/match/bet write는 source revision만
진행한다.
- records는 기존 `lastGeneralRecordId`/`lastWorldHistoryId` 증분 조회를 유지하되 해당
domain이 선택되지 않으면 query하지 않는다.
@@ -374,6 +381,13 @@ manual refresh, visible 복귀와 realtime 재활성화는 cadence를 기다리
snapshot을 한 번 읽는다. 숫자는 실제 혼합 부하 결과에 따라 조정하며, 부하만 낮추기
위해 5초를 초과하지 않는다.
메인 dashboard의 `tournamentStage`는 store가 소유한다. visible leader 탭이
`tournament: true`를 받으면 기존 access-only gate 뒤 `tournament.getState`만 읽어 stage
patch를 만들고, 같은 profile/account의 follower 탭은 BroadcastChannel patch를 적용한다.
따라서 상단 `토너먼트:` 문구, 국가 메뉴와 모바일 메뉴의 stage 강조가 한 값으로 함께
갱신된다. Redis/API 오류에는 현재 stage를 거짓 0으로 덮지 않고 다음 event, 사용자
`갱 신`, visible 복귀 snapshot으로 복구한다.
## 구현 단계와 commit 경계
### Phase A: 저위험 read 절감
@@ -414,13 +428,13 @@ snapshot을 한 번 읽는다. 숫자는 실제 혼합 부하 결과에 따라
5. 결과에 따라 pool, cadence, cache와 worker concurrency를 조정하고 전체 benchmark를
다시 실행한다.
### 2026-08-16 구현 상태
### 2026-08-17 구현 상태
| Phase | 상태 | 현재 근거 |
| --- | --- | --- |
| A | 완료 | all-false access gate, frontend 강제 context 제거, Chromium realtime trace |
| B | 완료 | typed journal, PostgreSQL revision/outbox/meta, engine/API 원자 writer, retry dispatcher, 86 mutation inventory |
| C | 완료 | dashboard revision-first, auth/global dependency, durable map cache, 모든 tournament Redis writer 원자화, coverage v1 activation/rollback integration |
| C | 완료 | dashboard revision-first, auth/global dependency, durable map cache, 모든 tournament Redis writer 원자화, stage-only main realtime invalidation, coverage v1 activation/rollback integration |
| D | 부분 완료 | E1 1,200장수 1개월 deterministic profile과 300 SSE/HTTP 짧은 calibration 완료. E2 actual daemon DB flush 및 30분 M1/R1은 미실행 |
`부분 완료`는 capacity 합격을 뜻하지 않는다. 이 작업의 수용 추산은 아래 실제 짧은
+17 -1
View File
@@ -51,6 +51,8 @@ export interface RealtimeReadModelInvalidation {
reservedTurns: boolean;
records: boolean;
frontStatus: boolean;
/** Shared tournament stage shown by the main dashboard changed. */
tournament: boolean;
}
export interface RealtimeViewerIdentity {
@@ -69,6 +71,7 @@ export const createEmptyRealtimeReadModelInvalidation = (): RealtimeReadModelInv
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({
@@ -81,6 +84,7 @@ export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInva
reservedTurns: true,
records: true,
frontStatus: true,
tournament: true,
});
export const mergeRealtimeReadModelInvalidations = (
@@ -96,6 +100,7 @@ export const mergeRealtimeReadModelInvalidations = (
reservedTurns: left.reservedTurns || right.reservedTurns,
records: left.records || right.records,
frontStatus: left.frontStatus || right.frontStatus,
tournament: left.tournament || right.tournament,
});
export const hasRealtimeReadModelInvalidation = (invalidation: RealtimeReadModelInvalidation): boolean =>
@@ -141,6 +146,7 @@ export const resolveRealtimeReadModelInvalidation = (
frontStatusGeneralChanged ||
ownFrontStatusNationChanged ||
ownFrontStatusActorChanged,
tournament: false,
};
};
@@ -212,6 +218,11 @@ export interface MessagesChangedEvent {
mailboxes: number[];
}
/** Redis-owned tournament stage changed after its atomic source revision commit. */
export interface TournamentChangedEvent {
type: 'tournamentChanged';
}
export interface ReadModelInvalidatedEvent {
type: 'readModelInvalidated';
invalidation: RealtimeReadModelInvalidation;
@@ -224,4 +235,9 @@ export interface MessagesInvalidatedEvent {
/** Events safe to expose to an authenticated browser over SSE. */
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent;
export type RealtimeEvent =
| TurnCompletedEvent
| ReadModelChangedEvent
| MessageCreatedEvent
| MessagesChangedEvent
| TournamentChangedEvent;
@@ -1,4 +1,5 @@
import { asRecord } from '../util/parse.js';
import { buildGameEventChannel } from '../realtime/keys.js';
import { writeTournamentProjection } from './sourceRevision.js';
interface TournamentState {
@@ -44,6 +45,7 @@ const buildTournamentKeys = (profileName: string) => ({
bettingKey: `sammo:${profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName),
});
const resolveTermSeconds = (tickSeconds: number): number => {
@@ -1,6 +1,8 @@
export interface TournamentSourceKeys {
stateKey: string;
sourceRevisionKey: string;
sourceRevisionChannel: string;
realtimeEventChannel: string;
}
export interface TournamentProjectionRedis {
@@ -24,11 +26,25 @@ if current then
return redis.error_reply('tournament source revision exhausted')
end
end
local stage_changed = false
for index = 1, #KEYS - 1 do
local next_ok, next_value = pcall(cjson.decode, ARGV[index])
if next_ok and type(next_value) == 'table' and next_value['stage'] ~= nil then
local previous = redis.call('GET', KEYS[index])
local previous_stage = nil
if previous then
local previous_ok, previous_value = pcall(cjson.decode, previous)
if previous_ok and type(previous_value) == 'table' then
previous_stage = previous_value['stage']
end
end
local next_stage = next_value['stage']
stage_changed = (not previous) or previous_stage ~= next_stage
end
redis.call('SET', KEYS[index], ARGV[index])
end
local revision = redis.call('INCR', revision_key)
return tostring(revision)
return tostring(revision) .. ':' .. (stage_changed and '1' or '0')
`;
export const parseTournamentSourceRevision = (value: unknown): string | null => {
@@ -54,11 +70,16 @@ export const writeTournamentProjection = async (
throw new Error('Tournament projection write keys must be unique.');
}
const writesState = writes.some(({ key }) => key === keys.stateKey);
const result = await redis.eval(WRITE_TOURNAMENT_PROJECTION_SCRIPT, {
keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey],
arguments: writes.map(({ value }) => JSON.stringify(value)),
});
const sourceRevision = parseTournamentSourceRevision(result);
const scriptResult = typeof result === 'string' ? /^(\d+):([01])$/u.exec(result) : null;
const sourceRevision = parseTournamentSourceRevision(scriptResult?.[1] ?? result);
// Plain revision results remain accepted for rolling deployments and small
// Redis fakes; only the current Lua contract can suppress same-stage writes.
const stageChanged = writesState && (scriptResult ? scriptResult[2] === '1' : true);
if (sourceRevision === null) {
throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.');
}
@@ -69,6 +90,13 @@ export const writeTournamentProjection = async (
} catch {
// Payload and revision are committed; publication remains best effort.
}
if (stageChanged) {
try {
await redis.publish(keys.realtimeEventChannel, JSON.stringify({ type: 'tournamentChanged' }));
} catch {
// The source-revision wake-up and main SSE wake-up are independent best-effort fan-out.
}
}
}
return sourceRevision;
};
@@ -5,14 +5,14 @@ import { writeTournamentProjection } from '../src/tournament/sourceRevision.js';
describe('tournament source revision', () => {
it('passes every payload and one profile revision key to a single atomic script', async () => {
const calls: Array<{ keys: string[]; arguments: string[] }> = [];
const published: string[] = [];
const published: Array<{ channel: string; message: string }> = [];
const redis = {
eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => {
calls.push(options);
return '7';
return '7:1';
},
publish: async (_channel: string, message: string) => {
published.push(message);
publish: async (channel: string, message: string) => {
published.push({ channel, message });
return 1;
},
};
@@ -20,7 +20,12 @@ describe('tournament source revision', () => {
await expect(
writeTournamentProjection(
redis,
{ sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' },
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[
{ key: 'state', value: { stage: 1 } },
{ key: 'matches', value: [] },
@@ -34,18 +39,35 @@ describe('tournament source revision', () => {
arguments: [JSON.stringify({ stage: 1 }), '[]'],
},
]);
expect(published).toEqual([JSON.stringify({ sourceRevision: '7' })]);
expect(published).toEqual([
{ channel: 'changed', message: JSON.stringify({ sourceRevision: '7' }) },
{ channel: 'realtime', message: JSON.stringify({ type: 'tournamentChanged' }) },
]);
});
it('rejects empty or duplicate writes before evaluating Redis', async () => {
const redis = { eval: async () => '1' };
await expect(
writeTournamentProjection(redis, { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, [])
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[]
)
).rejects.toThrow('at least one');
await expect(
writeTournamentProjection(
redis,
{ sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' },
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[
{ key: 'state', value: 1 },
{ key: 'state', value: 2 },
@@ -53,4 +75,79 @@ describe('tournament source revision', () => {
)
).rejects.toThrow('unique');
});
it('does not wake the main dashboard for participant-only writes', async () => {
const published: string[] = [];
const redis = {
eval: async () => '8',
publish: async (channel: string) => {
published.push(channel);
return 1;
},
};
await writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'participants', value: [{ id: 7 }] }]
);
expect(published).toEqual(['changed']);
});
it('does not wake the main dashboard when tournament state keeps the same stage', async () => {
const published: string[] = [];
const redis = {
eval: async () => '10:0',
publish: async (channel: string) => {
published.push(channel);
return 1;
},
};
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'state', value: { stage: 1, phase: 2 } }]
)
).resolves.toBe('10');
expect(published).toEqual(['changed']);
});
it('keeps both post-commit wake-up channels independently best effort', async () => {
const published: string[] = [];
const redis = {
eval: async () => '9',
publish: async (channel: string) => {
published.push(channel);
if (channel === 'changed') throw new Error('source subscriber unavailable');
return 1;
},
};
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'state', value: { stage: 1 } }]
)
).resolves.toBe('9');
expect(published).toEqual(['changed', 'realtime']);
});
});