Files
proxmox-snapshot-service/pvesnap/schedule.py
T
2026-07-30 16:52:58 +02:00

134 lines
4.4 KiB
Python

"""Berechnung der naechsten Ausfuehrungszeit einer Gruppe (lokale Zeit)."""
from __future__ import annotations
import calendar
from datetime import datetime, timedelta
from .config import WEEKDAY_NAMES, parse_time_of_day
from .util import format_duration
_EPOCH = datetime(1970, 1, 1)
def _midnight(moment):
return moment.replace(hour=0, minute=0, second=0, microsecond=0)
def _aligned_after(moment, interval):
"""Naechster an der Uhr ausgerichteter Zeitpunkt nach `moment`.
Intervalle, die glatt in einen Tag passen (5m, 15m, 1h, 6h, 12h), werden an
Mitternacht verankert - 6h ergibt also 00:00, 06:00, 12:00, 18:00.
Alles andere wird an der Epoche verankert.
"""
if interval <= 86400 and 86400 % interval == 0:
anchor = _midnight(moment)
else:
anchor = _EPOCH
elapsed = (moment - anchor).total_seconds()
steps = int(elapsed // interval) + 1
return anchor + timedelta(seconds=steps * interval)
def _at_time(day, hour, minute):
return day.replace(hour=hour, minute=minute, second=0, microsecond=0)
def _clamp_day(year, month, day):
return min(day, calendar.monthrange(year, month)[1])
def next_due(group, last_run, now):
"""Wann ist die Gruppe das naechste Mal faellig?
`last_run` ist der letzte tatsaechliche Lauf (oder None). Liegt das
Ergebnis in der Vergangenheit, ist die Gruppe sofort faellig - so werden
Laeufe nachgeholt, die waehrend eines Neustarts ausgefallen sind.
"""
if group.interval > 0:
if last_run is None:
base = now
else:
base = last_run
if group.align:
return _aligned_after(base, group.interval)
return base + timedelta(seconds=group.interval)
base = last_run if last_run is not None else now - timedelta(seconds=1)
kind = group.schedule
if kind == "hourly":
minute = max(0, min(59, group.minute))
candidate = base.replace(minute=minute, second=0, microsecond=0)
while candidate <= base:
candidate += timedelta(hours=1)
return candidate
hour, minute = parse_time_of_day(group.at)
if kind == "daily":
candidate = _at_time(base, hour, minute)
while candidate <= base:
candidate += timedelta(days=1)
return candidate
if kind == "weekly":
candidate = _at_time(base, hour, minute)
shift = (group.day_of_week - candidate.weekday()) % 7
candidate += timedelta(days=shift)
while candidate <= base:
candidate += timedelta(days=7)
return candidate
if kind == "monthly":
year, month = base.year, base.month
for _ in range(60):
day = _clamp_day(year, month, group.day_of_month)
candidate = _at_time(datetime(year, month, day), hour, minute)
if candidate > base:
return candidate
month += 1
if month > 12:
month, year = 1, year + 1
raise ValueError("kein monatlicher Termin ermittelbar")
if kind == "yearly":
year = base.year
month = max(1, min(12, group.month))
for _ in range(5):
day = _clamp_day(year, month, group.day_of_month)
candidate = _at_time(datetime(year, month, day), hour, minute)
if candidate > base:
return candidate
year += 1
raise ValueError("kein jaehrlicher Termin ermittelbar")
raise ValueError("unbekannter Zeitplan: %r" % kind)
def is_due(group, last_run, now, run_on_start=False):
if not group.enabled:
return False
if last_run is None and run_on_start:
return True
return next_due(group, last_run, now) <= now
def describe(group):
"""Zeitplan der Gruppe als kurzer, lesbarer Text."""
if group.interval > 0:
text = "alle %s" % format_duration(group.interval)
return text + (" (an der Uhr ausgerichtet)" if group.align else "")
if group.schedule == "hourly":
return "stuendlich zur Minute %02d" % group.minute
if group.schedule == "daily":
return "taeglich um %s" % group.at
if group.schedule == "weekly":
return "woechentlich %s um %s" % (WEEKDAY_NAMES[group.day_of_week], group.at)
if group.schedule == "monthly":
return "monatlich am %d. um %s" % (group.day_of_month, group.at)
if group.schedule == "yearly":
return "jaehrlich am %d.%d. um %s" % (group.day_of_month, group.month, group.at)
return "kein Zeitplan"