feat: 시계 reconciliation 워커와 명령 경계 완성
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user