Files
inkling/src/main/services/HealthChecker.ts
altair823 9fef2edb6e feat(ollama): ProviderHolder + AiWorker/HealthChecker refactor (v0.2.3.1)
- ProviderHolder: mutable holder + listeners, indirection layer
- AiWorker: constructor InferenceProvider → ProviderHolder
  this.provider.x → this.holder.get().x 전환
- HealthChecker: 동일 패턴
- src/main/index.ts: provider 를 ProviderHolder 로 감싸서 생성
- 기존 AiWorker / HealthChecker 테스트의 constructor 호출에 ProviderHolder wrap
- 단위 +2 cases (ProviderHolder)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:32:20 +09:00

88 lines
3.2 KiB
TypeScript

import type { HealthResult } from '../ai/InferenceProvider.js';
import { ProviderHolder } from '../ai/ProviderHolder.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 {
// sentinel: 첫 healthCheck 가 ok=true 면 transition 으로 인식 안 됨 (no-op),
// ok=false 면 unreachable transition 으로 정상 인식. 즉 첫 호출이 healthy 면 telemetry 0.
private last: HealthResult = { ok: true };
private timer: NodeJS.Timeout | null = null;
private unreachableSince: number | null = null;
// m2 fix: in-flight guard — 첫 runOnce 가 늦게 끝나는 동안 setInterval 이 두 번째
// runOnce 를 시작하면 같은 promise 반환. healthCheck 가 idempotent HTTP 라 안전 측면에선
// 큰 문제 없지만, telemetry 이중 emit (false→true→false 동시 처리) 회피.
private inFlight: Promise<HealthResult> | null = null;
private intervalMs: number;
private now: () => number;
constructor(
private holder: ProviderHolder,
private opts: HealthCheckerOptions = {}
) {
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
this.now = opts.now ?? Date.now;
}
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult> {
// n4 의도: ollama_recheck_manual 은 healthCheck 호출 *전에* fire — provider 가 throw 하거나
// 늦게 응답해도 manual 카운트는 누락 없음. user click → telemetry 1:1 보장.
if (opts?.manual === true) {
this.opts.onTelemetry?.({ kind: 'ollama_recheck_manual' });
}
if (this.inFlight !== null) return this.inFlight;
this.inFlight = this.doRunOnce();
try { return await this.inFlight; }
finally { this.inFlight = null; }
}
private async doRunOnce(): Promise<HealthResult> {
const next = await this.holder.get().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; }
}