-
Notifications
You must be signed in to change notification settings - Fork 33
U4: Worker refresh + warmup #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,178 @@ | ||||||||
| import { getSqlClient } from "@/lib/db/schema"; | ||||||||
| import { getRedis } from "@/lib/redis"; | ||||||||
| import { getNextApiKey } from "@/lib/api-keys"; | ||||||||
| import { PROVIDER_URLS } from "@/lib/providers"; | ||||||||
| import { recordOutcome } from "@/lib/live-score"; | ||||||||
|
|
||||||||
| // ─── Warmup worker ─── | ||||||||
| // Pings passing models every 2min to keep upstream connections warm and | ||||||||
| // detect dead providers fast. Uses its own Redis lock so it can run | ||||||||
| // concurrently with the main worker cycle (separate schedule). | ||||||||
|
|
||||||||
| const WARMUP_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes | ||||||||
| const WARMUP_TIMEOUT_MS = 5_000; | ||||||||
| const MAX_MODELS_PER_WARMUP = 30; | ||||||||
| const WARMUP_LEADER_KEY = "worker:warmup-leader"; | ||||||||
| const WARMUP_LEADER_TTL_SEC = 90; | ||||||||
|
|
||||||||
| let warmupTimer: ReturnType<typeof setInterval> | null = null; | ||||||||
| let isWarming = false; | ||||||||
|
|
||||||||
| function workerId(): string { | ||||||||
| return process.env.HOSTNAME || process.env.COMPUTERNAME || "local"; | ||||||||
| } | ||||||||
|
|
||||||||
| async function acquireWarmupLeader(): Promise<boolean> { | ||||||||
| try { | ||||||||
| const redis = getRedis(); | ||||||||
| const me = workerId(); | ||||||||
| const result = await redis.set(WARMUP_LEADER_KEY, me, "EX", WARMUP_LEADER_TTL_SEC, "NX"); | ||||||||
| if (result === "OK") return true; | ||||||||
| const holder = await redis.get(WARMUP_LEADER_KEY); | ||||||||
| return holder === me; | ||||||||
| } catch { | ||||||||
| // Redis down → single-replica fallback | ||||||||
| return true; | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| async function renewWarmupLeader(): Promise<void> { | ||||||||
| try { | ||||||||
| const redis = getRedis(); | ||||||||
| await redis.expire(WARMUP_LEADER_KEY, WARMUP_LEADER_TTL_SEC); | ||||||||
| } catch { | ||||||||
| // silent | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| async function releaseWarmupLeader(): Promise<void> { | ||||||||
| try { | ||||||||
| const redis = getRedis(); | ||||||||
| const me = workerId(); | ||||||||
| const holder = await redis.get(WARMUP_LEADER_KEY); | ||||||||
| if (holder === me) { | ||||||||
| await redis.del(WARMUP_LEADER_KEY); | ||||||||
| } | ||||||||
| } catch { | ||||||||
| // silent | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| async function logWorker(step: string, message: string, level = "info"): Promise<void> { | ||||||||
| try { | ||||||||
| const sql = getSqlClient(); | ||||||||
| await sql`INSERT INTO worker_logs (step, message, level) VALUES (${step}, ${message}, ${level})`; | ||||||||
| } catch { | ||||||||
| // silent | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| interface WarmupModel { | ||||||||
| id: string; | ||||||||
| provider: string; | ||||||||
| model_id: string; | ||||||||
| } | ||||||||
|
|
||||||||
| async function pingModel(m: WarmupModel): Promise<{ success: boolean; latency: number }> { | ||||||||
| const url = PROVIDER_URLS[m.provider]; | ||||||||
| if (!url) return { success: false, latency: 0 }; | ||||||||
|
|
||||||||
| const apiKey = getNextApiKey(m.provider); | ||||||||
| if (!apiKey && m.provider !== "ollama" && m.provider !== "pollinations") { | ||||||||
| return { success: false, latency: 0 }; | ||||||||
| } | ||||||||
|
|
||||||||
| const start = Date.now(); | ||||||||
| try { | ||||||||
| const res = await fetch(url, { | ||||||||
| method: "POST", | ||||||||
| headers: { | ||||||||
| "Content-Type": "application/json", | ||||||||
| Authorization: `Bearer ${apiKey || "dummy"}`, | ||||||||
|
Comment on lines
+87
to
+91
|
||||||||
| }, | ||||||||
| body: JSON.stringify({ | ||||||||
|
Comment on lines
+89
to
+93
|
||||||||
| model: m.model_id, | ||||||||
| messages: [{ role: "user", content: "." }], | ||||||||
| max_tokens: 1, | ||||||||
| temperature: 0, | ||||||||
| }), | ||||||||
| signal: AbortSignal.timeout(WARMUP_TIMEOUT_MS), | ||||||||
| }); | ||||||||
| return { success: res.ok, latency: Date.now() - start }; | ||||||||
| } catch { | ||||||||
| return { success: false, latency: Date.now() - start }; | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| export async function runWarmupCycle(): Promise<void> { | ||||||||
| if (isWarming) return; | ||||||||
|
|
||||||||
| const isLeader = await acquireWarmupLeader(); | ||||||||
| if (!isLeader) return; | ||||||||
|
|
||||||||
|
Comment on lines
+107
to
+112
|
||||||||
| isWarming = true; | ||||||||
| try { | ||||||||
| const sql = getSqlClient(); | ||||||||
| // Passing models that aren't in cooldown | ||||||||
| const models = await sql<WarmupModel[]>` | ||||||||
| SELECT m.id, m.provider, m.model_id | ||||||||
| FROM models m | ||||||||
| INNER JOIN ( | ||||||||
| SELECT DISTINCT ON (model_id) model_id, passed | ||||||||
| FROM exam_attempts WHERE finished_at IS NOT NULL | ||||||||
| ORDER BY model_id, started_at DESC | ||||||||
| ) ea ON m.id = ea.model_id AND ea.passed = true | ||||||||
| LEFT JOIN ( | ||||||||
| SELECT DISTINCT ON (model_id) model_id, cooldown_until | ||||||||
| FROM health_logs ORDER BY model_id, id DESC | ||||||||
| ) h ON h.model_id = m.id | ||||||||
| WHERE h.cooldown_until IS NULL OR h.cooldown_until < now() | ||||||||
| LIMIT ${MAX_MODELS_PER_WARMUP} | ||||||||
| `; | ||||||||
|
|
||||||||
| if (models.length === 0) { | ||||||||
| await logWorker("warmup", "No models to warm up"); | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| let success = 0; | ||||||||
| let failed = 0; | ||||||||
| const CONCURRENCY = 5; | ||||||||
| let idx = 0; | ||||||||
|
|
||||||||
| async function worker() { | ||||||||
| while (idx < models.length) { | ||||||||
| const m = models[idx++]; | ||||||||
| const { success: ok, latency } = await pingModel(m); | ||||||||
| if (ok) { | ||||||||
| success++; | ||||||||
| recordOutcome(m.provider, m.model_id, true, latency); | ||||||||
| } else { | ||||||||
| failed++; | ||||||||
| recordOutcome(m.provider, m.model_id, false, latency); | ||||||||
|
||||||||
| recordOutcome(m.provider, m.model_id, false, latency); | |
| // Warmup uses a shorter timeout than the normal request path, so do | |
| // not let warmup-only failures penalize the live EMA used for routing. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The worker cycle interval was shortened to 15 minutes, but
src/lib/worker/leader.tsstill uses a 55-minuteLEADER_TTL_SEC. If the leader process dies, the lock can block all replicas from running the cycle for up to ~55 minutes. Please reduce the leader TTL to slightly longer than the new cycle window (and keep renew cadence consistent) so failover works as intended.