feat(companion): add heartbeat loop jitter controls

This commit is contained in:
William Valentin
2026-02-16 18:57:56 -08:00
parent fd59d88c0c
commit 01b24e71b9
4 changed files with 58 additions and 2 deletions
+21
View File
@@ -42,6 +42,12 @@ describe('CompanionHeartbeatLoop', () => {
expect(() => new CompanionHeartbeatLoop(publisher, { intervalMs: 0 })).toThrow(
'intervalMs must be a positive number',
);
expect(() => new CompanionHeartbeatLoop(publisher, { jitterRatio: -0.1 })).toThrow(
'jitterRatio must be between 0 and 1',
);
expect(() => new CompanionHeartbeatLoop(publisher, { jitterRatio: 1.1 })).toThrow(
'jitterRatio must be between 0 and 1',
);
expect(() => new CompanionHeartbeatLoop(publisher, { maxConsecutiveFailures: 0 })).toThrow(
'maxConsecutiveFailures must be >= 1 when specified',
);
@@ -60,6 +66,21 @@ describe('CompanionHeartbeatLoop', () => {
loop.stop();
});
it('applies jitterRatio to scheduling delay bounds', async () => {
const publishHeartbeat = vi.fn(async () => buildStatusResult());
const loop = new CompanionHeartbeatLoop(
{ publishHeartbeat },
{ intervalMs: 1000, jitterRatio: 0.5, randomFn: () => 1 },
);
loop.start(false);
await vi.advanceTimersByTimeAsync(1499);
expect(publishHeartbeat).toHaveBeenCalledTimes(0);
await vi.advanceTimersByTimeAsync(1);
expect(publishHeartbeat).toHaveBeenCalledTimes(1);
loop.stop();
});
it('passes buildHeartbeat payload into publishHeartbeat', async () => {
const publishHeartbeat = vi.fn(async () => buildStatusResult());
const buildHeartbeat = vi.fn(() => ({ statusText: 'loop', powerSource: 'ac' as const }));
+23 -1
View File
@@ -7,10 +7,12 @@ export interface HeartbeatPublisher {
export interface CompanionHeartbeatLoopOptions {
intervalMs?: number;
jitterRatio?: number;
buildHeartbeat?: () => HeartbeatStatusInput | Promise<HeartbeatStatusInput>;
onError?: (error: Error) => void;
maxConsecutiveFailures?: number;
onFailureLimitReached?: (error: Error, consecutiveFailures: number) => void;
randomFn?: () => number;
}
/**
@@ -20,10 +22,12 @@ export interface CompanionHeartbeatLoopOptions {
export class CompanionHeartbeatLoop {
private readonly publisher: HeartbeatPublisher;
private readonly intervalMs: number;
private readonly jitterRatio: number;
private readonly buildHeartbeat?: () => HeartbeatStatusInput | Promise<HeartbeatStatusInput>;
private readonly onError?: (error: Error) => void;
private readonly maxConsecutiveFailures: number;
private readonly onFailureLimitReached?: (error: Error, consecutiveFailures: number) => void;
private readonly randomFn: () => number;
private timer: NodeJS.Timeout | null = null;
private started = false;
@@ -35,6 +39,10 @@ export class CompanionHeartbeatLoop {
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
throw new Error('intervalMs must be a positive number');
}
const jitterRatio = options.jitterRatio ?? 0;
if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) {
throw new Error('jitterRatio must be between 0 and 1');
}
const maxConsecutiveFailures = options.maxConsecutiveFailures ?? Number.POSITIVE_INFINITY;
if (!Number.isFinite(maxConsecutiveFailures) && maxConsecutiveFailures !== Number.POSITIVE_INFINITY) {
throw new Error('maxConsecutiveFailures must be a positive number or Infinity');
@@ -45,10 +53,12 @@ export class CompanionHeartbeatLoop {
this.publisher = publisher;
this.intervalMs = intervalMs;
this.jitterRatio = jitterRatio;
this.buildHeartbeat = options.buildHeartbeat;
this.onError = options.onError;
this.maxConsecutiveFailures = maxConsecutiveFailures;
this.onFailureLimitReached = options.onFailureLimitReached;
this.randomFn = options.randomFn ?? Math.random;
}
get running(): boolean {
@@ -85,9 +95,21 @@ export class CompanionHeartbeatLoop {
if (!this.started) {
return;
}
const delay = this.computeDelayMs();
this.timer = setTimeout(() => {
void this.tick();
}, this.intervalMs);
}, delay);
}
private computeDelayMs(): number {
if (this.jitterRatio === 0) {
return this.intervalMs;
}
const minFactor = 1 - this.jitterRatio;
const maxFactor = 1 + this.jitterRatio;
const random = this.randomFn();
const factor = minFactor + (maxFactor - minFactor) * random;
return Math.max(1, Math.round(this.intervalMs * factor));
}
private async tick(force = false): Promise<void> {