Skip to content

bug: fleet-health monitoring loop runs once per uvicorn worker — duplicate health checks and doubled circuit-breaker failure feed #1464

Description

@vybe

Summary

The MonitoringService fleet-health loop is started unconditionally in the FastAPI lifespan of every uvicorn worker, with no cross-worker leader election. The prod compose runs the backend with --workers 2, so two independent monitoring loops run concurrently: every health check is performed and stored twice per cycle, and — because check_network_health() feeds probe failures into the per-agent circuit breaker (#631) — every probe failure is counted twice, effectively halving the breaker's failure threshold and probe budget. This directly amplifies #1463 (breaker opening for busy-but-healthy agents).

Component

Backend / Monitoring Service (startup wiring in main.py)

Priority

P2

Error

Observed on a production instance — every monitoring pass is duplicated, a few ms apart:

08:06:40.368 fleet_health_check pass complete: agents=8 (running=8 stopped=0) healthy=7 ... unhealthy=1 duration_ms=32376
08:06:40.380 fleet_health_check pass complete: agents=8 (running=8 stopped=0) healthy=7 ... unhealthy=1 duration_ms=32296

And agent_health_checks stores two identical rows per layer per cycle (same second, e.g. two docker + two network + two business + two aggregate rows at 08:09:47, 08:10:50, 08:11:52, ...).

Location

  • File: src/backend/main.py (~lines 532–545) — _start_monitoring_delayed() / asyncio.create_task(...) in the lifespan, runs in every worker process
  • File: src/backend/services/monitoring_service.pystart_monitoring_service() / MonitoringService._run_loop() have no distributed lock

Root Cause

Lifespan code executes per worker process. The scheduler was long ago split into its own single-process service (with Redis locking) precisely to avoid multi-worker duplication — the monitoring loop (re-armed at boot by #1121) has no equivalent guard, so with N workers the fleet gets probed N times per interval.

Consequences:

  1. Duplicate rows in agent_health_checks (storage bloat, misleading dashboards/rollups)
  2. Doubled record_failure() feed into the circuit breaker: the 3-failure open threshold is reached in ~1.5 real probe rounds and the 10-probe dormant budget in ~5 — see bug: circuit breaker opens for busy-but-healthy agents — /health probe timeouts during long CPU-bound executions count as liveness failures #1463
  3. Doubled probe load against every agent-server, worst exactly when an agent is already busy

Reproduction Steps

  1. Run the backend with --workers 2 (prod compose default) and monitoring enabled
  2. grep "fleet_health_check pass complete" <backend logs> — two lines per cycle, milliseconds apart
  3. Query agent_health_checks — two rows per check layer per agent per cycle

Suggested Fix

Give the loop a cross-process guard, mirroring the scheduler's approach:

# start_monitoring_service(): acquire a Redis lock before starting the loop;
# non-holders skip. Holder refreshes TTL each cycle so a dead worker's lock expires.
lock = redis.lock("monitoring:leader", timeout=interval * 3)
if not lock.acquire(blocking=False):
    logger.info("Monitoring loop not started in this worker (another worker holds the lease)")
    return

Alternatively: move the loop into the scheduler service (already single-process and lock-aware), or gate on an env flag set for exactly one worker.

Environment

  • Trinity version: 85a7a85a (docker-compose.prod.yml, --workers 2, PostgreSQL backend)

Related

Metadata

Metadata

Assignees

Labels

priority-p2Importantstatus-in-devMerged to dev, awaiting release cut to mainstatus-readyGreenlit and ready for development (vetted; counterpart to status-incubating)theme-reliabilityTheme: Reliabilitytype-bugBug fix

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions