fix(agent): stop heartbeat watchdog from firing every heartbeat on slow links (#2386) - #2392
Conversation
…ow links (#2386) The heartbeat watchdog (15s) timed the whole runHeartbeat() — metrics collection plus a 30s-capped primary POST plus, after consecutive failures, a second 30s-capped backup-probe POST — so on any degraded uplink it fired on every single heartbeat, doing a stop-the-world runtime.Stack over all goroutines and emitting a 100KB WARN entry the API's 32,000-char fields limit rejects, burning the whole shipped log batch with it. - Raise the watchdog timeout default to 90s, above the ~60-65s legitimate worst case. The watchdog targets indefinite broker-mutex starvation (#387), which exceeds any finite bound, so the diagnostic is preserved while routine slow sends never trip it. - Rate-limit goroutine dumps to one per 10 minutes across invocations; suppressed fires log a cheap one-line WARN with a running counter, and the next dump reports how many fires were suppressed. - Cap the raw dump at 8KB (cut at a goroutine boundary) so the shipped entry fits the API's 32,000-stringified-char fields ceiling with JSON-escaping headroom; add goroutine_count alongside. - Shipper: pre-cap any entry's fields at 31,000 JSON bytes before buffering, replacing oversized fields with a small marker so one bloated entry can no longer 400 the entire batch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
0ebe700
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://d9450258.breeze-9te.pages.dev |
| Branch Preview URL: | https://fix-2386-heartbeat-watchdog.breeze-9te.pages.dev |
…alvage, deterministic tests - capFields: an unmarshalable value (NaN/Inf) no longer passes through to poison the whole-batch marshal in shipBatch (which drops every co-batched entry) — it is dropped by name like an oversized field. - capFields: salvage small correlating fields (ids, durations) instead of discarding everything, and name each dropped key with its size in the fields_dropped marker. - Raise the watchdog dump cap 8KB -> 12KB (still <32,000 chars at worst-case 2x escaping); note the capFields backstop for pathological <>&-heavy dumps. - Comment precision: backup probe fires only past backupProbeThreshold; typical JSON inflation is a few percent (cap sized for worst case); byte>=char claim scoped to strings; note the rate-limit slot is consumed even if the WARN is dropped at enqueue. - Tests: replace sleep-racy blocked-heartbeat helper with an observe-then- release pattern; add deterministic CAS concurrency + interval-boundary tests, production-defaults pin, NaN capFields test, salvage assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review run: /pr-review-toolkit:review-pr (code-reviewer, pr-test-analyzer, silent-failure-hunter, comment-analyzer), plus a focused code-reviewer re-pass on the follow-up commit. Findings: 10 raised across the four agents → all addressed in 0ebe700; 0 outstanding.
Not addressed (pre-existing, out of scope): shipper batches up to 500 entries while the API caps Tests: Status: review-clean, awaiting maintainer merge. |
|
Independent re-review of follow-up commit 0ebe700: clean — |
…cap (#2397) (#2402) Closes #2397 ## Problem The agent log shipper accumulates up to 500 entries per flush (`defaultMaxBatchSize`) but shipped them all in one HTTP request. The API's logs endpoint caps `logs` at 200 entries per request (`apps/api/src/routes/agents/logs.ts`, `z.array(...).max(200)`) and 400s the whole request when exceeded — and the shipper never retries 4xx. A full batch was therefore guaranteed lost wholesale, precisely under bursty logging when logs matter most. Same burn-the-batch class as #2386. ## Fix (agent-side only) - New named constant `maxEntriesPerShipRequest = 200` in `agent/internal/logging/shipper.go`, with a comment pointing at the API-side cap (Go and TS can't share code — the comment is the sync mechanism, and the value must stay <= the oldest supported API cap since self-hosted versions vary). - `shipBatch` now splits each flush into <=200-entry chunks; the 500-entry accumulation buffer is unchanged. - New `shipChunk` carries one HTTP request with the existing retry loop: - **Non-auth 4xx** — chunk-local: drops only that chunk's entries; the batch's remaining chunks still ship. - **429/5xx/network errors** — retried per chunk (`shipRetryCount`, Retry-After honored as before). - **Terminal failures abort the batch's remaining chunks** with a counted drop: network error or 429/5xx after exhausting retries (server unreachable — each further chunk would burn another full retry cycle while blocking the ship loop), and **401** (token dead for every chunk alike; also keeps `RecordAuthFailure` at one per flush, matching the pre-chunking rate the auth monitor's skip threshold was tuned for). Net drop count in these cases matches the old single-request behavior. - Server cap untouched. ## Tests (`agent/internal/logging/shipper_test.go`) - 500-entry flush produces exactly 3 requests of 200/200/100, in order, none lost or duplicated. - <=200-entry batch still goes out as a single request. - A 400 on chunk 2 drops only that chunk (300 entries delivered, `DroppedLogCount == 200`, chunk 3 verified shipped). - A transient 500 on chunk 2 is retried per-chunk; all 500 entries delivered. - Dead server: only chunk 1's retry cycle runs (3 requests), remaining chunks abort with counted drops. - 401: exactly 1 request, exactly 1 `RecordAuthFailure`, all 500 dropped with count. - Pin test asserting `maxEntriesPerShipRequest == 200` against the API contract. ## Verification - `cd agent && go test -race ./internal/logging/...` — ok (7.5s) - `gofmt -l` clean, `go vet` clean, `go build ./...` ok ## Note for the merger Open PR #2392 (#2386) also touches `shipper.go` (adds `capFields` in `Enqueue`). This change is confined to the flush/ship path, so overlap is minimal, but whichever merges second may need a trivial rebase. This PR is based on `origin/main`, not on #2392's branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Todd Hebebrand <todd@lanternops.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…hrottle (#2422) (#2453) ## Summary Fixes both gaps in the `capture_pprof` diagnostic command (#2394/#2408 seam): 1. **Wedge gauges were always 0/0.** `handleCapturePprof` discarded its `*Heartbeat` receiver and embedded `collectors.CollectRuntimeStats()` directly, which never populates `commandsInFlight`/`commandsOverdue`. The snapshot now goes through `h.collectAgentRuntime(now)` — the wrapper that wires in `inFlightCommandStats` — so a capture taken while chasing an overdue-commands heartbeat trend reflects the real pool state. 2. **No throttle on GC-forcing captures.** Heap/all captures unconditionally ran `runtime.GC()`; as a server-queued command (10 concurrent / 100 queued), a burst could force back-to-back stop-the-world GCs. Added a 30s minimum interval between admitted captures using the same atomic CAS slot pattern as the heartbeat watchdog dump throttle (#2392). Throttled captures fail with an explicit `rate-limited` error. Payload validation runs before the throttle so malformed requests don't consume the slot. All changes are confined to `handlers_diag.go` / `handlers_diag_test.go` — `heartbeat.go` is untouched. ## Tests - New: `TestHandleCapturePprofIncludesWedgeGauges` (tracked overdue command shows up as 1/1 in the snapshot), `TestHandleCapturePprofThrottled` (second capture rejected, slot frees after interval), `TestHandleCapturePprofValidationDoesNotConsumeSlot`. - Existing diag tests updated to use a zero-value `*Heartbeat` + per-test throttle reset. - `cd agent && go test -race ./internal/heartbeat/...` — ok (41s); `go build ./...` clean; `go vet` clean. Closes #2422 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Todd Hebebrand <todd@lanternops.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Closes #2386
Problem
The heartbeat watchdog armed a 15s timer around the whole
runHeartbeat()— metrics collection, the primary POST (one 30s-capped context around the retry loop), and after consecutive failures a second full backup-probe POST (another 30s cap). Legitimate worst case is ~60-65s, so on any slow/flapping uplink the watchdog fired on every heartbeat (observed live: every 60s for 2+ days on a macOS device). Each fire did a stop-the-worldruntime.Stack(buf, true), allocated 1MiB, and logged a 100KB WARN entry the shipper uploads — which the API rejects (fields> 32,000 stringified chars), 400ing the entire batch and discarding every legitimate log entry shipped with it.Fix
sendHeartbeat's internals and would stop covering the metrics-collection hang class the whole-call watchdog currently catches for free.suppressed_dumpscounter; the next emitted dump reportssuppressed_dumps_since_last. Concurrent/overlapping fires race for the slot via CAS.goroutine_countas a separate field. JSON escaping roughly doubles a stack dump, so 8KB leaves comfortable headroom under the API's 32,000-charfieldsceiling (worst-case-escaping test included).Enqueuenow pre-caps any entry'sfieldsat 31,000 JSON bytes (mirroring the API limit, byte-count is conservative vs. JS string length), replacing oversized fields with a smallfields_droppedmarker — so no single bloated entry can burn a whole batch again, whatever produces it. No per-entry retry protocol; the guard is purely local and pre-buffer.Tests
truncateGoroutineDumpboundary/marker behavior, worst-case-escaped dump fits the 32,000-char API limit,capFieldspass-through/replacement, andEnqueuecapping an oversized entry while preserving the message.cd agent && go test -race ./internal/heartbeat/... ./internal/logging/...— green.gofmtclean on touched files,go vetclean.🤖 Generated with Claude Code