feat(ollama): HealthChecker.start/stop + delta + onTelemetry hook (#1 v0.2.3)
This commit is contained in:
@@ -1,12 +1,70 @@
|
||||
import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js';
|
||||
|
||||
export type HealthTelemetryEvent =
|
||||
| { kind: 'ollama_unreachable'; reason: string }
|
||||
| { kind: 'ollama_recovered'; downtimeMs: number }
|
||||
| { kind: 'ollama_recheck_manual' };
|
||||
|
||||
export interface HealthCheckerOptions {
|
||||
intervalMs?: number;
|
||||
onUpdate?: (status: HealthResult) => void;
|
||||
onTelemetry?: (event: HealthTelemetryEvent) => void;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60_000;
|
||||
|
||||
export class HealthChecker {
|
||||
private last: HealthResult = { ok: true };
|
||||
constructor(private provider: InferenceProvider) {}
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private unreachableSince: number | null = null;
|
||||
private intervalMs: number;
|
||||
private now: () => number;
|
||||
|
||||
async runOnce(): Promise<HealthResult> {
|
||||
this.last = await this.provider.healthCheck();
|
||||
return this.last;
|
||||
constructor(
|
||||
private provider: InferenceProvider,
|
||||
private opts: HealthCheckerOptions = {}
|
||||
) {
|
||||
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
this.now = opts.now ?? Date.now;
|
||||
}
|
||||
|
||||
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult> {
|
||||
if (opts?.manual === true) {
|
||||
this.opts.onTelemetry?.({ kind: 'ollama_recheck_manual' });
|
||||
}
|
||||
const next = await this.provider.healthCheck();
|
||||
const prev = this.last;
|
||||
const okChanged = prev.ok !== next.ok;
|
||||
const reasonChanged = prev.reason !== next.reason;
|
||||
if (okChanged) {
|
||||
if (next.ok === false) {
|
||||
this.unreachableSince = this.now();
|
||||
this.opts.onTelemetry?.({ kind: 'ollama_unreachable', reason: next.reason ?? 'unknown' });
|
||||
} else {
|
||||
const downtimeMs = this.unreachableSince !== null ? this.now() - this.unreachableSince : 0;
|
||||
this.unreachableSince = null;
|
||||
this.opts.onTelemetry?.({ kind: 'ollama_recovered', downtimeMs });
|
||||
}
|
||||
this.opts.onUpdate?.(next);
|
||||
} else if (reasonChanged) {
|
||||
this.opts.onUpdate?.(next);
|
||||
}
|
||||
this.last = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer !== null) return;
|
||||
void this.runOnce();
|
||||
this.timer = setInterval(() => { void this.runOnce(); }, this.intervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
lastStatus(): HealthResult { return this.last; }
|
||||
|
||||
117
tests/unit/HealthChecker.test.ts
Normal file
117
tests/unit/HealthChecker.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HealthChecker, type HealthTelemetryEvent } from '@main/services/HealthChecker.js';
|
||||
import type { InferenceProvider, HealthResult, GenerateInput } from '@main/ai/InferenceProvider.js';
|
||||
import type { AiResponse } from '@main/ai/schema.js';
|
||||
|
||||
class FakeProvider implements InferenceProvider {
|
||||
readonly name = 'fake';
|
||||
results: HealthResult[] = [];
|
||||
private idx = 0;
|
||||
async healthCheck(): Promise<HealthResult> {
|
||||
const r = this.results[Math.min(this.idx, this.results.length - 1)] ?? { ok: true };
|
||||
this.idx += 1;
|
||||
return r;
|
||||
}
|
||||
async generate(_input: GenerateInput): Promise<AiResponse> {
|
||||
throw new Error('not used');
|
||||
}
|
||||
}
|
||||
|
||||
describe('HealthChecker — start/stop polling', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('start() runs runOnce immediately + every intervalMs', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }, { ok: true }, { ok: true }];
|
||||
const hc = new HealthChecker(provider, { intervalMs: 1000 });
|
||||
hc.start();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((provider as any).idx).toBeGreaterThanOrEqual(3);
|
||||
hc.stop();
|
||||
});
|
||||
|
||||
it('start() is idempotent — second call does not duplicate timer', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }];
|
||||
const hc = new HealthChecker(provider, { intervalMs: 1000 });
|
||||
hc.start();
|
||||
hc.start();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((provider as any).idx).toBeLessThanOrEqual(2);
|
||||
hc.stop();
|
||||
});
|
||||
|
||||
it('stop() clears timer (no further runOnce)', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }, { ok: true }];
|
||||
const hc = new HealthChecker(provider, { intervalMs: 1000 });
|
||||
hc.start();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
const before = (provider as any).idx;
|
||||
hc.stop();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect((provider as any).idx).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HealthChecker — delta transitions + telemetry', () => {
|
||||
it('ok=true → ok=false 전이 시 onUpdate + ollama_unreachable emit', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }, { ok: false, reason: 'connection refused' }];
|
||||
const updates: HealthResult[] = [];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
const hc = new HealthChecker(provider, {
|
||||
onUpdate: (s) => updates.push(s),
|
||||
onTelemetry: (e) => events.push(e)
|
||||
});
|
||||
await hc.runOnce();
|
||||
await hc.runOnce();
|
||||
expect(updates).toEqual([{ ok: false, reason: 'connection refused' }]);
|
||||
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'connection refused' }]);
|
||||
});
|
||||
|
||||
it('ok=false → ok=true 전이 시 onUpdate + ollama_recovered emit (downtimeMs 정확)', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: false, reason: 'refused' }, { ok: true }];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
let nowCounter = 0;
|
||||
const hc = new HealthChecker(provider, {
|
||||
onTelemetry: (e) => events.push(e),
|
||||
now: () => { nowCounter += 1; return nowCounter * 1000; }
|
||||
});
|
||||
await hc.runOnce();
|
||||
await hc.runOnce();
|
||||
const recovered = events.find((e) => e.kind === 'ollama_recovered');
|
||||
expect(recovered).toEqual({ kind: 'ollama_recovered', downtimeMs: 1000 });
|
||||
});
|
||||
|
||||
it('reason 변경만 (ok=false 유지) 시 onUpdate fire 하지만 telemetry emit 안 함', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [
|
||||
{ ok: false, reason: 'refused' },
|
||||
{ ok: false, reason: 'timeout' }
|
||||
];
|
||||
const updates: HealthResult[] = [];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
const hc = new HealthChecker(provider, {
|
||||
onUpdate: (s) => updates.push(s),
|
||||
onTelemetry: (e) => events.push(e)
|
||||
});
|
||||
await hc.runOnce();
|
||||
await hc.runOnce();
|
||||
expect(updates).toHaveLength(2);
|
||||
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'refused' }]);
|
||||
});
|
||||
|
||||
it('runOnce({manual:true}) 가 ollama_recheck_manual 1회 fire', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
const hc = new HealthChecker(provider, { onTelemetry: (e) => events.push(e) });
|
||||
await hc.runOnce({ manual: true });
|
||||
expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user