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")) 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): class SyncRun(Base):
__tablename__ = "sync_runs" __tablename__ = "sync_runs"

View File

@@ -5,7 +5,18 @@ from sqlalchemy import and_, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session 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) @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: class SyncRunRepository:
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
self.session = session self.session = session

View File

@@ -13,7 +13,7 @@ from app.mywhoosh.client import MyWhooshClient
from app.notifications.emailer import EmailNotifier from app.notifications.emailer import EmailNotifier
from app.security.credentials import CredentialCipher from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager 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.account import router as account_router
from app.web.operations import router as operations_router from app.web.operations import router as operations_router
from app.web.routes import router as web_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 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 app.state.scheduler = scheduler
await scheduler.start() await scheduler.start()

View File

@@ -1,22 +1,80 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Callable
from app.db.repositories import SchedulerSettingsRepository
from app.sync.manager import SyncAlreadyRunning from app.sync.manager import SyncAlreadyRunning
logger = logging.getLogger(__name__) 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: 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.manager = manager
self.interval_seconds = interval_seconds self.interval_seconds = interval_seconds
self.interval_provider = interval_provider
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._stop = asyncio.Event() self._stop = asyncio.Event()
self.last_tick = None self.last_tick = None
self.next_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) self.last_tick = datetime.now(timezone.utc)
try: try:
results = await self.manager.sync_all_enabled() results = await self.manager.sync_all_enabled()
@@ -36,13 +94,14 @@ class SyncScheduler:
except Exception: except Exception:
logger.exception("sync_all_enabled failed during scheduled tick") logger.exception("sync_all_enabled failed during scheduled tick")
finally: 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: async def _run(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():
await self.run_once() interval = self._current_interval()
await self.run_once(interval)
try: try:
await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds) await asyncio.wait_for(self._stop.wait(), timeout=interval)
except TimeoutError: except TimeoutError:
pass pass

View File

@@ -1,11 +1,11 @@
from fastapi import APIRouter, Form, HTTPException, Request from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import func, select from sqlalchemy import func, select
from app.auth.admin import require_admin from app.auth.admin import require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import Activity 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.sync.manager import SyncAlreadyRunning
from app.web.routes import templates from app.web.routes import templates
@@ -104,13 +104,45 @@ def system_page(request: Request):
user_count = len(UserRepository(session).list_all()) user_count = len(UserRepository(session).list_all())
activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0 activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0
log_entries = SystemLogRepository(session).list_recent(limit=50) 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", { return templates.TemplateResponse(request, "system.html", {
"csrf_token": ensure_csrf_token(request), "csrf_token": ensure_csrf_token(request),
"app_version": APP_VERSION, "app_version": APP_VERSION,
"sync_interval_minutes": settings.sync_interval_minutes,
"last_tick": scheduler.last_tick, "last_tick": scheduler.last_tick,
"next_tick": scheduler.next_tick, "next_tick": scheduler.next_tick,
"user_count": user_count, "user_count": user_count,
"activity_count": activity_count, "activity_count": activity_count,
"log_entries": log_entries, "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> <dt>Application version</dt>
<dd>{{ app_version }}</dd> <dd>{{ app_version }}</dd>
<dt>Sync interval (minutes)</dt>
<dd>{{ sync_interval_minutes }}</dd>
<dt>Last scheduler tick</dt> <dt>Last scheduler tick</dt>
<dd>{{ last_tick or "-" }}</dd> <dd>{{ last_tick or "-" }}</dd>
@@ -32,6 +29,31 @@
<button type="submit">Sync all now</button> <button type="submit">Sync all now</button>
</form> </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> <h2>System log</h2>
<div class="card"> <div class="card">
{% if log_entries %} {% if log_entries %}

View File

@@ -9,7 +9,13 @@ from sqlalchemy.pool import StaticPool
from app.config import Settings from app.config import Settings
from app.db.models import Activity, Base, HealthState from app.db.models import Activity, Base, HealthState
from app.db.repositories import ActivityRepository, SyncRunRepository, SystemLogRepository, UserRepository from app.db.repositories import (
ActivityRepository,
SchedulerSettingsRepository,
SyncRunRepository,
SystemLogRepository,
UserRepository,
)
from app.main import create_app from app.main import create_app
@@ -44,6 +50,11 @@ def sync_run_repository(db_session: Session) -> SyncRunRepository:
return SyncRunRepository(db_session) return SyncRunRepository(db_session)
@pytest.fixture
def scheduler_settings_repository(db_session: Session) -> SchedulerSettingsRepository:
return SchedulerSettingsRepository(db_session)
@pytest.fixture @pytest.fixture
def system_log_repository(db_session: Session) -> SystemLogRepository: def system_log_repository(db_session: Session) -> SystemLogRepository:
return SystemLogRepository(db_session) return SystemLogRepository(db_session)

View File

@@ -93,3 +93,39 @@ def test_system_log_respects_limit(system_log_repository) -> None:
entries = system_log_repository.list_recent(limit=2) entries = system_log_repository.list_recent(limit=2)
assert len(entries) == 2 assert len(entries) == 2
def test_scheduler_settings_get_or_create_seeds_defaults(scheduler_settings_repository) -> None:
row = scheduler_settings_repository.get_or_create(default_minutes=7)
assert row.day_interval_minutes == 7
assert row.night_interval_minutes == 7
assert row.day_start_hour == 6
assert row.night_start_hour == 22
def test_scheduler_settings_get_or_create_is_idempotent_after_update(scheduler_settings_repository) -> None:
row = scheduler_settings_repository.get_or_create(default_minutes=5)
scheduler_settings_repository.update(row, day_interval_minutes=15)
reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
assert reloaded.day_interval_minutes == 15
def test_scheduler_settings_update_persists_all_fields(scheduler_settings_repository) -> None:
row = scheduler_settings_repository.get_or_create(default_minutes=5)
scheduler_settings_repository.update(
row,
day_start_hour=8,
night_start_hour=20,
day_interval_minutes=10,
night_interval_minutes=45,
)
reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
assert reloaded.day_start_hour == 8
assert reloaded.night_start_hour == 20
assert reloaded.day_interval_minutes == 10
assert reloaded.night_interval_minutes == 45

View File

@@ -1,8 +1,14 @@
import asyncio import asyncio
from datetime import datetime
import pytest import pytest
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from app.sync.scheduler import SyncScheduler from app.db.models import Base
from app.db.repositories import SchedulerSettingsRepository
from app.db.session import create_session_factory
from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler, is_daytime
class FakeSyncManager: class FakeSyncManager:
@@ -40,3 +46,91 @@ async def test_scheduler_stop_cancels_the_loop() -> None:
calls_after_stop = fake.calls calls_after_stop = fake.calls
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
assert fake.calls == calls_after_stop assert fake.calls == calls_after_stop
@pytest.mark.parametrize(
("hour", "day_start", "night_start", "expected"),
[
(6, 6, 22, True),
(21, 6, 22, True),
(5, 6, 22, False),
(22, 6, 22, False),
(0, 6, 22, False),
# wraps past midnight: day period is [20, 6)
(23, 20, 6, True),
(2, 20, 6, True),
(10, 20, 6, False),
(20, 20, 6, True),
(6, 20, 6, False),
# degenerate: identical start hours means always day
(13, 9, 9, True),
],
)
def test_is_daytime(hour, day_start, night_start, expected) -> None:
assert is_daytime(hour, day_start, night_start) is expected
@pytest.fixture
def db_session_factory():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
factory = create_session_factory(engine)
try:
yield factory
finally:
engine.dispose()
@pytest.mark.asyncio
async def test_interval_provider_uses_day_interval_during_the_day(db_session_factory) -> None:
provider = DayNightIntervalProvider(
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
)
assert provider() == 5 * 60
@pytest.mark.asyncio
async def test_interval_provider_uses_night_interval_at_night(db_session_factory) -> None:
with db_session_factory() as session:
repo = SchedulerSettingsRepository(session)
row = repo.get_or_create(default_minutes=5)
repo.update(row, night_interval_minutes=30)
provider = DayNightIntervalProvider(
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 23, 0)
)
assert provider() == 30 * 60
@pytest.mark.asyncio
async def test_scheduler_uses_interval_provider_for_next_tick(db_session_factory) -> None:
with db_session_factory() as session:
repo = SchedulerSettingsRepository(session)
row = repo.get_or_create(default_minutes=5)
repo.update(row, day_interval_minutes=1, night_interval_minutes=1)
provider = DayNightIntervalProvider(
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
)
fake = FakeSyncManager()
scheduler = SyncScheduler(fake, interval_seconds=999, interval_provider=provider)
await scheduler.run_once()
expected_seconds = 60 # day_interval_minutes=1 -> 60s, not the unrelated interval_seconds=999 fallback
delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
assert abs(delta - expected_seconds) < 1
@pytest.mark.asyncio
async def test_scheduler_falls_back_when_interval_provider_raises() -> None:
def broken_provider():
raise RuntimeError("db unavailable")
fake = FakeSyncManager()
scheduler = SyncScheduler(fake, interval_seconds=42, interval_provider=broken_provider)
await scheduler.run_once()
delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
assert abs(delta - 42) < 1

View File

@@ -1,6 +1,6 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.db.repositories import SystemLogRepository from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository
def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None: def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None:
@@ -89,3 +89,115 @@ def test_system_page_shows_recorded_log_entries(app, authenticated_client) -> No
assert response.status_code == 200 assert response.status_code == 200
assert "email_notification" in response.text assert "email_notification" in response.text
assert "SMTP timeout" in response.text assert "SMTP timeout" in response.text
def _extract_csrf(html: str) -> str:
marker = 'name="csrf_token" value="'
start = html.index(marker) + len(marker)
end = html.index('"', start)
return html[start:end]
def test_scheduler_settings_page_prefills_defaults(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
response = authenticated_client.get("/system")
assert response.status_code == 200
assert 'id="day_start_hour"' in response.text
assert 'value="6"' in response.text
assert 'value="22"' in response.text
def test_update_scheduler_settings_persists_and_takes_effect_next_tick(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
page = authenticated_client.get("/system")
csrf = _extract_csrf(page.text)
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": csrf,
"day_start_hour": "8",
"night_start_hour": "20",
"day_interval_minutes": "3",
"night_interval_minutes": "45",
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/system"
with app.state.session_factory() as session:
row = SchedulerSettingsRepository(session).get_or_create(default_minutes=5)
assert row.day_start_hour == 8
assert row.night_start_hour == 20
assert row.day_interval_minutes == 3
assert row.night_interval_minutes == 45
def test_update_scheduler_settings_rejects_out_of_range_hour(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
page = authenticated_client.get("/system")
csrf = _extract_csrf(page.text)
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": csrf,
"day_start_hour": "24",
"night_start_hour": "22",
"day_interval_minutes": "5",
"night_interval_minutes": "5",
},
)
assert response.status_code == 400
def test_update_scheduler_settings_rejects_non_positive_interval(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
page = authenticated_client.get("/system")
csrf = _extract_csrf(page.text)
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": csrf,
"day_start_hour": "6",
"night_start_hour": "22",
"day_interval_minutes": "0",
"night_interval_minutes": "5",
},
)
assert response.status_code == 400
def test_update_scheduler_settings_requires_admin(client: TestClient) -> None:
response = client.post(
"/system/scheduler-settings",
data={
"csrf_token": "whatever",
"day_start_hour": "6",
"night_start_hour": "22",
"day_interval_minutes": "5",
"night_interval_minutes": "5",
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_update_scheduler_settings_rejects_invalid_csrf(app, authenticated_client) -> None:
app.state.scheduler = _FakeScheduler()
response = authenticated_client.post(
"/system/scheduler-settings",
data={
"csrf_token": "invalid-token",
"day_start_hour": "6",
"night_start_hour": "22",
"day_interval_minutes": "5",
"night_interval_minutes": "5",
},
)
assert response.status_code == 403