Add ruff config to exclude alembic E402, SQLAlchemy F821, and pre-existing E501 line-length issues. Auto-fix 69 unused imports and 2 f-strings without placeholders. Manually fix 8 unused variables. Apply ruff format to 127 files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
121 lines
3.7 KiB
Python
121 lines
3.7 KiB
Python
"""NATS subscriber for config change events from the Go poller.
|
|
|
|
Triggers automatic backups when out-of-band config changes are detected,
|
|
with 5-minute deduplication to prevent rapid-fire backups.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Optional
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.config import settings
|
|
from app.database import AdminAsyncSessionLocal
|
|
from app.models.config_backup import ConfigBackupRun
|
|
from app.services import backup_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEDUP_WINDOW_MINUTES = 5
|
|
|
|
_nc: Optional[Any] = None
|
|
|
|
|
|
async def _last_backup_within_dedup_window(device_id: str) -> bool:
|
|
"""Check if a backup was created for this device in the last N minutes."""
|
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=DEDUP_WINDOW_MINUTES)
|
|
async with AdminAsyncSessionLocal() as session:
|
|
result = await session.execute(
|
|
select(ConfigBackupRun)
|
|
.where(
|
|
ConfigBackupRun.device_id == device_id,
|
|
ConfigBackupRun.created_at > cutoff,
|
|
)
|
|
.limit(1)
|
|
)
|
|
return result.scalar_one_or_none() is not None
|
|
|
|
|
|
async def handle_config_changed(event: dict) -> None:
|
|
"""Handle a config change event. Trigger backup with dedup."""
|
|
device_id = event.get("device_id")
|
|
tenant_id = event.get("tenant_id")
|
|
|
|
if not device_id or not tenant_id:
|
|
logger.warning("Config change event missing device_id or tenant_id: %s", event)
|
|
return
|
|
|
|
# Dedup check
|
|
if await _last_backup_within_dedup_window(device_id):
|
|
logger.info(
|
|
"Config change on device %s — skipping backup (within %dm dedup window)",
|
|
device_id,
|
|
DEDUP_WINDOW_MINUTES,
|
|
)
|
|
return
|
|
|
|
logger.info(
|
|
"Config change detected on device %s (tenant %s): %s -> %s",
|
|
device_id,
|
|
tenant_id,
|
|
event.get("old_timestamp", "?"),
|
|
event.get("new_timestamp", "?"),
|
|
)
|
|
|
|
try:
|
|
async with AdminAsyncSessionLocal() as session:
|
|
await backup_service.run_backup(
|
|
device_id=device_id,
|
|
tenant_id=tenant_id,
|
|
trigger_type="config-change",
|
|
db_session=session,
|
|
)
|
|
await session.commit()
|
|
logger.info("Config-change backup completed for device %s", device_id)
|
|
except Exception as e:
|
|
logger.error("Config-change backup failed for device %s: %s", device_id, e)
|
|
|
|
|
|
async def _on_message(msg) -> None:
|
|
"""NATS message handler for config.changed.> subjects."""
|
|
try:
|
|
event = json.loads(msg.data.decode())
|
|
await handle_config_changed(event)
|
|
await msg.ack()
|
|
except Exception as e:
|
|
logger.error("Error handling config change message: %s", e)
|
|
await msg.nak()
|
|
|
|
|
|
async def start_config_change_subscriber() -> Optional[Any]:
|
|
"""Connect to NATS and subscribe to config.changed.> events."""
|
|
import nats
|
|
|
|
global _nc
|
|
try:
|
|
logger.info("NATS config-change: connecting to %s", settings.NATS_URL)
|
|
_nc = await nats.connect(settings.NATS_URL)
|
|
js = _nc.jetstream()
|
|
await js.subscribe(
|
|
"config.changed.>",
|
|
cb=_on_message,
|
|
durable="api-config-change-consumer",
|
|
stream="DEVICE_EVENTS",
|
|
manual_ack=True,
|
|
)
|
|
logger.info("Config change subscriber started")
|
|
return _nc
|
|
except Exception as e:
|
|
logger.error("Failed to start config change subscriber: %s", e)
|
|
return None
|
|
|
|
|
|
async def stop_config_change_subscriber() -> None:
|
|
"""Gracefully close the NATS connection."""
|
|
global _nc
|
|
if _nc:
|
|
await _nc.drain()
|
|
_nc = None
|