Skip to content

πŸ› fix: stop waking the scale-to-zero federated peer (and stop it self-warming afterwards) - #192

Merged
flupkede merged 6 commits into
developfrom
fix/federated-silent-poll-diagnosis
Aug 5, 2026
Merged

πŸ› fix: stop waking the scale-to-zero federated peer (and stop it self-warming afterwards)#192
flupkede merged 6 commits into
developfrom
fix/federated-silent-poll-diagnosis

Conversation

@flupkede

@flupkede flupkede commented Aug 5, 2026

Copy link
Copy Markdown
Owner

What

A mounted federated cloud peer (Azure Container Apps, minReplicas: 0) was waking up roughly every two hours and staying warm for about an hour, with nobody querying it. This branch stops the wake and stops the self-warming that followed it.

Background polling of local repos is unchanged and unaffected β€” the local/federated split is a deliberate design constraint, and it was previously honoured in only one direction.

Ground truth

From Azure Log Analytics on the deployed peer, over a window with zero federated searches:

Observation Value
Interval between wakes 120, 121, 120 minutes
Warm period per wake ~67 min (1h idle window + 5min KEDA cooldown)
Resulting duty cycle β‰ˆ13.4h warm/day, ~56% β€” at zero searches

The 120-minute spacing is the tell: that is the local host's 2h DEFAULT_IDLE_SUSPEND_SECS, not any value configured on the peer. Each wake additionally paid an azcopy sync of the docs blob and a git pull of the KB.

Root cause β€” two defects

1. The trigger. spawn_remote_discovery in the serve TUI used state.idle_suspend_secs() as a baseline poll interval and ran a JoinSet /status fan-out to every configured peer. The poll itself was the ingress traffic that woke the replica.

2. The amplifier. The cloud keep-warm loop computed most_recent_tool_call().unwrap_or(start). /status and /healthz have their own handlers and never call record_tool_call, so a replica woken by anything other than a genuine tool call fell back to the process start time and self-pinged every 120s for the entire idle window β€” ~67 min instead of the ~6 min a bare wake costs (~11x amplification). That fallback was unreachable in the case it was written for: a real tool call always records itself, so it could only ever fire when the wake was not real work.

Neither defect alone produces the observed duty cycle; together they do.

Why this is the third attempt

PR #181 introduced the cadence on the reasoning that polling no faster than the host's own suspend term is harmless. PR #184 named "waking the scale-to-zero cloud peer for no real reason" as the defect and then explicitly sanctioned that same cadence.

The flaw: not keeping a peer awake past its suspend term is strictly weaker than not waking it β€” and the two windows were unrelated values anyway (the cadence read the local host's 2h default, not the peer's ~1h). Both PRs correctly said "local repos unaffected", confirming the split was real but honoured one-way.

The rejected reasoning is now recorded in AGENTS.md, the diagnosis doc and the code comments, specifically so it is not re-litigated a fourth time.

Changes

  • 6f1d1c5 β€” no timer poll of federated peers. The discovery tick is now config-only (REMOTE_ROW_REFRESH_SECS = 5s, zero HTTP): it rebuilds mounted-remote rows from the remote_mounts allowlist so mount/unmount edits and l reloads still surface. A peer is contacted only by an activity poke (a real federated tool call just hit it, so it is demonstrably awake β€” single peer, never a fan-out) or the explicit i info-overlay keypress. Removes ServeState::idle_suspend_secs, which existed solely to feed the cadence and became write-only; --idle-suspend-secs still works because keep-warm resolves flag > env > default itself. Also fixes a pre-existing bug where removing the last peer left its rows rendered forever.
  • 12edcf2 β€” keep-warm requires a real recorded tool call. With none, it does not ping and lets the host suspend the replica. Also fixes the startup "target isn't self" warning from 55fa36b, which false-positived on the only deployment where keep-warm is correct: Azure binds 0.0.0.0 while the target is the ingress FQDN, so it fired on every cold start. A wildcard bind means our external host is unknown, so the check now stays silent β€” a warning that cries wolf on the correct configuration just trains operators to ignore it. Rule extracted into a testable keep_warm_foreign_target helper.
  • 3bcf153 / 08276de β€” docs. Diagnosis rewritten, AGENTS.md + CHANGELOG.md corrected, work log added.

Behaviour explicitly preserved

A peer staying warm for its full idle window after real use is correct and is unchanged. Verified: an inbound federated search forces project=<alias> and reaches record_tool_call, and last_tool_call is insert-only (no remove/clear/retain, untouched by repo idle-eviction), so once one real query lands the previous behaviour holds for the process lifetime.

The earlier diagnosis was wrong

The pre-existing diagnosis blamed a misconfigured local CODESEARCH_KEEP_WARM_URL. Disproven on four independent grounds, now recorded under What was ruled out: the env var is set nowhere locally (process env, HKCU, HKLM, every shell profile); the one-time πŸ”₯ keep-warm enabled line appears in zero logs from 2026-04-26 on; that absence is meaningful because init_serve_logger is always file-only in serve mode and those logs do carry other INFO lines; and no local process held a :443 connection.

Validation

cargo fmt --check, cargo check --all-targets, cargo clippy --all-targets -- -D warnings all clean. 1134 passed, 42 ignored (up from 1124 β€” five new tests covering wildcard binds, a genuine foreign host, a matching host, loopback targets and an unparseable URL).

Every commit was code-reviewed; the final full-branch review returned PASS with no findings.

Known residual (not exploitable today)

The MCP status tool, when project-scoped, does record a tool call (allow_unscoped=true reduces the guard to !is_multi), so an automated poller of that tool would still buy a warm window. No such poller exists β€” Watch-CodesearchServeReplicas.ps1 and FederationClient::list_repos both use the HTTP /status endpoint, which does not record. Documented as the first place to look if the symptom recurs.

Not yet verified in production

Validated by tests and review, not by observing the deployed peer stay asleep. Worth re-running the Log Analytics query once this reaches the cloud replica.

πŸ€– Generated with Claude Code

Test User and others added 6 commits August 5, 2026 13:02
Diagnosed a user report: a local 'codesearch serve' instance was silently
keeping a mounted cloud federation peer warm, defeating its scale-to-zero,
with zero trace in the logs. Traced and ruled out TUI federated polling
(correctly gated behind --no-tui already) and explicit federated tool
calls (none logged). Root cause: the cloud keep-warm task
(CODESEARCH_KEEP_WARM_URL / --keep-warm-url) is not gated by --no-tui at
all, has no restriction that its target must be 'self', and every ping
was completely silent (success and failure both discarded with zero log
line) -- so a keep-warm URL accidentally pointing at another peer (e.g.
copy-pasted from a cloud deployment env into a local shell profile) would
silently generate periodic outbound traffic every KEEP_WARM_INTERVAL_SECS
(120s -- matches the reported 'every 2 minutes') with no way to see it in
the local logs.

Fixes:
- Per-ping logging: debug! on success, warn! on failure (was: silently
  discarded).
- Startup sanity check: new extract_host_from_url() helper (no new crate
  dependency) compares the keep-warm target's host against this server's
  own effective bind host; a mismatch (and not localhost/127.0.0.1/::1)
  now fires a loud warn! naming both hosts, explicit that keep-warm exists
  to self-ping THIS replica, not another peer.

Tests: 6 new cases for extract_host_from_url (plain http, https with real
hostname, no-scheme input, IPv6 literal bracket-preserving, query/fragment
stripping, empty-host edge case). Full serve:: suite green (104 passed).

Diagnosis write-up: docs/diagnose-federated-keep-warm.md
The embedded serve TUI ran a background `/status` fan-out to every mounted
federated peer on a cadence equal to the LOCAL serve's idle-suspend window
(2h by default). Each such poll WOKE the peer's scale-to-zero replica, which
then held itself warm for its own full idle window (~1h on the cloud deploy)
β€” roughly a 50% duty cycle on a peer nobody had queried. Ground truth from
Azure Log Analytics: wakes exactly 120/121/120 minutes apart, each warm
period ~67 min, with zero federated searches.

The design spec was that background polling of LOCAL repos is fine but a
FEDERATED peer must never be polled in the background. "Cannot keep a peer
awake past the host's own suspend term" is a strictly weaker property than
"never wakes it", and the two windows were unrelated values besides (local
host vs. remote peer).

- tui.rs: `spawn_remote_discovery` no longer polls on a timer. The periodic
  tick is now CONFIG-ONLY (`REMOTE_ROW_REFRESH_SECS` = 5s, zero HTTP): it
  rebuilds rows from the `remote_mounts` allowlist so mount/unmount edits and
  `l` reloads surface promptly. The activity poke (a real federated tool call
  landed on that peer) remains the only thing that ever contacts a peer, plus
  the explicit `i` info-overlay keypress. The `initial_cycle` startup gate is
  gone β€” every cycle is now config-only, so it had nothing left to gate.
- serve/mod.rs: drop `ServeState::idle_suspend_secs` (field, env init, getter
  and the `--idle-suspend-secs` override). It existed solely to feed the TUI
  poll cadence and is now write-only. The keep-warm task reads the flag/env
  directly, so `--idle-suspend-secs` behaviour is unchanged.
- Doc comments record WHY there is no baseline poll, to stop the reasoning
  from being reintroduced.

Local repos are entirely unaffected.

Review-fixes:
- [Important] tui_common.rs `activity_stale` doc still referenced "the slow
  baseline poll hasn't fired" as the reason a remote row goes stale β†’ rewritten
  to state there is no background poll and that `-` on an idle mount is the
  normal steady state, not a fault.
- [Important] AGENTS.md and CHANGELOG.md still asserted the removed
  `idle_suspend_secs` cadence as current design (and named a field this commit
  deletes) β†’ corrected, but deliberately NOT folded in here: both files carry a
  large unrelated pending doc-cleanup rewrite that must not enter a source
  commit. They land in the docs commit later on this same unpushed branch.
- [Pre-existing, fixed opportunistically] the discovery snapshot was gated on
  `!cfg.remotes.is_empty()`, so removing the last peer from repos.json left its
  rows rendered forever with no snapshot to clear them. The emit is now
  unconditional; with no peers `build_remote_rows` yields an empty vec, which
  clears them. Still zero HTTP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second half of the federated scale-to-zero defect. Removing the TUI's timer
poll (previous commit) stops the peer being woken; this stops a wake that
does happen from costing a full warm hour.

The keep-warm loop computed its idle check as:

    let last = kw_state.most_recent_tool_call().unwrap_or(start);

`/status` and `/healthz` have their own handlers and never call
`record_tool_call`, so a replica woken by anything other than a genuine tool
call found no recorded call, fell back to the process start time, and
self-pinged its own ingress every 120s for the entire idle window.

The fallback was unreachable in the case it was written for ("a freshly
deployed replica stays warm for the full idle window before first use"): a
real tool call always records itself, so the fallback could ONLY ever fire
when the wake was not real work. Its entire practical effect was rewarding
spurious wakes β€” ~67 min warm instead of the ~6 min a bare wake costs,
roughly 11x amplification.

Keep-warm now requires a real recorded tool call; with none it does not ping
and lets the host suspend the replica, which the next real request wakes.
Verified this preserves the legitimate path: an inbound federated search hits
SEARCH_PATH -> crate::mcp::rest_search_handler -> the MultiStoreContext path
that calls record_tool_call, so after real use the peer keeps itself warm
exactly as before. A peer staying warm for an hour after real use is correct
behaviour and is unchanged.

Also fixes the startup "target isn't self" warning shipped in 55fa36b, which
false-positived on the ONLY deployment where keep-warm is correct: on Azure
Container Apps the process binds 0.0.0.0 while keep_warm_url is the ingress
FQDN, so the host comparison failed and the warning fired on every cold
start. A wildcard bind means our externally-visible host is genuinely
unknown, so the comparison cannot conclude anything and must stay silent β€” a
check that cries wolf on the correct configuration trains operators to ignore
the case that matters. The rule moved into a testable `keep_warm_foreign_target`
helper, covered by 5 new tests (wildcard binds, genuine foreign host, matching
host, loopback targets, unparseable URL).

cargo fmt / clippy -D warnings clean; 1134 passed, 42 ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The diagnosis document's root cause was a hypothesis that turned out to be
wrong, and two project docs still described the removed poll cadence as the
current design β€” which is how this same behaviour got re-introduced twice
(PR #181, #184). Corrected against the code now on this branch.

DIAGNOSE_FEDERATED_KEEP_WARM.md (moved from docs/, rewritten):
- The old root cause blamed a misconfigured local CODESEARCH_KEEP_WARM_URL.
  Disproven on four independent grounds, now recorded under "What was ruled
  out": the env var is set nowhere locally (process env, HKCU, HKLM, all
  shell profiles); the one-time "keep-warm enabled" line appears in zero
  logs from 2026-04-26 on; that absence is meaningful because
  init_serve_logger is always file-only in serve mode and those logs do
  carry other INFO lines; and no local process held a :443 connection.
- Replaced with the confirmed two-defect root cause plus the Azure Log
  Analytics ground truth (wakes 120/121/120 min apart, ~67 min warm each,
  zero searches).
- Records the requirement being violated ("poll LOCAL repos, never
  federated") and the rejected reasoning, so it is not re-litigated.
- Notes one residual, not currently exploitable: the MCP `status` TOOL,
  when project-scoped, does record a tool call β€” unlike the HTTP /status
  endpoint that every known poller actually uses.

CHANGELOG.md:
- The consolidated fix entry goes under [1.2.4] (unreleased). An earlier
  draft wrongly rewrote the [1.2.0] section β€” v1.2.0 is a real tag and
  #181/#184 shipped in it, so editing it would have made released notes
  claim a fix that is not in that release. Both original 1.2.0 entries are
  restored verbatim as historical record, each marked superseded.
- Older version entries compressed to one-liners (existing convention).

AGENTS.md:
- The "Scale-to-zero-safe federated polling" bullet asserted the removed
  cadence as current and named ServeState::idle_suspend_secs, a field that
  no longer exists. Rewritten as an explicit design constraint with the
  rejected reasoning attached.
- Keep-warm bullet updated for the tool-call requirement and the
  wildcard-bind carve-out.
- Completed TODO sections removed, Implemented Features compressed.

README.md:
- The grep-guard bullet still described the 5-minute retry-unblock that
  1.2.0 replaced with a /healthz liveness probe. Rewritten to match.
- Verified NOT stale and left alone: "17 languages" (supported_languages()
  returns 17 β€” the table's 18th row, Jupyter, is JSON-parsed rather than
  tree-sitter) and the web-guard's 5-minute retry, which still exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per-feature work log covering the whole cycle: the disproven original
hypothesis, the Azure Log Analytics ground truth, the two defects, the three
stages with their commit SHAs and review outcomes, and the open follow-ups.

Records why this behaviour took three attempts across PR #181, #184 and this
branch, so the rejected reasoning is not re-litigated a fourth time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ci.yml`'s push trigger is a prefix allowlist that never included `fix/**`,
and there is no `pull_request` trigger. Every `fix/...` branch therefore
merged into develop having never run fmt, clippy or a single test in CI.

This is the repo's own documented naming convention β€” the comment directly
above the branch list even says "feature/fix -> develop". Only the prefix
was missing.

It was invisible because CodeQL is a separate, pull_request-triggered
workflow, so the PR still showed a green check. On PR #192,
`gh pr checks` listed CodeQL and nothing else; the local pre-push QC gate
was the only thing validating the branch.

Adds `fix/**` and documents the footgun plus how to verify coverage
(`gh pr checks <n>` should list the CI jobs, not just CodeQL).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant