mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(api): derive voice reconciliation candidate ttl from real sweep spacing (#1810)
This commit is contained in:
1 parent
b5324c9223
commit
e7973b8be0
7 files changed
+148
-13
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
|
||||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||||
|
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||||
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
|
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
|
||||||
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
|
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
|
||||||
@@ -463,6 +463,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
|||||||
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
|
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
|
||||||
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
|
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
|
||||||
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
|
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
|
||||||
|
voiceReconciliation: {
|
||||||
|
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
|
||||||
|
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
|
||||||
|
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
|
||||||
|
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
|
||||||
|
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
|
||||||
|
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
|
||||||
|
},
|
||||||
laneConcurrencyOverrides: {
|
laneConcurrencyOverrides: {
|
||||||
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
|
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
|
||||||
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
|
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
|
||||||
|
|||||||
@@ -361,6 +361,14 @@ export interface APIConfig {
|
|||||||
taskName?: WorkerTaskName;
|
taskName?: WorkerTaskName;
|
||||||
enableCronScheduler?: boolean;
|
enableCronScheduler?: boolean;
|
||||||
enableVoiceReconciliation: boolean;
|
enableVoiceReconciliation: boolean;
|
||||||
|
voiceReconciliation: {
|
||||||
|
intervalMs: number | undefined;
|
||||||
|
staggerDelayMs: number | undefined;
|
||||||
|
lockTtlSeconds: number | undefined;
|
||||||
|
cadenceTtlSeconds: number | undefined;
|
||||||
|
gatewayOnlyGraceMs: number | undefined;
|
||||||
|
liveKitOnlyGraceMs: number | undefined;
|
||||||
|
};
|
||||||
laneConcurrencyOverrides: {
|
laneConcurrencyOverrides: {
|
||||||
realtime?: number;
|
realtime?: number;
|
||||||
unfurl?: number;
|
unfurl?: number;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
import {candidateTtlSecondsFor} from './VoiceReconciliationWorker';
|
||||||
|
|
||||||
|
const INTERVAL_MS = 15000;
|
||||||
|
const GATEWAY_ONLY_GRACE_MS = 10000;
|
||||||
|
|
||||||
|
function ttlFor(observedSweepSpacingMs: number): number {
|
||||||
|
return candidateTtlSecondsFor({
|
||||||
|
intervalMs: INTERVAL_MS,
|
||||||
|
observedSweepSpacingMs,
|
||||||
|
graceMs: GATEWAY_ONLY_GRACE_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('candidateTtlSecondsFor', () => {
|
||||||
|
it('outlives the gap between two consecutive observations of the same key', () => {
|
||||||
|
for (const observedSweepSpacingMs of [0, 45_000, 136_000, 300_000, 596_000, 900_000]) {
|
||||||
|
expect(ttlFor(observedSweepSpacingMs) * 1000).toBeGreaterThan(observedSweepSpacingMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outlives a sweep gap far longer than the tick interval', () => {
|
||||||
|
expect(ttlFor(596_000) * 1000).toBeGreaterThan(596_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grows with the observed sweep spacing rather than the tick interval', () => {
|
||||||
|
expect(ttlFor(596_000)).toBeGreaterThan(ttlFor(136_000));
|
||||||
|
expect(ttlFor(136_000)).toBeGreaterThan(ttlFor(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a floor that survives a single long sweep before any spacing is observed', () => {
|
||||||
|
expect(ttlFor(0)).toBeGreaterThanOrEqual(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays bounded so a stale candidate cannot outlive its connection indefinitely', () => {
|
||||||
|
expect(ttlFor(Number.MAX_SAFE_INTEGER)).toBeLessThanOrEqual(3600);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,9 +96,24 @@ const DEFAULT_STAGGER_DELAY_MS = 25;
|
|||||||
const DEFAULT_LOCK_TTL_SECONDS = 180;
|
const DEFAULT_LOCK_TTL_SECONDS = 180;
|
||||||
const DEFAULT_GATEWAY_ONLY_GRACE_MS = 10000;
|
const DEFAULT_GATEWAY_ONLY_GRACE_MS = 10000;
|
||||||
const DEFAULT_LIVEKIT_ONLY_GRACE_MS = 60000;
|
const DEFAULT_LIVEKIT_ONLY_GRACE_MS = 60000;
|
||||||
|
const MIN_CANDIDATE_TTL_SECONDS = 300;
|
||||||
|
const MAX_CANDIDATE_TTL_SECONDS = 3600;
|
||||||
|
const CANDIDATE_TTL_SWEEP_MULTIPLIER = 3;
|
||||||
|
const LAST_SWEEP_KEY_TTL_SECONDS = 86400;
|
||||||
const ROOM_KEY_PREFIX = 'voice:room:server:';
|
const ROOM_KEY_PREFIX = 'voice:room:server:';
|
||||||
|
export function candidateTtlSecondsFor(input: {
|
||||||
|
intervalMs: number;
|
||||||
|
observedSweepSpacingMs: number;
|
||||||
|
graceMs: number;
|
||||||
|
}): number {
|
||||||
|
const spacingMs = Math.max(input.intervalMs, input.observedSweepSpacingMs);
|
||||||
|
const ttlSeconds = Math.ceil((spacingMs * CANDIDATE_TTL_SWEEP_MULTIPLIER + input.graceMs * 2) / 1000);
|
||||||
|
return Math.min(MAX_CANDIDATE_TTL_SECONDS, Math.max(MIN_CANDIDATE_TTL_SECONDS, ttlSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
const VOICE_RECONCILIATION_LOCK_KEY = 'voice:reconcile:lock';
|
const VOICE_RECONCILIATION_LOCK_KEY = 'voice:reconcile:lock';
|
||||||
const VOICE_RECONCILIATION_CADENCE_KEY = 'voice:reconcile:cadence';
|
const VOICE_RECONCILIATION_CADENCE_KEY = 'voice:reconcile:cadence';
|
||||||
|
const VOICE_RECONCILIATION_LAST_SWEEP_KEY = 'voice:reconcile:last-sweep-at';
|
||||||
const GATEWAY_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:gateway-only:';
|
const GATEWAY_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:gateway-only:';
|
||||||
const LIVEKIT_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:livekit-only:';
|
const LIVEKIT_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:livekit-only:';
|
||||||
|
|
||||||
@@ -115,8 +130,7 @@ export class VoiceReconciliationWorker {
|
|||||||
private readonly cadenceTtlSeconds: number;
|
private readonly cadenceTtlSeconds: number;
|
||||||
private readonly gatewayOnlyGraceMs: number;
|
private readonly gatewayOnlyGraceMs: number;
|
||||||
private readonly liveKitOnlyGraceMs: number;
|
private readonly liveKitOnlyGraceMs: number;
|
||||||
private readonly gatewayOnlyCandidateTtlSeconds: number;
|
private observedSweepSpacingMs = 0;
|
||||||
private readonly liveKitOnlyCandidateTtlSeconds: number;
|
|
||||||
private intervalHandle: NodeJS.Timeout | null = null;
|
private intervalHandle: NodeJS.Timeout | null = null;
|
||||||
private reconciling = false;
|
private reconciling = false;
|
||||||
private reconciliationLockLost = false;
|
private reconciliationLockLost = false;
|
||||||
@@ -136,14 +150,6 @@ export class VoiceReconciliationWorker {
|
|||||||
this.cadenceTtlSeconds = options.cadenceTtlSeconds ?? Math.max(1, Math.ceil((this.intervalMs * 3) / 1000));
|
this.cadenceTtlSeconds = options.cadenceTtlSeconds ?? Math.max(1, Math.ceil((this.intervalMs * 3) / 1000));
|
||||||
this.gatewayOnlyGraceMs = options.gatewayOnlyGraceMs ?? DEFAULT_GATEWAY_ONLY_GRACE_MS;
|
this.gatewayOnlyGraceMs = options.gatewayOnlyGraceMs ?? DEFAULT_GATEWAY_ONLY_GRACE_MS;
|
||||||
this.liveKitOnlyGraceMs = options.liveKitOnlyGraceMs ?? DEFAULT_LIVEKIT_ONLY_GRACE_MS;
|
this.liveKitOnlyGraceMs = options.liveKitOnlyGraceMs ?? DEFAULT_LIVEKIT_ONLY_GRACE_MS;
|
||||||
this.gatewayOnlyCandidateTtlSeconds = Math.max(
|
|
||||||
60,
|
|
||||||
Math.ceil((this.intervalMs * 4 + this.gatewayOnlyGraceMs * 4) / 1000),
|
|
||||||
);
|
|
||||||
this.liveKitOnlyCandidateTtlSeconds = Math.max(
|
|
||||||
60,
|
|
||||||
Math.ceil((this.intervalMs * 4 + this.liveKitOnlyGraceMs * 4) / 1000),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -156,6 +162,7 @@ export class VoiceReconciliationWorker {
|
|||||||
intervalMs: this.intervalMs,
|
intervalMs: this.intervalMs,
|
||||||
gatewayOnlyGraceMs: this.gatewayOnlyGraceMs,
|
gatewayOnlyGraceMs: this.gatewayOnlyGraceMs,
|
||||||
liveKitOnlyGraceMs: this.liveKitOnlyGraceMs,
|
liveKitOnlyGraceMs: this.liveKitOnlyGraceMs,
|
||||||
|
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
|
||||||
},
|
},
|
||||||
'Starting VoiceReconciliationWorker',
|
'Starting VoiceReconciliationWorker',
|
||||||
);
|
);
|
||||||
@@ -175,6 +182,7 @@ export class VoiceReconciliationWorker {
|
|||||||
|
|
||||||
async reconcile(): Promise<void> {
|
async reconcile(): Promise<void> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
await this.recordSweepSpacing(startTime);
|
||||||
const discovery = await this.discoverActiveRooms();
|
const discovery = await this.discoverActiveRooms();
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{
|
||||||
@@ -233,6 +241,8 @@ export class VoiceReconciliationWorker {
|
|||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{
|
||||||
|
observedSweepSpacingMs: this.observedSweepSpacingMs,
|
||||||
|
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
|
||||||
roomsChecked,
|
roomsChecked,
|
||||||
totalConfirmed,
|
totalConfirmed,
|
||||||
totalRepaired,
|
totalRepaired,
|
||||||
@@ -300,6 +310,37 @@ export class VoiceReconciliationWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private candidateTtlSeconds(graceMs: number): number {
|
||||||
|
return candidateTtlSecondsFor({
|
||||||
|
intervalMs: this.intervalMs,
|
||||||
|
observedSweepSpacingMs: this.observedSweepSpacingMs,
|
||||||
|
graceMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordSweepSpacing(startedAt: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const previous = await this.kvClient.get(VOICE_RECONCILIATION_LAST_SWEEP_KEY);
|
||||||
|
const previousAt = previous === null ? Number.NaN : Number(previous);
|
||||||
|
if (Number.isFinite(previousAt) && startedAt > previousAt) {
|
||||||
|
this.observedSweepSpacingMs = Math.max(this.observedSweepSpacingMs, startedAt - previousAt);
|
||||||
|
const gatewayOnlyCandidateTtlSeconds = this.candidateTtlSeconds(this.gatewayOnlyGraceMs);
|
||||||
|
if (this.observedSweepSpacingMs >= gatewayOnlyCandidateTtlSeconds * 1000) {
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
observedSweepSpacingMs: this.observedSweepSpacingMs,
|
||||||
|
gatewayOnlyCandidateTtlSeconds,
|
||||||
|
},
|
||||||
|
'Reconciliation sweeps are further apart than the candidate TTL; divergent voice states will be deferred forever',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.kvClient.setex(VOICE_RECONCILIATION_LAST_SWEEP_KEY, LAST_SWEEP_KEY_TTL_SECONDS, String(startedAt));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({error}, 'Failed to record reconciliation sweep spacing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async acquireCadenceLease(): Promise<boolean> {
|
private async acquireCadenceLease(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
return await this.kvClient.setnx(VOICE_RECONCILIATION_CADENCE_KEY, '1', this.cadenceTtlSeconds);
|
return await this.kvClient.setnx(VOICE_RECONCILIATION_CADENCE_KEY, '1', this.cadenceTtlSeconds);
|
||||||
@@ -993,7 +1034,7 @@ export class VoiceReconciliationWorker {
|
|||||||
}
|
}
|
||||||
await this.kvClient.setex(
|
await this.kvClient.setex(
|
||||||
key,
|
key,
|
||||||
this.gatewayOnlyCandidateTtlSeconds,
|
this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
|
||||||
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
|
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
@@ -1024,7 +1065,7 @@ export class VoiceReconciliationWorker {
|
|||||||
}
|
}
|
||||||
await this.kvClient.setex(
|
await this.kvClient.setex(
|
||||||
key,
|
key,
|
||||||
this.liveKitOnlyCandidateTtlSeconds,
|
this.candidateTtlSeconds(this.liveKitOnlyGraceMs),
|
||||||
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
|
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -243,6 +243,12 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
|
|||||||
voiceRoomStore,
|
voiceRoomStore,
|
||||||
kvClient,
|
kvClient,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
|
intervalMs: Config.worker.voiceReconciliation.intervalMs,
|
||||||
|
staggerDelayMs: Config.worker.voiceReconciliation.staggerDelayMs,
|
||||||
|
lockTtlSeconds: Config.worker.voiceReconciliation.lockTtlSeconds,
|
||||||
|
cadenceTtlSeconds: Config.worker.voiceReconciliation.cadenceTtlSeconds,
|
||||||
|
gatewayOnlyGraceMs: Config.worker.voiceReconciliation.gatewayOnlyGraceMs,
|
||||||
|
liveKitOnlyGraceMs: Config.worker.voiceReconciliation.liveKitOnlyGraceMs,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
if (Config.voice.enabled && voiceTopology !== null) {
|
if (Config.voice.enabled && voiceTopology !== null) {
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ export interface MasterConfig {
|
|||||||
task?: string;
|
task?: string;
|
||||||
enable_cron_scheduler?: boolean;
|
enable_cron_scheduler?: boolean;
|
||||||
enable_voice_reconciliation?: boolean;
|
enable_voice_reconciliation?: boolean;
|
||||||
|
voice_reconciliation?: {
|
||||||
|
interval_ms?: number;
|
||||||
|
stagger_delay_ms?: number;
|
||||||
|
lock_ttl_seconds?: number;
|
||||||
|
cadence_ttl_seconds?: number;
|
||||||
|
gateway_only_grace_ms?: number;
|
||||||
|
livekit_only_grace_ms?: number;
|
||||||
|
};
|
||||||
lane_concurrency_overrides?: {
|
lane_concurrency_overrides?: {
|
||||||
realtime?: number;
|
realtime?: number;
|
||||||
unfurl?: number;
|
unfurl?: number;
|
||||||
|
|||||||
@@ -96,6 +96,30 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
|||||||
path: ['services', 'api', 'worker', 'enable_voice_reconciliation'],
|
path: ['services', 'api', 'worker', 'enable_voice_reconciliation'],
|
||||||
parse: parseEnvValue,
|
parse: parseEnvValue,
|
||||||
},
|
},
|
||||||
|
FLUXER_API_WORKER_VOICE_RECONCILIATION_INTERVAL_MS: {
|
||||||
|
path: ['services', 'api', 'worker', 'voice_reconciliation', 'interval_ms'],
|
||||||
|
parse: parseEnvValue,
|
||||||
|
},
|
||||||
|
FLUXER_API_WORKER_VOICE_RECONCILIATION_STAGGER_DELAY_MS: {
|
||||||
|
path: ['services', 'api', 'worker', 'voice_reconciliation', 'stagger_delay_ms'],
|
||||||
|
parse: parseEnvValue,
|
||||||
|
},
|
||||||
|
FLUXER_API_WORKER_VOICE_RECONCILIATION_LOCK_TTL_SECONDS: {
|
||||||
|
path: ['services', 'api', 'worker', 'voice_reconciliation', 'lock_ttl_seconds'],
|
||||||
|
parse: parseEnvValue,
|
||||||
|
},
|
||||||
|
FLUXER_API_WORKER_VOICE_RECONCILIATION_CADENCE_TTL_SECONDS: {
|
||||||
|
path: ['services', 'api', 'worker', 'voice_reconciliation', 'cadence_ttl_seconds'],
|
||||||
|
parse: parseEnvValue,
|
||||||
|
},
|
||||||
|
FLUXER_API_WORKER_VOICE_RECONCILIATION_GATEWAY_ONLY_GRACE_MS: {
|
||||||
|
path: ['services', 'api', 'worker', 'voice_reconciliation', 'gateway_only_grace_ms'],
|
||||||
|
parse: parseEnvValue,
|
||||||
|
},
|
||||||
|
FLUXER_API_WORKER_VOICE_RECONCILIATION_LIVEKIT_ONLY_GRACE_MS: {
|
||||||
|
path: ['services', 'api', 'worker', 'voice_reconciliation', 'livekit_only_grace_ms'],
|
||||||
|
parse: parseEnvValue,
|
||||||
|
},
|
||||||
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: {
|
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: {
|
||||||
path: ['services', 'api', 'worker', 'lane_concurrency_overrides'],
|
path: ['services', 'api', 'worker', 'lane_concurrency_overrides'],
|
||||||
parse: parseEnvValue,
|
parse: parseEnvValue,
|
||||||
|
|||||||
Reference in new issue
Block a user