|
| 1 | +/** |
| 2 | + * Derive the scale-web.ts hourly instance curve(s) from Axiom data. |
| 3 | + * |
| 4 | + * DATA CONSTRAINT (2026-07): the event-loop `utilization` metric (the capacity |
| 5 | + * signal) only went live ~2026-07-03 23:00Z, so there is exactly ONE full day of |
| 6 | + * it — Saturday 2026-07-04 — and that day ran ~65% above the normal daily load. |
| 7 | + * A straight multi-day utilization curve is therefore impossible yet. Request |
| 8 | + * LOAD, however, has months of history (chat_completion_concurrency count), and |
| 9 | + * the 14-day picture shows weekday vs weekend TOTAL load within ~2% — the real |
| 10 | + * variation is the hourly SHAPE, not weekday/weekend volume. |
| 11 | + * |
| 12 | + * METHOD (honest projection): |
| 13 | + * 1. Calibrate utilization-vs-load from the one util-day (07-04): least-squares |
| 14 | + * fit util ≈ a + b·load at the current flat BASELINE_INSTANCES. |
| 15 | + * 2. Pull the *normal* (07-04 spike excluded) 14-day hourly load shape, split |
| 16 | + * weekday vs weekend. |
| 17 | + * 3. Project util per hour per day-type, size instances to hold projected |
| 18 | + * per-instance util near TARGET_UTIL. |
| 19 | + * |
| 20 | + * Re-run once ≥2 full weekdays of real utilization exist to replace the |
| 21 | + * projection with measured weekday util. |
| 22 | + */ |
| 23 | +import { axiom } from '../logs/lib' |
| 24 | + |
| 25 | +const DS = 'freebuff' |
| 26 | +const WINDOW = process.argv[2] || '14d' |
| 27 | +const CALIB_DAY = '2026-07-04' // the only full utilization day (also a load spike) |
| 28 | +const SKIP_DAYS = new Set([CALIB_DAY, '2026-07-05']) // spike day + partial today |
| 29 | +const BASELINE_INSTANCES = 6 |
| 30 | +const TARGET_UTIL = 0.4 |
| 31 | +const FLOOR = 6 |
| 32 | +const CAP = 16 |
| 33 | + |
| 34 | +async function q(apl: string): Promise<Record<string, any>[]> { |
| 35 | + const res: any = await axiom().query(apl) |
| 36 | + if (res.buckets?.totals?.length) |
| 37 | + return res.buckets.totals.map((g: any) => ({ |
| 38 | + ...(g.group ?? {}), |
| 39 | + ...Object.fromEntries( |
| 40 | + (g.aggregations ?? []).map((a: any) => [a.alias ?? a.op, a.value]), |
| 41 | + ), |
| 42 | + })) |
| 43 | + return (res.matches ?? []).map((m: any) => ({ ...m.data, _time: m._time })) |
| 44 | +} |
| 45 | + |
| 46 | +const hourOf = (iso: string) => new Date(iso).getUTCHours() |
| 47 | +const isWeekend = (iso: string) => [0, 6].includes(new Date(iso).getUTCDay()) |
| 48 | +const sizeFor = (util: number) => |
| 49 | + Math.min(CAP, Math.max(FLOOR, Math.round((BASELINE_INSTANCES * util) / TARGET_UTIL))) |
| 50 | + |
| 51 | +async function main() { |
| 52 | + // --- 1. calibration day: util AND load per hour --- |
| 53 | + const calUtil = await q(`['${DS}'] |
| 54 | + | where _time >= datetime(${CALIB_DAY}) and _time < datetime(${CALIB_DAY}) + 1d |
| 55 | + | where service == "web" and message startswith "[EventLoop]" |
| 56 | + | extend h = toint(bin(_time,1h) - startofday(_time)) / 3600000000000, u = toreal(parse_json(data).utilization) |
| 57 | + | summarize util = avg(u) by h | sort by h asc`) |
| 58 | + const calLoad = await q(`['${DS}'] |
| 59 | + | where _time >= datetime(${CALIB_DAY}) and _time < datetime(${CALIB_DAY}) + 1d |
| 60 | + | where service == "web" and tostring(parse_json(data).metric) == "chat_completion_concurrency" |
| 61 | + | extend h = toint(bin(_time,1h) - startofday(_time)) / 3600000000000 |
| 62 | + | summarize load = count() by h | sort by h asc`) |
| 63 | + const loadByH = new Map<number, number>(calLoad.map((r) => [Number(r.h), Number(r.load)])) |
| 64 | + const pts = calUtil |
| 65 | + .map((r) => ({ x: loadByH.get(Number(r.h)) ?? 0, y: Number(r.util) })) |
| 66 | + .filter((p) => p.x > 0 && p.y > 0) |
| 67 | + // least squares util = a + b*load |
| 68 | + const n = pts.length |
| 69 | + const sx = pts.reduce((s, p) => s + p.x, 0) |
| 70 | + const sy = pts.reduce((s, p) => s + p.y, 0) |
| 71 | + const sxy = pts.reduce((s, p) => s + p.x * p.y, 0) |
| 72 | + const sxx = pts.reduce((s, p) => s + p.x * p.x, 0) |
| 73 | + const b = (n * sxy - sx * sy) / (n * sxx - sx * sx) |
| 74 | + const a = (sy - b * sx) / n |
| 75 | + console.log(`Calibration (${CALIB_DAY}, ${n} hrs): util ≈ ${a.toFixed(4)} + ${b.toExponential(3)}·load`) |
| 76 | + |
| 77 | + // --- 2. normal 14-day hourly load shape, weekday vs weekend --- |
| 78 | + const rows = await q(`['${DS}'] |
| 79 | + | where _time >= ago(${WINDOW}) and service == "web" |
| 80 | + | where tostring(parse_json(data).metric) == "chat_completion_concurrency" |
| 81 | + | extend hr = tostring(bin(_time,1h)) | summarize n = count() by hr | sort by hr asc`) |
| 82 | + const wd: number[][] = Array.from({ length: 24 }, () => []) |
| 83 | + const we: number[][] = Array.from({ length: 24 }, () => []) |
| 84 | + for (const r of rows) { |
| 85 | + const iso = String(r.hr) |
| 86 | + if (SKIP_DAYS.has(iso.slice(0, 10))) continue |
| 87 | + ;(isWeekend(iso) ? we : wd)[hourOf(iso)].push(Number(r.n) || 0) |
| 88 | + } |
| 89 | + const mean = (arr: number[]) => (arr.length ? arr.reduce((s, x) => s + x, 0) / arr.length : 0) |
| 90 | + |
| 91 | + // --- 3. project util per hour per day-type, size instances --- |
| 92 | + const build = (buckets: number[][], label: string) => { |
| 93 | + const curve: number[] = [] |
| 94 | + const table: any[] = [] |
| 95 | + for (let h = 0; h < 24; h++) { |
| 96 | + const load = mean(buckets[h]) |
| 97 | + const projUtil = a + b * load |
| 98 | + const inst = sizeFor(projUtil) |
| 99 | + curve.push(inst) |
| 100 | + table.push({ hourUTC: h, avgLoad: Math.round(load), projUtil: Math.round(projUtil * 1000) / 1000, instances: inst }) |
| 101 | + } |
| 102 | + console.log(`\n--- ${label} (UTC) ---`) |
| 103 | + console.table(table) |
| 104 | + return curve |
| 105 | + } |
| 106 | + const wdCurve = build(wd, 'WEEKDAY (Mon–Fri)') |
| 107 | + const weCurve = build(we, 'WEEKEND (Sat–Sun)') |
| 108 | + const merged = wdCurve.map((v, i) => Math.max(v, weCurve[i])) // safe single curve |
| 109 | + |
| 110 | + const fmt = (c: number[]) => |
| 111 | + c.map((v, i) => (i % 12 === 0 ? `\n ${v}` : `${v}`)).join(', ') |
| 112 | + const delta = wdCurve.map((v, i) => Math.abs(v - weCurve[i])) |
| 113 | + console.log(`\nmax |weekday−weekend| across hours: ${Math.max(...delta)} instance(s)`) |
| 114 | + console.log(`WEEKDAY_CURVE = [${fmt(wdCurve)},\n]`) |
| 115 | + console.log(`WEEKEND_CURVE = [${fmt(weCurve)},\n]`) |
| 116 | + console.log(`MERGED (max) = [${fmt(merged)},\n]`) |
| 117 | + const peak = (c: number[]) => Math.max(...c) |
| 118 | + const avg = (c: number[]) => Math.round((c.reduce((s, v) => s + v, 0) / c.length) * 10) / 10 |
| 119 | + console.log( |
| 120 | + `\nweekday peak ${peak(wdCurve)} avg ${avg(wdCurve)} · weekend peak ${peak(weCurve)} avg ${avg(weCurve)} · merged peak ${peak(merged)} avg ${avg(merged)}`, |
| 121 | + ) |
| 122 | +} |
| 123 | + |
| 124 | +main().catch((e) => { |
| 125 | + console.error(e) |
| 126 | + process.exit(1) |
| 127 | +}) |
0 commit comments