Botim Lighthouse · Incident report

Lighthouse restarting every 4 minutes drove ~300 Fabric queries/hour

Lighthouse 每 4 分钟重启一次,每小时打出约 300 条 Fabric 查询

Date 2026-10-22 Window since ~01:00 UTC Severity high Owner Zhifeng Guo · Core CoE Briefing Lighthouse briefings post

0 · What is happening

Fabric's monitoring attributes 280–320 queries/hour, ~100 CPU-hours per hour, to the botim-lighthouse-fabric app registration since ~01:00 UTC on 2026-10-22 — several times the next-largest consumer, a Power BI refresh.

Root cause, in one line: the production Lighthouse process is being restarted every ~4 minutes, and every boot replays ~45–50 Fabric "warm-up" queries. ~14 boots/hour × ~45–50 statements ≈ the exact rate Fabric reports.
280–320 / hFabric queries attributed to the Lighthouse app registration
~4 minbetween consecutive process starts, measured live
~45–50Fabric statements fired by every process start

Evidence 1 — the restart loop, measured live

Each ETL records the run it performs at process start, so start timestamps in /api/etl/status reveal process boots. Four consecutive samples:

06:53:46Z  →  06:58:03Z (+4m17s)  →  07:02:12Z (+4m09s)  →  07:06:16Z (+4m04s)

crashlytics is a 6-hourly ETL and app-errors a 12-hourly one; both showed "ran 1 minute ago, run count = 1" on every sample. That is only possible if the process keeps dying and starting over. The loop was still running at the last sample (process age 0.4 min).

Evidence 2 — what every boot fires at Fabric

server/index.ts registers three boot-time warm-ups that run unconditionally 45–75 s after start. Their results live in an in-memory cache that dies with the process, so each restart starts from zero and pays the full cost again. That is the amplifier.

Boot warm-upFabric statements / bootModuleAdded by
warmOnboardingFunnelDefaults~40 (18 jobs × 1–4) + 2 frontier lookupsqueries/onboarding/funnel-routes.tsPR #8 · CE-1896 · 2026-09-24
warmAppLanguageDefaults~3–4queries/cx-app-language/routes.tsPR #43 · 2026-10-02
warmOsVersionDefaults~1–2queries/cx-os-version/routes.tsPR #105 · CE-2136 · 2026-10-21

The repeated statements Fabric should show: the onboarding WITH attempts … THRESHOLD_CTE … family, the SELECT CONVERT(char(8),[date],112) … MAX(etl_updated_at) frontier lookups, and the app-language / os-version … WHERE dt IN (…) statements. Please confirm against the Fabric monitoring hub.

Why it started at 01:00 UTC

PR #105 (CE-2136, OS version dashboard) merged on 10-21 and added the third boot warm-up, on top of #8 (CE-1896, onboarding, 09-24) and #43 (app language, 10-02). The deploy that followed is when the loop began. The boot-warm pattern predates these — warmServerTraceDefaults (VPQ-375, July) reads StarRocks with one small query and is harmless. Moving the pattern onto Fabric at ~40 statements per boot and stacking three of them is what changed its nature.

What this is not — ruled out, so nobody chases it

CandidateWhat was readResult
22 scheduled ETLs/api/etl/status on productionAll at ≥10-minute intervals; only fabric-bad-app reads Fabric (6-hourly, healthy)
Alert engineserver/alerts/*Kibana-only source; daily / weekly / monthly cadence
Anomaly detectorserver/anomaly/*No Fabric reads
Fabric client retriesserver/fabric-sql-client.tsAt most one retry, on connection errors only
Frontend pollersweb/src setInterval sitesAll poll ES / StarRocks endpoints
Developer laptopsLocal .env of running serversNo app-registration credentials; local servers run as the developer, not the SP
PR #102 Payment Corequeries/money/payment-core.tsUncached, but request-path only; nothing calls it on a timer

Still to confirm on the VM

The repo has no code path that exits the process (unhandled errors are logged, not fatal; process.exit runs only inside the SIGTERM handler). So the process is being killed externally — most likely pm2 max_memory_restart or a kernel OOM during the heavy boot window (76-hour ES backfill + Crashlytics pull + three Fabric warm-ups all start within 75 s). Production runs under pm2 (deploy/pull_and_deploy.sh); the systemd unit in the repo is legacy.
pm2 describe voip-lighthouse          # restarts / unstable restarts / max memory restart
pm2 logs voip-lighthouse --err --lines 100
dmesg -T | grep -iE "killed process|out of memory"

1 · Stop it now

  1. Immediately: stop the restart loop — pm2 stop voip-lighthouse (accepting downtime), or if the cause is the memory cap, pm2 restart voip-lighthouse --max-memory-restart 0 so the process stops being killed mid-boot. Either stops the Fabric bleed within one cycle.
  2. Today — hotfix PR (Core CoE): make boot warm-ups restart-aware: persist the boot time; if the previous boot was < 30 minutes ago, skip every Fabric warm-up. A crash loop then costs zero Fabric queries. Run the warm-ups serially and staggered (10 minutes after boot, not 45 s) so they stop competing with the boot backfills for memory.
  3. This week — CX module owner + Core CoE: persist warm-up results in MySQL (already the warehouse) instead of process memory, so a restart no longer discards them; review which of the 18 onboarding jobs need pre-warming versus on-demand.
  4. Hygiene: the app registration's client secret was found in a developer worktree .env on a laptop. Rotate it; SP credentials belong only in the VM's /etc/botim-lighthouse.env.

2 · Prevent it at merge time

The gap: nothing today reviews what a PR adds to the boot path or how many Fabric statements it fires per process start. Proposed gate, enforced on every PR to main:

2a · One sanctioned way to register boot work

Introduce bootWarm(name, fn, { fabricBudget }) in server/boot-warm.ts as the only API for start-up work. It enforces restart-aware skip, serial + staggered execution, and a per-boot Fabric statement budget (hard cap, e.g. 10 per warm, 20 total). Raw setTimeout(() => void warm…) or top-level void run…() in server/index.ts becomes a lint failure.

2b · CI check that blocks merge

Extend the existing .github/workflows/pr-review.yml with a boot-cost job that fails the PR when:

2c · Automated PR review lens

Add a "Boot & Fabric cost" lens to .claude/skills/pr-review/lenses/ beside the existing ETL lens: every PR must state statements per boot and statements per request for each new Fabric route, and flag anything that runs at boot or lacks a cache.

2d · Ownership

CODEOWNERS: server/index.ts, server/fabric-sql-client.ts and server/boot-warm.ts require a Core CoE review. Changes to the boot path stop landing as a side effect of a dashboard PR.

2e · Runtime guardrail (defence in depth)

A per-process Fabric statement counter exposed on /api/health; an alert when a single process exceeds N statements in its first 10 minutes, or when boots per hour exceed 3. This loop ran ~6 hours before anyone noticed because nothing watched either number.

Asks. (1) Someone with VM access to run the three commands above and confirm the kill reason. (2) A Fabric admin to confirm the repeated query texts. (3) CX to co-own the warm-up redesign. Core CoE opens the hotfix PR (§1.2) and the gate PR (§2).