pulse-guard / control_plane /controller.py
hemalathashetty
Deploy PulseGuard X self-healing control plane to Hugging Face Spaces
3cb0e2c
Raw
History Blame Contribute Delete
18.2 kB
import asyncio
import json
import time
import logging
import numpy as np
from typing import Dict, List, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from database import async_session
from models import AuditLog, ReliabilityEvent
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("reliability-controller")
SERVICES = ["service-a", "service-b", "service-c"]
SLIDING_WINDOW_SEC = 10.0
DEFAULT_RPS = 100.0
MIN_RPS = 1.0
# Store state in memory
class ServiceState:
def __init__(self, name: str):
self.name = name
self.current_limit = DEFAULT_RPS
self.circuit_state = "CLOSED" # CLOSED, OPEN, HALF_OPEN
self.last_circuit_change = time.time()
# For anti-flapping
self.healthy_ticks = 0
self.history_latencies = []
self.history_errors = []
self.ema_latency = 0.0
self.ema_error = 0.0
service_states = {s: ServiceState(s) for s in SERVICES}
async def publish_event(redis_client, event_type: str, service: str, severity: str, message: str, db_session: AsyncSession):
"""Saves event to Postgres and broadcasts to Redis Pub/Sub."""
logger.info(f"[{severity}] Service {service}: {message}")
event_dict = {
"event_type": event_type,
"service": service,
"severity": severity,
"message": message,
"timestamp": time.time()
}
# Save to PostgreSQL
event_db = ReliabilityEvent(
event_type=event_type,
service=service,
severity=severity,
message=message
)
db_session.add(event_db)
# Publish to Redis
await redis_client.publish("reliability_events", json.dumps(event_dict))
async def save_audit_log(service: str, action: str, old_val: str, new_val: str, reason: str, metrics: dict, db_session: AsyncSession):
"""Saves a controller decision to the database."""
log = AuditLog(
service=service,
action=action,
old_value=old_val,
new_value=new_val,
reason=reason,
trigger_metrics=metrics
)
db_session.add(log)
async def evaluate_service_metrics(redis_client, service: str) -> Dict[str, Any]:
"""Fetches telemetry logs from Redis and computes statistics."""
now = time.time()
cutoff = now - SLIDING_WINDOW_SEC
# Fetch records in sliding window
records_raw = await redis_client.zrangebyscore(f"metrics:raw:{service}", cutoff, now)
total_reqs = 0
errors = 0
latencies = []
for r in records_raw:
try:
data = json.loads(r)
total_reqs += 1
latencies.append(data.get("latency_ms", 0.0) / 1000.0) # convert to seconds
status = int(data.get("status_code", 200))
if status in [500, 502, 504] or data.get("downstream_error", False):
errors += 1
except Exception:
pass
if total_reqs == 0:
# Check if service is responsive via health checks
try:
# We can check if any recent ping is there, if not, treat as unreachable
is_unreachable = True
except Exception:
is_unreachable = True
return {
"total_requests": 0,
"error_rate": 0.0,
"p50": 0.0,
"p95": 0.0,
"p99": 0.0,
"rps": 0.0,
"status": "UNREACHABLE"
}
latencies = sorted(latencies)
p50 = float(np.percentile(latencies, 50)) if latencies else 0.0
p95 = float(np.percentile(latencies, 95)) if latencies else 0.0
p99 = float(np.percentile(latencies, 99)) if latencies else 0.0
error_rate = float(errors) / total_reqs
rps = float(total_reqs) / SLIDING_WINDOW_SEC
status = "HEALTHY"
if error_rate > 0.10 or p95 > 0.50:
status = "CRITICAL"
elif error_rate > 0.02 or p95 > 0.20:
status = "DEGRADED"
return {
"total_requests": total_reqs,
"error_rate": error_rate,
"p50": p50,
"p95": p95,
"p99": p99,
"rps": rps,
"status": status,
"latencies_raw": latencies
}
async def run_reliability_loop(redis_client):
"""Main Reliability Controller Loop running every 2 seconds."""
logger.info("Reliability control loop started.")
# Make sure default Redis keys are set
for s in SERVICES:
await redis_client.set(f"limit:capacity:{s}", str(DEFAULT_RPS))
await redis_client.set(f"limit:rate:{s}", str(DEFAULT_RPS))
await redis_client.set(f"circuit_breaker:state:{s}", "CLOSED")
while True:
await asyncio.sleep(2.0)
async with async_session() as db_session:
try:
metrics_summary = {}
# Step 1: Collect Metrics
for s in SERVICES:
metrics_summary[s] = await evaluate_service_metrics(redis_client, s)
# Sync states in Redis
for s in SERVICES:
metrics = metrics_summary[s]
state = service_states[s]
# Update EMA (smooth calculations to prevent flapping)
alpha = 0.3
state.ema_latency = (alpha * metrics["p95"]) + ((1 - alpha) * state.ema_latency)
state.ema_error = (alpha * metrics["error_rate"]) + ((1 - alpha) * state.ema_error)
# Store history for anomaly detection
state.history_latencies.append(metrics["p95"])
state.history_errors.append(metrics["error_rate"])
if len(state.history_latencies) > 30: # 1 minute history
state.history_latencies.pop(0)
state.history_errors.pop(0)
# Anomaly Detection: Latency Spike Check
if len(state.history_latencies) >= 5:
mean = np.mean(state.history_latencies[:-1])
std = np.std(state.history_latencies[:-1])
if std > 0.01 and metrics["p95"] > mean + 3 * std:
await publish_event(
redis_client, "ANOMALY_DETECTED", s, "WARNING",
f"Anomaly Detected: Latency Spike! Current P95={metrics['p95']*1000:.1f}ms exceeds threshold. Mean={mean*1000:.1f}ms, Std={std*1000:.1f}ms",
db_session
)
# Quick clamp limit to mitigate immediate load
old_limit = state.current_limit
state.current_limit = max(MIN_RPS, state.current_limit * 0.4)
await redis_client.set(f"limit:capacity:{s}", str(state.current_limit))
await redis_client.set(f"limit:rate:{s}", str(state.current_limit))
await save_audit_log(
s, "ADAPTIVE_LIMIT_CLAMPED", str(old_limit), str(state.current_limit),
"Anomaly detection triggered immediate clamp due to latency spike",
metrics, db_session
)
# Step 2: Evaluate State Machines & Backpressure propagation
# We analyze from downstream (C) to upstream (A)
for s in reversed(SERVICES):
metrics = metrics_summary[s]
state = service_states[s]
now = time.time()
# Circuit Breaker Logic
if state.circuit_state == "CLOSED":
# If critical error rate or latency is exceeded, open circuit
if metrics["error_rate"] >= 0.10 or metrics["p95"] >= 0.50:
state.circuit_state = "OPEN"
state.last_circuit_change = now
await redis_client.set(f"circuit_breaker:state:{s}", "OPEN")
await publish_event(
redis_client, "CIRCUIT_OPENED", s, "CRITICAL",
f"Circuit breaker OPENED. Error Rate={metrics['error_rate']*100:.1f}%, P95 Latency={metrics['p95']*1000:.1f}ms",
db_session
)
# Instantly drop limits to minimum
old_limit = state.current_limit
state.current_limit = MIN_RPS
await redis_client.set(f"limit:capacity:{s}", str(MIN_RPS))
await redis_client.set(f"limit:rate:{s}", str(MIN_RPS))
await save_audit_log(
s, "CIRCUIT_OPEN", str(old_limit), str(MIN_RPS),
"Circuit opened due to critical failures. Rate limits clamped.",
metrics, db_session
)
elif state.circuit_state == "OPEN":
# Stay open for 8 seconds, then transition to HALF_OPEN
if now - state.last_circuit_change > 8.0:
state.circuit_state = "HALF_OPEN"
state.last_circuit_change = now
await redis_client.set(f"circuit_breaker:state:{s}", "HALF_OPEN")
await publish_event(
redis_client, "CIRCUIT_HALF_OPEN", s, "WARNING",
"Circuit transitioned to HALF-OPEN. Probing downstream connectivity.",
db_session
)
elif state.circuit_state == "HALF_OPEN":
# If errors still persist in Half-Open, return to OPEN
if metrics["error_rate"] > 0.05:
state.circuit_state = "OPEN"
state.last_circuit_change = now
await redis_client.set(f"circuit_breaker:state:{s}", "OPEN")
await publish_event(
redis_client, "CIRCUIT_REOPENED", s, "CRITICAL",
f"HALF-OPEN probe failed. Circuit re-opened. Error Rate={metrics['error_rate']*100:.1f}%",
db_session
)
# If healthy over multiple evaluations, close the circuit
elif metrics["total_requests"] >= 1 and metrics["error_rate"] <= 0.01:
state.healthy_ticks += 1
if state.healthy_ticks >= 2: # Sustained health (4 seconds)
state.circuit_state = "CLOSED"
state.healthy_ticks = 0
state.last_circuit_change = now
await redis_client.set(f"circuit_breaker:state:{s}", "CLOSED")
await publish_event(
redis_client, "CIRCUIT_CLOSED", s, "INFO",
"Circuit closed. Normal operations fully restored.",
db_session
)
# Gradually scale up limits
old_limit = state.current_limit
state.current_limit = 20.0
await redis_client.set(f"limit:capacity:{s}", str(state.current_limit))
await redis_client.set(f"limit:rate:{s}", str(state.current_limit))
await save_audit_log(
s, "CIRCUIT_CLOSED_RECOVERY", str(old_limit), str(state.current_limit),
"Circuit closed. Starting gradual load recovery.",
metrics, db_session
)
# Adaptive Throttling and Backpressure logic for CLOSED circuit
if state.circuit_state == "CLOSED":
if metrics["status"] == "CRITICAL":
# Clamp limit down by 60%
old_limit = state.current_limit
state.current_limit = max(MIN_RPS, state.current_limit * 0.4)
await redis_client.set(f"limit:capacity:{s}", str(state.current_limit))
await redis_client.set(f"limit:rate:{s}", str(state.current_limit))
await save_audit_log(
s, "LIMIT_CLAMPED_CRITICAL", str(old_limit), str(state.current_limit),
f"Critical condition. P95={metrics['p95']*1000:.1f}ms, Error Rate={metrics['error_rate']*100:.1f}%",
metrics, db_session
)
elif metrics["status"] == "DEGRADED":
# Clamp limit down by 30%
old_limit = state.current_limit
state.current_limit = max(MIN_RPS, state.current_limit * 0.7)
await redis_client.set(f"limit:capacity:{s}", str(state.current_limit))
await redis_client.set(f"limit:rate:{s}", str(state.current_limit))
await save_audit_log(
s, "LIMIT_CLAMPED_DEGRADED", str(old_limit), str(state.current_limit),
f"Degraded condition. P95={metrics['p95']*1000:.1f}ms, Error Rate={metrics['error_rate']*100:.1f}%",
metrics, db_session
)
elif metrics["status"] == "HEALTHY":
# If capacity is not at default, gradually restore
if state.current_limit < DEFAULT_RPS:
old_limit = state.current_limit
# Incremental increase
state.current_limit = min(DEFAULT_RPS, state.current_limit + 20.0)
await redis_client.set(f"limit:capacity:{s}", str(state.current_limit))
await redis_client.set(f"limit:rate:{s}", str(state.current_limit))
await save_audit_log(
s, "LIMIT_RESTORED_STEP", str(old_limit), str(state.current_limit),
"Gradual self-healing recovery step.",
metrics, db_session
)
# Step 3: Coordinate Backpressure Propagation
# If service C degrades/opens, we immediately reduce limits of B and A
# If B degrades/opens, we immediately reduce limits of A
# Propagation C -> B -> A
if service_states["service-c"].circuit_state in ["OPEN", "HALF_OPEN"] or service_states["service-c"].current_limit < DEFAULT_RPS:
c_health_ratio = service_states["service-c"].current_limit / DEFAULT_RPS
# Clamp B to at most B's current capacity scaled by C's health, or match C's limit
target_b_limit = min(service_states["service-b"].current_limit, service_states["service-c"].current_limit)
if service_states["service-b"].current_limit > target_b_limit:
old = service_states["service-b"].current_limit
service_states["service-b"].current_limit = max(MIN_RPS, target_b_limit)
await redis_client.set(f"limit:capacity:service-b", str(service_states["service-b"].current_limit))
await redis_client.set(f"limit:rate:service-b", str(service_states["service-b"].current_limit))
await save_audit_log(
"service-b", "BACKPRESSURE_PROPAGATED", str(old), str(service_states["service-b"].current_limit),
"Backpressure propagated from degraded service-c",
metrics_summary["service-b"], db_session
)
if service_states["service-b"].circuit_state in ["OPEN", "HALF_OPEN"] or service_states["service-b"].current_limit < DEFAULT_RPS:
target_a_limit = min(service_states["service-a"].current_limit, service_states["service-b"].current_limit)
if service_states["service-a"].current_limit > target_a_limit:
old = service_states["service-a"].current_limit
service_states["service-a"].current_limit = max(MIN_RPS, target_a_limit)
await redis_client.set(f"limit:capacity:service-a", str(service_states["service-a"].current_limit))
await redis_client.set(f"limit:rate:service-a", str(service_states["service-a"].current_limit))
await save_audit_log(
"service-a", "BACKPRESSURE_PROPAGATED", str(old), str(service_states["service-a"].current_limit),
"Backpressure propagated from degraded service-b",
metrics_summary["service-a"], db_session
)
# Commit all database changes (audit logs & events)
await db_session.commit()
except Exception as e:
logger.error(f"Error in reliability control loop: {e}")
await db_session.rollback()