intervall

This commit is contained in:
Bastian Wagner
2026-08-15 21:44:13 +02:00
parent f7b04337ce
commit ff5dab6f0d
10 changed files with 438 additions and 17 deletions

View File

@@ -89,6 +89,20 @@ class SystemLogEntry(Base):
user_id: Mapped[int | None] = mapped_column(ForeignKey("sync_users.id", ondelete="SET NULL"))
class SchedulerSettings(Base):
"""Singleton row (id is always 1) holding the admin-configurable day/night
sync interval -- editable at runtime via the UI, unlike SYNC_INTERVAL_MINUTES
which only seeds this row's initial values on first startup."""
__tablename__ = "scheduler_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
day_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=6)
night_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=22)
day_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
night_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
class SyncRun(Base):
__tablename__ = "sync_runs"

View File

@@ -5,7 +5,18 @@ from sqlalchemy import and_, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.db.models import Activity, ActivityStatus, SyncRun, SyncRunStatus, SystemLogEntry, SyncUser, utcnow
from app.db.models import (
Activity,
ActivityStatus,
SchedulerSettings,
SyncRun,
SyncRunStatus,
SystemLogEntry,
SyncUser,
utcnow,
)
_SCHEDULER_SETTINGS_ID = 1
@dataclass(frozen=True)
@@ -211,6 +222,29 @@ class SystemLogRepository:
)
class SchedulerSettingsRepository:
def __init__(self, session: Session) -> None:
self.session = session
def get_or_create(self, *, default_minutes: int = 5) -> SchedulerSettings:
row = self.session.get(SchedulerSettings, _SCHEDULER_SETTINGS_ID)
if row is None:
row = SchedulerSettings(
id=_SCHEDULER_SETTINGS_ID,
day_interval_minutes=default_minutes,
night_interval_minutes=default_minutes,
)
self.session.add(row)
self.session.commit()
return row
def update(self, row: SchedulerSettings, **values) -> SchedulerSettings:
for key, value in values.items():
setattr(row, key, value)
self.session.commit()
return row
class SyncRunRepository:
def __init__(self, session: Session) -> None:
self.session = session

View File

@@ -13,7 +13,7 @@ from app.mywhoosh.client import MyWhooshClient
from app.notifications.emailer import EmailNotifier
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager
from app.sync.scheduler import SyncScheduler
from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler
from app.web.account import router as account_router
from app.web.operations import router as operations_router
from app.web.routes import router as web_router
@@ -55,7 +55,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
)
app.state.sync_manager = sync_manager
scheduler = SyncScheduler(sync_manager, interval_seconds=resolved.sync_interval_minutes * 60)
interval_provider = DayNightIntervalProvider(
app.state.session_factory, default_minutes=resolved.sync_interval_minutes
)
scheduler = SyncScheduler(
sync_manager,
interval_seconds=resolved.sync_interval_minutes * 60,
interval_provider=interval_provider,
)
app.state.scheduler = scheduler
await scheduler.start()

View File

@@ -1,22 +1,80 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Callable
from app.db.repositories import SchedulerSettingsRepository
from app.sync.manager import SyncAlreadyRunning
logger = logging.getLogger(__name__)
def is_daytime(hour: int, day_start_hour: int, night_start_hour: int) -> bool:
"""True if `hour` falls within [day_start_hour, night_start_hour),
handling a day period that wraps past midnight (e.g. day_start=20,
night_start=6). Equal start hours are treated as "always day"."""
if day_start_hour == night_start_hour:
return True
if day_start_hour < night_start_hour:
return day_start_hour <= hour < night_start_hour
return hour >= day_start_hour or hour < night_start_hour
class DayNightIntervalProvider:
"""Reads the admin-configurable day/night interval from the DB on every
call, so a change made via the UI takes effect on the scheduler's very
next tick without an app restart."""
def __init__(self, session_factory, *, default_minutes: int, now: Callable[[], datetime] | None = None) -> None:
self.session_factory = session_factory
self.default_minutes = default_minutes
self._now = now or datetime.now
def __call__(self) -> float:
with self.session_factory() as session:
row = SchedulerSettingsRepository(session).get_or_create(default_minutes=self.default_minutes)
day_start_hour = row.day_start_hour
night_start_hour = row.night_start_hour
day_interval_minutes = row.day_interval_minutes
night_interval_minutes = row.night_interval_minutes
hour = self._now().hour
minutes = (
day_interval_minutes
if is_daytime(hour, day_start_hour, night_start_hour)
else night_interval_minutes
)
return minutes * 60
class SyncScheduler:
def __init__(self, manager, *, interval_seconds: float) -> None:
def __init__(
self,
manager,
*,
interval_seconds: float,
interval_provider: Callable[[], float] | None = None,
) -> None:
self.manager = manager
self.interval_seconds = interval_seconds
self.interval_provider = interval_provider
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
self.last_tick = None
self.next_tick = None
async def run_once(self) -> None:
def _current_interval(self) -> float:
if self.interval_provider is None:
return self.interval_seconds
try:
return float(self.interval_provider())
except Exception:
logger.warning("interval_provider failed, falling back to default interval", exc_info=True)
return self.interval_seconds
async def run_once(self, interval: float | None = None) -> None:
if interval is None:
interval = self._current_interval()
self.last_tick = datetime.now(timezone.utc)
try:
results = await self.manager.sync_all_enabled()
@@ -36,13 +94,14 @@ class SyncScheduler:
except Exception:
logger.exception("sync_all_enabled failed during scheduled tick")
finally:
self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=self.interval_seconds)
self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=interval)
async def _run(self) -> None:
while not self._stop.is_set():
await self.run_once()
interval = self._current_interval()
await self.run_once(interval)
try:
await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds)
await asyncio.wait_for(self._stop.wait(), timeout=interval)
except TimeoutError:
pass

View File

@@ -1,11 +1,11 @@
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import func, select
from app.auth.admin import require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import Activity
from app.db.repositories import ActivityRepository, SystemLogRepository, UserRepository
from app.db.repositories import ActivityRepository, SchedulerSettingsRepository, SystemLogRepository, UserRepository
from app.sync.manager import SyncAlreadyRunning
from app.web.routes import templates
@@ -104,13 +104,45 @@ def system_page(request: Request):
user_count = len(UserRepository(session).list_all())
activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0
log_entries = SystemLogRepository(session).list_recent(limit=50)
scheduler_settings = SchedulerSettingsRepository(session).get_or_create(
default_minutes=settings.sync_interval_minutes
)
return templates.TemplateResponse(request, "system.html", {
"csrf_token": ensure_csrf_token(request),
"app_version": APP_VERSION,
"sync_interval_minutes": settings.sync_interval_minutes,
"last_tick": scheduler.last_tick,
"next_tick": scheduler.next_tick,
"user_count": user_count,
"activity_count": activity_count,
"log_entries": log_entries,
"scheduler_settings": scheduler_settings,
})
@router.post("/system/scheduler-settings")
def update_scheduler_settings(
request: Request,
csrf_token: str = Form(...),
day_start_hour: int = Form(...),
night_start_hour: int = Form(...),
day_interval_minutes: int = Form(...),
night_interval_minutes: int = Form(...),
):
require_admin(request)
validate_csrf(request, csrf_token)
if not (0 <= day_start_hour <= 23) or not (0 <= night_start_hour <= 23):
raise HTTPException(status_code=400, detail="Hours must be between 0 and 23")
if day_interval_minutes < 1 or night_interval_minutes < 1:
raise HTTPException(status_code=400, detail="Intervals must be at least 1 minute")
settings = request.app.state.settings
with request.app.state.session_factory() as session:
repository = SchedulerSettingsRepository(session)
row = repository.get_or_create(default_minutes=settings.sync_interval_minutes)
repository.update(
row,
day_start_hour=day_start_hour,
night_start_hour=night_start_hour,
day_interval_minutes=day_interval_minutes,
night_interval_minutes=night_interval_minutes,
)
return RedirectResponse("/system", status_code=303)

View File

@@ -10,9 +10,6 @@
<dt>Application version</dt>
<dd>{{ app_version }}</dd>
<dt>Sync interval (minutes)</dt>
<dd>{{ sync_interval_minutes }}</dd>
<dt>Last scheduler tick</dt>
<dd>{{ last_tick or "-" }}</dd>
@@ -32,6 +29,31 @@
<button type="submit">Sync all now</button>
</form>
<h2>Sync scheduling</h2>
<div class="card">
<form method="post" action="/system/scheduler-settings" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="day_start_hour">Day starts at (hour, 0-23)</label>
<input type="number" id="day_start_hour" name="day_start_hour" min="0" max="23"
value="{{ scheduler_settings.day_start_hour }}" required>
<label for="day_interval_minutes">Sync interval during the day (minutes)</label>
<input type="number" id="day_interval_minutes" name="day_interval_minutes" min="1"
value="{{ scheduler_settings.day_interval_minutes }}" required>
<label for="night_start_hour">Night starts at (hour, 0-23)</label>
<input type="number" id="night_start_hour" name="night_start_hour" min="0" max="23"
value="{{ scheduler_settings.night_start_hour }}" required>
<label for="night_interval_minutes">Sync interval during the night (minutes)</label>
<input type="number" id="night_interval_minutes" name="night_interval_minutes" min="1"
value="{{ scheduler_settings.night_interval_minutes }}" required>
<button type="submit">Save</button>
</form>
</div>
<h2>System log</h2>
<div class="card">
{% if log_entries %}