feat: 시계 reconciliation 워커와 명령 경계 완성

This commit is contained in:
2026-09-03 09:40:48 +00:00
parent ae7d55ef47
commit a3e2bf90ae
46 changed files with 3011 additions and 219 deletions
+46 -5
View File
@@ -17,8 +17,8 @@ export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'Sch
export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' };
export type WallInstant = Date & { readonly [wallInstantBrand]: 'WallInstant' };
export interface ExactClockAlignmentPlan {
policy: 'EXACT';
export interface ClockAlignmentPlan {
policy: ClockAlignmentPolicy;
sourceRevision: ClockRevision;
targetRevision: ClockRevision;
cutTick: GameTick;
@@ -84,6 +84,15 @@ export const parseGameClockPhase = (value: string): GameClockPhase => {
throw new Error(`Unknown game clock phase: ${value}`);
};
const CLOCK_ALIGNMENT_POLICIES: readonly ClockAlignmentPolicy[] = ['EXACT', 'LEGACY_COMPLETE_TURNS', 'CATCH_UP'];
export const parseClockAlignmentPolicy = (value: string): ClockAlignmentPolicy => {
if ((CLOCK_ALIGNMENT_POLICIES as readonly string[]).includes(value)) {
return value as ClockAlignmentPolicy;
}
throw new Error(`Unknown clock alignment policy: ${value}`);
};
export const scheduleNotBefore = (instant: ObservedGameInstant, phase: GameClockPhase): ScheduleInstant => {
if (phase === 'PREOPEN') {
return asScheduleInstant(Math.max(0, instant));
@@ -103,14 +112,15 @@ export const assertGameplayCommitAllowed = (phase: GameClockPhase): void => {
}
};
export const buildExactClockAlignmentPlan = (input: {
const buildAlignmentPlan = (input: {
policy: ClockAlignmentPolicy;
sourceRevision: number;
cutTick: number;
cutWall: Date;
resumeWall: Date;
ticksPerSecond: number;
catchUpTicks?: number;
}): ExactClockAlignmentPlan => {
}): ClockAlignmentPlan => {
const sourceRevision = asClockRevision(input.sourceRevision);
const cutTick = asGameTick(input.cutTick);
const cutWall = asWallInstant(input.cutWall);
@@ -136,7 +146,7 @@ export const buildExactClockAlignmentPlan = (input: {
}
const shiftTicks = asGameTick(gapTicks - catchUpTicks);
return {
policy: 'EXACT',
policy: input.policy,
sourceRevision,
targetRevision: asClockRevision(sourceRevision + 1),
cutTick,
@@ -147,6 +157,37 @@ export const buildExactClockAlignmentPlan = (input: {
};
};
export const buildExactClockAlignmentPlan = (
input: Omit<Parameters<typeof buildAlignmentPlan>[0], 'policy'>
): ClockAlignmentPlan => buildAlignmentPlan({ ...input, policy: 'EXACT' });
export const buildClockAlignmentPlan = (input: {
policy: ClockAlignmentPolicy;
sourceRevision: number;
cutTick: number;
cutWall: Date;
resumeWall: Date;
ticksPerSecond: number;
catchUpTicks?: number;
}): ClockAlignmentPlan => {
if (input.policy === 'EXACT') {
if ((input.catchUpTicks ?? 0) !== 0) {
throw new Error('EXACT alignment does not allow catch-up ticks.');
}
return buildAlignmentPlan({ ...input, policy: 'EXACT', catchUpTicks: 0 });
}
if (input.policy === 'CATCH_UP') {
return buildAlignmentPlan({ ...input, policy: 'CATCH_UP' });
}
const exact = buildAlignmentPlan({ ...input, policy: 'LEGACY_COMPLETE_TURNS', catchUpTicks: 0 });
const shiftTicks = asGameTick(Math.floor(exact.gapTicks / GAME_TICKS_PER_TURN) * GAME_TICKS_PER_TURN);
return {
...exact,
catchUpTicks: asGameTick(exact.gapTicks - shiftTicks),
shiftTicks,
};
};
const tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => {
const wholeSeconds = Math.floor(tick / ticksPerSecond);
const remainingTicks = tick - wholeSeconds * ticksPerSecond;
@@ -18,6 +18,15 @@ export interface TournamentProjectionWrite {
value: unknown;
}
export interface TournamentClockFence {
activeRevisionKey: string;
deadlineGenerationKey: string;
phaseKey: string;
revision: number;
deadlineGeneration: number;
phase: 'RUNNING';
}
const WRITE_TOURNAMENT_PROJECTION_SCRIPT = `
local revision_key = KEYS[#KEYS]
local current = redis.call('GET', revision_key)
@@ -55,6 +64,49 @@ local revision = redis.call('INCR', revision_key)
return tostring(revision) .. ':' .. (stage_changed and '1' or '0') .. ':' .. (rankings_changed and '1' or '0')
`;
const WRITE_FENCED_TOURNAMENT_PROJECTION_SCRIPT = `
local revision_key_index = #KEYS - 3
if redis.call('GET', KEYS[#KEYS - 2]) ~= ARGV[#ARGV - 2]
or redis.call('GET', KEYS[#KEYS - 1]) ~= ARGV[#ARGV - 1]
or redis.call('GET', KEYS[#KEYS]) ~= ARGV[#ARGV] then
return '__CLOCK_FENCE__'
end
local revision_key = KEYS[revision_key_index]
local current = redis.call('GET', revision_key)
if current then
if not string.match(current, '^%d+$') then
return redis.error_reply('invalid tournament source revision')
end
if string.len(current) > 18 then
return redis.error_reply('tournament source revision exhausted')
end
end
local stage_changed = false
local rankings_changed = false
for index = 1, revision_key_index - 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_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') .. ':' .. (rankings_changed and '1' or '0')
`;
export const parseTournamentSourceRevision = (value: unknown): string | null => {
if (typeof value === 'number') {
return Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
@@ -69,7 +121,8 @@ export const parseTournamentSourceRevision = (value: unknown): string | null =>
export const writeTournamentProjection = async (
redis: TournamentProjectionRedis,
keys: TournamentSourceKeys,
writes: readonly TournamentProjectionWrite[]
writes: readonly TournamentProjectionWrite[],
clockFence?: TournamentClockFence
): Promise<string> => {
if (writes.length === 0) {
throw new Error('Tournament projection write must contain at least one payload.');
@@ -90,10 +143,27 @@ export const writeTournamentProjection = async (
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 result = await redis.eval(
clockFence ? WRITE_FENCED_TOURNAMENT_PROJECTION_SCRIPT : WRITE_TOURNAMENT_PROJECTION_SCRIPT,
{
keys: [
...writes.map(({ key }) => key),
keys.sourceRevisionKey,
...(clockFence
? [clockFence.activeRevisionKey, clockFence.deadlineGenerationKey, clockFence.phaseKey]
: []),
],
arguments: [
...writes.map(({ value }) => JSON.stringify(value)),
...(clockFence
? [String(clockFence.revision), String(clockFence.deadlineGeneration), clockFence.phase]
: []),
],
}
);
if (result === '__CLOCK_FENCE__') {
throw new Error('Tournament projection clock revision fence failed.');
}
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
+59
View File
@@ -6,6 +6,7 @@ import {
MAX_SAFE_GAME_TICK,
asGameTick,
asObservedGameInstant,
buildClockAlignmentPlan,
buildExactClockAlignmentPlan,
createDeadline,
scheduleNotBefore,
@@ -113,6 +114,64 @@ describe('GameClock', () => {
expect(plan.alignedTick).toBe(26 * GAME_TICKS_PER_TURN);
});
it('keeps legacy complete-turn rebasing separate from bounded catch-up', () => {
const common = {
sourceRevision: 4,
cutTick: 100,
cutWall: new Date('2026-01-01T00:00:00.000Z'),
resumeWall: new Date('2026-01-01T01:05:17.250Z'),
ticksPerSecond: 10_000,
};
const legacy = buildClockAlignmentPlan({ ...common, policy: 'LEGACY_COMPLETE_TURNS' });
const catchUp = buildClockAlignmentPlan({ ...common, policy: 'CATCH_UP', catchUpTicks: 1_000_000 });
expect(legacy).toMatchObject({
policy: 'LEGACY_COMPLETE_TURNS',
gapTicks: 39_172_500,
shiftTicks: GAME_TICKS_PER_TURN,
catchUpTicks: 3_172_500,
});
expect(catchUp).toMatchObject({
policy: 'CATCH_UP',
gapTicks: 39_172_500,
shiftTicks: 38_172_500,
catchUpTicks: 1_000_000,
});
expect(() => buildClockAlignmentPlan({ ...common, policy: 'EXACT', catchUpTicks: 1 })).toThrow(
'EXACT alignment does not allow catch-up ticks'
);
});
it('preserves schedule ordering, remaining distance, and occurrence ticks across generated exact gaps', () => {
let seed = 0x5eed1234;
const next = (): number => {
seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0;
return seed;
};
for (let iteration = 0; iteration < 500; iteration += 1) {
const cutTick = next() % 1_000_000_000;
const gapMilliseconds = next() % (7 * 24 * 60 * 60 * 1_000);
const ticksPerSecond = [5_000, 10_000, 60_000][next() % 3]!;
const offsets = Array.from({ length: 8 }, () => next() % (3 * GAME_TICKS_PER_TURN)).sort(
(left, right) => left - right
);
const occurrenceTicks = Array.from({ length: 4 }, () => cutTick - (next() % GAME_TICKS_PER_TURN));
const plan = buildClockAlignmentPlan({
policy: 'EXACT',
sourceRevision: 1 + (next() % 10_000),
cutTick,
cutWall: new Date(0),
resumeWall: new Date(gapMilliseconds),
ticksPerSecond,
});
const shifted = offsets.map((offset) => cutTick + offset + plan.shiftTicks);
expect(shifted.map((deadline) => deadline - plan.alignedTick)).toEqual(offsets);
expect([...shifted].sort((left, right) => left - right)).toEqual(shifted);
expect(occurrenceTicks).toEqual([...occurrenceTicks]);
}
});
it('projects near the safe tick boundary without unsafe intermediate multiplication', () => {
const clock = new GameClock({
baseTime: new Date(0),