feat: Slot-basierter Auto-Refresh mit konfigurierbarer Startzeit

Auto-Refresh nutzt jetzt eine feste Anker-Uhrzeit (refresh_start_time) statt
reinem Intervall-basiertem Driften. Verpasste Slots werden max. 1x aufgeholt.
Bestehendes Intervall-Verhalten bleibt als Fallback erhalten (ohne Startzeit).

Migration: Bestehende Auto-Lagen erhalten 07:00 als Startzeit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Dev
2026-04-03 13:14:34 +02:00
Ursprung 68c6666d87
Commit d3e8c0adc7
6 geänderte Dateien mit 93 neuen und 20 gelöschten Zeilen

Datei anzeigen

@@ -5,7 +5,7 @@ import logging
import os
import sys
from contextlib import asynccontextmanager
from datetime import datetime
from datetime import datetime, timedelta
from typing import Dict
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response
@@ -107,11 +107,11 @@ scheduler = AsyncIOScheduler()
async def check_auto_refresh():
"""Prüft welche Lagen einen Auto-Refresh brauchen."""
"""Prüft welche Lagen einen Auto-Refresh brauchen (Slot-basiert)."""
db = await get_db()
try:
cursor = await db.execute(
"SELECT id, refresh_interval FROM incidents WHERE status = 'active' AND refresh_mode = 'auto'"
"SELECT id, refresh_interval, refresh_start_time FROM incidents WHERE status = 'active' AND refresh_mode = 'auto'"
)
incidents = await cursor.fetchall()
@@ -120,18 +120,72 @@ async def check_auto_refresh():
for incident in incidents:
incident_id = incident["id"]
interval = incident["refresh_interval"]
start_time_str = incident["refresh_start_time"]
# Letzten abgeschlossenen Refresh prüfen (egal ob auto oder manual)
# Letzten abgeschlossenen oder laufenden Refresh pruefen
cursor = await db.execute(
"SELECT started_at FROM refresh_log WHERE incident_id = ? AND status = 'completed' ORDER BY id DESC LIMIT 1",
"SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running') ORDER BY id DESC LIMIT 1",
(incident_id,),
)
last_refresh = await cursor.fetchone()
# Laufenden Refresh ueberspringen
if last_refresh and last_refresh["status"] == "running":
logger.debug(f"Auto-Refresh Lage {incident_id}: uebersprungen (laeuft bereits)")
continue
should_refresh = False
if not last_refresh:
# Noch nie gelaufen -> sofort starten
should_refresh = True
logger.info(f"Auto-Refresh Lage {incident_id}: erster Refresh")
elif start_time_str:
# Slot-basierte Logik: Naechsten faelligen Slot berechnen
try:
start_h, start_m = map(int, start_time_str.split(":"))
except (ValueError, AttributeError):
logger.warning(f"Auto-Refresh Lage {incident_id}: ungueltiges Startzeit-Format '{start_time_str}'")
continue
last_time = datetime.fromisoformat(last_refresh["started_at"])
if last_time.tzinfo is None:
last_time = last_time.replace(tzinfo=TIMEZONE)
else:
last_time = last_time.astimezone(TIMEZONE)
# Anker: heute um start_time
anchor_today = now.replace(hour=start_h, minute=start_m, second=0, microsecond=0)
interval_td = timedelta(minutes=interval)
if interval >= 1440:
# Taeglicher oder laengerer Rhythmus
days_interval = interval // 1440
# Letzter Slot der <= now ist
current_slot = anchor_today
if current_slot > now:
current_slot -= timedelta(days=days_interval)
# Sicherheitsschleife: weiter zurueck falls noetig
while current_slot > now:
current_slot -= timedelta(days=days_interval)
else:
# Untertaegig: Slots ab Anker im Intervall-Takt
# Anker zurueck bis vor last_refresh
ref_anchor = anchor_today
while ref_anchor > last_time:
ref_anchor -= interval_td
# Von dort vorwaerts bis zum letzten Slot <= now
current_slot = ref_anchor
while current_slot + interval_td <= now:
current_slot += interval_td
if current_slot > last_time:
should_refresh = True
logger.info(f"Auto-Refresh Lage {incident_id}: Slot {current_slot.strftime('%H:%M')} faellig (letzter Refresh: {last_time.strftime('%Y-%m-%d %H:%M')})")
else:
logger.debug(f"Auto-Refresh Lage {incident_id}: kein faelliger Slot (letzter: {current_slot.strftime('%H:%M')})")
else:
# Fallback: altes Intervall-Verhalten (kein start_time gesetzt)
last_time = datetime.fromisoformat(last_refresh["started_at"])
if last_time.tzinfo is None:
last_time = last_time.replace(tzinfo=TIMEZONE)
@@ -145,15 +199,6 @@ async def check_auto_refresh():
logger.debug(f"Auto-Refresh Lage {incident_id}: {elapsed:.1f}/{interval} Min — noch nicht faellig")
if should_refresh:
# Prüfen ob bereits ein laufender Refresh existiert
running_cursor = await db.execute(
"SELECT id FROM refresh_log WHERE incident_id = ? AND status = 'running' LIMIT 1",
(incident_id,),
)
if await running_cursor.fetchone():
logger.debug(f"Auto-Refresh Lage {incident_id}: uebersprungen (laeuft bereits)")
continue
await orchestrator.enqueue_refresh(incident_id, trigger_type="auto")
except Exception as e: