feat: 토너먼트 페이지군 자동 갱신을 분리한다

토너먼트 전용 SSE scope와 slice invalidation을 추가하고 같은 계정의 visible 탭이 조회를 공유하게 한다. 서버 증명 snapshot 조회는 접속 점수를 더하지 않되 기존 hard limit은 유지한다.
This commit is contained in:
2026-08-22 06:00:22 +00:00
parent 3b2b98dc88
commit 985f627499
19 changed files with 845 additions and 163 deletions
+27 -2
View File
@@ -223,6 +223,26 @@ export interface TournamentChangedEvent {
type: 'tournamentChanged';
}
/** Redis-owned slices changed after the atomic tournament projection commit. */
export interface TournamentProjectionChangedEvent {
type: 'tournamentProjectionChanged';
invalidation: TournamentViewInvalidation;
}
/** Browser-safe tournament page-family refresh selection. */
export interface TournamentViewInvalidation {
snapshot: boolean;
betting: boolean;
rankings: boolean;
}
export interface TournamentViewInvalidatedEvent {
type: 'tournamentViewInvalidated';
invalidation: TournamentViewInvalidation;
/** Opaque, short-lived proof that the server initiated this refresh. */
refreshGrant: string;
}
export interface ReadModelInvalidatedEvent {
type: 'readModelInvalidated';
invalidation: RealtimeReadModelInvalidation;
@@ -239,7 +259,12 @@ export interface MessagesInvalidatedEvent {
export const REALTIME_ACCESS_GRANT_HEADER = 'x-sammo-realtime-access-grant';
/** Events safe to expose to an authenticated browser over SSE. */
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent | TournamentViewInvalidatedEvent;
export type RealtimeEvent =
TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent | TournamentChangedEvent;
| TurnCompletedEvent
| ReadModelChangedEvent
| MessageCreatedEvent
| MessagesChangedEvent
| TournamentChangedEvent
| TournamentProjectionChangedEvent;
@@ -1,5 +1,8 @@
export interface TournamentSourceKeys {
stateKey: string;
participantsKey: string;
matchesKey: string;
bettingKey: string;
sourceRevisionKey: string;
sourceRevisionChannel: string;
realtimeEventChannel: string;
@@ -27,24 +30,29 @@ if current then
end
end
local stage_changed = false
local rankings_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
local previous_value = nil
if previous then
local previous_ok, previous_value = pcall(cjson.decode, previous)
local previous_ok
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
local previous_reward_settled = previous_value and previous_value['rewardSettled'] or false
rankings_changed = next_value['rewardSettled'] == true and previous_reward_settled ~= true
end
redis.call('SET', KEYS[index], ARGV[index])
end
local revision = redis.call('INCR', revision_key)
return tostring(revision) .. ':' .. (stage_changed and '1' or '0')
return tostring(revision) .. ':' .. (stage_changed and '1' or '0') .. ':' .. (rankings_changed and '1' or '0')
`;
export const parseTournamentSourceRevision = (value: unknown): string | null => {
@@ -71,15 +79,27 @@ export const writeTournamentProjection = async (
}
const writesState = writes.some(({ key }) => key === keys.stateKey);
const writesSnapshot = writes.some(
({ key }) => key === keys.stateKey || key === keys.participantsKey || key === keys.matchesKey
);
const writesBetting = writes.some(({ key }) => key === keys.bettingKey);
const writesSettledRankings = writes.some(
({ key, value }) =>
key === keys.stateKey &&
typeof value === 'object' &&
value !== null &&
(value as { rewardSettled?: unknown }).rewardSettled === true
);
const result = await redis.eval(WRITE_TOURNAMENT_PROJECTION_SCRIPT, {
keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey],
arguments: writes.map(({ value }) => JSON.stringify(value)),
});
const scriptResult = typeof result === 'string' ? /^(\d+):([01])$/u.exec(result) : null;
const scriptResult = typeof result === 'string' ? /^(\d+):([01])(?::([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);
const rankingsChanged = scriptResult?.[3] === undefined ? writesSettledRankings : scriptResult[3] === '1';
if (sourceRevision === null) {
throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.');
}
@@ -90,6 +110,21 @@ export const writeTournamentProjection = async (
} catch {
// Payload and revision are committed; publication remains best effort.
}
try {
await redis.publish(
keys.realtimeEventChannel,
JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: {
snapshot: writesSnapshot,
betting: writesBetting || stageChanged,
rankings: rankingsChanged,
},
})
);
} catch {
// Tournament-page wake-up is a best-effort projection signal.
}
if (stageChanged) {
try {
await redis.publish(keys.realtimeEventChannel, JSON.stringify({ type: 'tournamentChanged' }));
@@ -2,6 +2,16 @@ import { describe, expect, it } from 'vitest';
import { writeTournamentProjection } from '../src/tournament/sourceRevision.js';
const keys = {
stateKey: 'state',
participantsKey: 'participants',
matchesKey: 'matches',
bettingKey: 'betting',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
};
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[] }> = [];
@@ -18,19 +28,10 @@ describe('tournament source revision', () => {
};
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[
{ key: 'state', value: { stage: 1 } },
{ key: 'matches', value: [] },
]
)
writeTournamentProjection(redis, keys, [
{ key: 'state', value: { stage: 1 } },
{ key: 'matches', value: [] },
])
).resolves.toBe('7');
expect(calls).toEqual([
@@ -41,38 +42,25 @@ describe('tournament source revision', () => {
]);
expect(published).toEqual([
{ channel: 'changed', message: JSON.stringify({ sourceRevision: '7' }) },
{
channel: 'realtime',
message: JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: { snapshot: true, betting: true, rankings: false },
}),
},
{ 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, keys, [])).rejects.toThrow('at least one');
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[]
)
).rejects.toThrow('at least one');
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[
{ key: 'state', value: 1 },
{ key: 'state', value: 2 },
]
)
writeTournamentProjection(redis, keys, [
{ key: 'state', value: 1 },
{ key: 'state', value: 2 },
])
).rejects.toThrow('unique');
});
@@ -86,18 +74,9 @@ describe('tournament source revision', () => {
},
};
await writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'participants', value: [{ id: 7 }] }]
);
await writeTournamentProjection(redis, keys, [{ key: 'participants', value: [{ id: 7 }] }]);
expect(published).toEqual(['changed']);
expect(published).toEqual(['changed', 'realtime']);
});
it('does not wake the main dashboard when tournament state keeps the same stage', async () => {
@@ -111,18 +90,9 @@ describe('tournament source revision', () => {
};
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'state', value: { stage: 1, phase: 2 } }]
)
writeTournamentProjection(redis, keys, [{ key: 'state', value: { stage: 1, phase: 2 } }])
).resolves.toBe('10');
expect(published).toEqual(['changed']);
expect(published).toEqual(['changed', 'realtime']);
});
it('keeps both post-commit wake-up channels independently best effort', async () => {
@@ -136,18 +106,55 @@ describe('tournament source revision', () => {
},
};
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']);
await expect(writeTournamentProjection(redis, keys, [{ key: 'state', value: { stage: 1 } }])).resolves.toBe(
'9'
);
expect(published).toEqual(['changed', 'realtime', 'realtime']);
});
it('selects only the betting slice for a same-stage bet write', async () => {
const published: Array<{ channel: string; message: string }> = [];
const redis = {
eval: async () => '11:0',
publish: async (channel: string, message: string) => {
published.push({ channel, message });
return 1;
},
};
await writeTournamentProjection(redis, keys, [{ key: 'betting', value: [{ targetId: 3, amount: 10 }] }]);
expect(published.at(-1)).toEqual({
channel: 'realtime',
message: JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: { snapshot: false, betting: true, rankings: false },
}),
});
expect(published.some(({ message }) => message.includes('tournamentChanged'))).toBe(false);
});
it('refreshes rankings after reward persistence without waking the main dashboard', async () => {
const published: Array<{ channel: string; message: string }> = [];
const redis = {
eval: async () => '12:0',
publish: async (channel: string, message: string) => {
published.push({ channel, message });
return 1;
},
};
await writeTournamentProjection(redis, keys, [
{ key: 'state', value: { stage: 0, rewardSettled: true, bettingSettled: false } },
]);
expect(published.at(-1)).toEqual({
channel: 'realtime',
message: JSON.stringify({
type: 'tournamentProjectionChanged',
invalidation: { snapshot: true, betting: false, rankings: true },
}),
});
expect(published.some(({ message }) => message.includes('tournamentChanged'))).toBe(false);
});
});