Files
Nexus/server/background/heartbeat_flush.py
T

201 lines
7.1 KiB
Python
Raw Normal View History

"""Nexus — Heartbeat Flush Background Task
Every 10 minutes: batch-flush Redis heartbeat data to MySQL servers table.
MySQL only stores historical snapshots; frontend reads live data from Redis.
"""
import asyncio
import logging
from datetime import datetime, timezone
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.redis.client import get_redis
from server.domain.models import Server
from server.infrastructure.database.fleet_metric_repo import FleetMetricRepositoryImpl
from server.infrastructure.database.server_metric_repo import ServerMetricRepositoryImpl
from server.utils.fleet_metrics import aggregate_fleet_metrics
from server.utils.server_metrics import sample_from_redis_heartbeat
logger = logging.getLogger("nexus.heartbeat_flush")
FLUSH_INTERVAL = 600 # 10 minutes (seconds)
REDIS_KEY_PREFIX = "heartbeat:"
# Redis TTL for live keys: server.api.agent.REDIS_KEY_EXPIRE (= FLUSH_INTERVAL * 1.5)
async def _write_server_metric_samples(
session: AsyncSession,
redis_by_id: dict[int, dict],
recorded_at: datetime,
) -> int:
"""Insert per-server CPU/mem samples for all agent-installed servers."""
result = await session.execute(
select(Server.id).where(
Server.agent_version.isnot(None),
Server.agent_version != "",
)
)
agent_ids = [row[0] for row in result.all()]
if not agent_ids:
return 0
repo = ServerMetricRepositoryImpl(session)
written = 0
for server_id in agent_ids:
data = redis_by_id.get(server_id)
if data:
is_online, cpu_pct, mem_pct = sample_from_redis_heartbeat(data)
else:
is_online, cpu_pct, mem_pct = False, None, None
await repo.insert_sample(
server_id=server_id,
recorded_at=recorded_at,
is_online=is_online,
cpu_pct=cpu_pct,
mem_pct=mem_pct,
)
written += 1
return written
async def _flush_redis_keys(session: AsyncSession, keys: list[str], redis) -> tuple[int, int, list[dict], dict[int, dict]]:
"""Update servers from Redis keys; return fleet rows and id→heartbeat map."""
flushed = 0
skipped = 0
heartbeat_rows: list[dict] = []
redis_by_id: dict[int, dict] = {}
for key in keys:
try:
server_id = int(key.replace(REDIS_KEY_PREFIX, ""))
data = await redis.hgetall(key)
if not data:
continue
row = dict(data)
heartbeat_rows.append(row)
redis_by_id[server_id] = row
is_online = data.get("is_online") == "True"
system_info = data.get("system_info", "{}")
agent_version = data.get("agent_version", "")
last_hb_str = data.get("last_heartbeat", "")
try:
last_heartbeat = datetime.fromisoformat(last_hb_str)
if last_heartbeat.tzinfo is None:
last_heartbeat = last_heartbeat.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
last_heartbeat = datetime.now(timezone.utc)
result = await session.execute(
update(Server)
.where(Server.id == server_id)
.values(
is_online=is_online,
system_info=system_info,
last_heartbeat=last_heartbeat,
agent_version=agent_version,
)
)
if (result.rowcount or 0) > 0:
flushed += 1
else:
skipped += 1
except Exception as e:
logger.warning(f"Failed to flush server {key}: {e}")
skipped += 1
return flushed, skipped, heartbeat_rows, redis_by_id
async def _commit_flush_metrics(
session: AsyncSession,
heartbeat_rows: list[dict],
redis_by_id: dict[int, dict],
recorded_at: datetime,
) -> None:
fleet_repo = FleetMetricRepositoryImpl(session)
if heartbeat_rows:
agg = aggregate_fleet_metrics(heartbeat_rows)
await fleet_repo.insert_sample(
recorded_at=recorded_at,
online_count=agg.online_count,
sample_count=agg.sample_count,
cpu_avg=agg.cpu_avg,
mem_avg=agg.mem_avg,
disk_avg=agg.disk_avg,
)
purged_fleet = await fleet_repo.purge_older_than()
server_samples = await _write_server_metric_samples(session, redis_by_id, recorded_at)
server_repo = ServerMetricRepositoryImpl(session)
purged_server = await server_repo.purge_older_than()
await session.commit()
if purged_fleet:
logger.info("Fleet metric purge: removed %s rows older than retention", purged_fleet)
if purged_server:
logger.info("Server metric purge: removed %s rows older than retention", purged_server)
if server_samples:
logger.info("Server metric samples: wrote %s rows", server_samples)
async def heartbeat_flush_loop():
"""Background task: every 10min, flush Redis heartbeat data to MySQL.
Flow:
1. Scan all heartbeat:* keys in Redis
2. For each key, extract server_id + heartbeat data
3. Batch UPDATE MySQL servers table (is_online, system_info, last_heartbeat, agent_version)
4. Write fleet + per-server metric samples; purge retention
"""
logger.info("Heartbeat flush loop started (interval: 10min)")
while True:
await asyncio.sleep(FLUSH_INTERVAL)
redis = get_redis()
recorded_at = datetime.now(timezone.utc)
try:
keys: list[str] = []
cursor = 0
while True:
cursor, batch = await redis.scan(cursor, match=f"{REDIS_KEY_PREFIX}*", count=100)
keys.extend(batch)
if cursor == 0:
break
except Exception as e:
logger.error(f"Redis scan failed: {e}")
continue
if not keys:
logger.info("No heartbeat keys in Redis — writing offline server metric samples only")
async with AsyncSessionLocal() as session:
try:
await _commit_flush_metrics(session, [], {}, recorded_at)
except Exception as e:
logger.error(f"Heartbeat flush commit failed (no Redis keys): {e}")
await session.rollback()
continue
flushed = 0
skipped = 0
async with AsyncSessionLocal() as session:
try:
flushed, skipped, heartbeat_rows, redis_by_id = await _flush_redis_keys(
session, keys, redis
)
await _commit_flush_metrics(session, heartbeat_rows, redis_by_id, recorded_at)
except Exception as e:
logger.error(f"Heartbeat flush commit failed: {e}")
await session.rollback()
continue
logger.info(
f"Heartbeat flush: {flushed}/{len(keys)} servers updated to MySQL"
+ (f", {skipped} failed" if skipped else "")
)