201 lines
7.1 KiB
Python
201 lines
7.1 KiB
Python
"""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 "")
|
|
)
|