From 12681e431c8017d4dd8ed9921c47f6e5d7ff9e56 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:25:26 +0900 Subject: [PATCH] feat(ollama): HealthChecker.start/stop + delta + onTelemetry hook (#1 v0.2.3) --- src/main/services/HealthChecker.ts | 66 +++++++++++++++- tests/unit/HealthChecker.test.ts | 117 +++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 tests/unit/HealthChecker.test.ts diff --git a/src/main/services/HealthChecker.ts b/src/main/services/HealthChecker.ts index a8567a2..c10b9d2 100644 --- a/src/main/services/HealthChecker.ts +++ b/src/main/services/HealthChecker.ts @@ -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 { - 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 { + 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; } diff --git a/tests/unit/HealthChecker.test.ts b/tests/unit/HealthChecker.test.ts new file mode 100644 index 0000000..c8a0603 --- /dev/null +++ b/tests/unit/HealthChecker.test.ts @@ -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 { + const r = this.results[Math.min(this.idx, this.results.length - 1)] ?? { ok: true }; + this.idx += 1; + return r; + } + async generate(_input: GenerateInput): Promise { + 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' }]); + }); +});