feat: auto-refresh calendar subscriptions
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from . import calendar as calendar_service
|
||||
from .models import CalendarSubscription, utcnow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DEFAULT_REFRESH_INTERVAL = timedelta(minutes=15)
|
||||
DEFAULT_POLL_SECONDS = 60
|
||||
|
||||
|
||||
async def refresh_subscription_cache(
|
||||
db: AsyncSession,
|
||||
row: CalendarSubscription,
|
||||
*,
|
||||
fail_without_cache: bool = True,
|
||||
) -> bool:
|
||||
requested_url = row.url
|
||||
requested_version = getattr(row, "updated_at", None)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
calendar_service.fetch_calendar,
|
||||
requested_url,
|
||||
etag=row.etag,
|
||||
last_modified=row.last_modified,
|
||||
)
|
||||
await db.refresh(row)
|
||||
if row.url != requested_url or getattr(row, "updated_at", None) != requested_version:
|
||||
return False
|
||||
if result.not_modified:
|
||||
if not row.ics_cache:
|
||||
raise HTTPException(502, "calendar returned not modified without cache")
|
||||
elif result.content is not None:
|
||||
# Parse before replacing a known-good cache.
|
||||
calendar_service.parse_ics_events(
|
||||
result.content,
|
||||
row.name,
|
||||
row.color,
|
||||
datetime.now(UTC) - timedelta(days=1),
|
||||
datetime.now(UTC) + timedelta(days=1),
|
||||
"UTC",
|
||||
)
|
||||
row.ics_cache = result.content.decode("utf-8-sig")
|
||||
row.etag = result.etag
|
||||
row.last_modified = result.last_modified
|
||||
row.refreshed_at = utcnow()
|
||||
row.last_error = None
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
error = exc.detail if isinstance(exc, HTTPException) else str(exc)
|
||||
if not row.ics_cache and fail_without_cache:
|
||||
await db.rollback()
|
||||
raise HTTPException(502, error) from exc
|
||||
row.last_error = error
|
||||
await db.commit()
|
||||
return False
|
||||
|
||||
|
||||
async def refresh_due_subscriptions(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
refresh_interval: timedelta = DEFAULT_REFRESH_INTERVAL,
|
||||
) -> int:
|
||||
current = now or datetime.now(UTC)
|
||||
cutoff = current - refresh_interval
|
||||
rows = (await db.scalars(select(CalendarSubscription).where(
|
||||
CalendarSubscription.enabled.is_(True),
|
||||
or_(
|
||||
CalendarSubscription.refreshed_at.is_(None),
|
||||
CalendarSubscription.refreshed_at <= cutoff,
|
||||
),
|
||||
).order_by(CalendarSubscription.refreshed_at, CalendarSubscription.created_at))).all()
|
||||
for row in rows:
|
||||
try:
|
||||
await refresh_subscription_cache(db, row, fail_without_cache=False)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception("Unexpected calendar refresh failure", extra={"subscription_id": str(row.id)})
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def calendar_refresh_loop(
|
||||
session_factory: Callable[[], AsyncSession],
|
||||
stop_event: asyncio.Event,
|
||||
*,
|
||||
refresh_interval: timedelta = DEFAULT_REFRESH_INTERVAL,
|
||||
poll_seconds: int = DEFAULT_POLL_SECONDS,
|
||||
) -> None:
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
async with session_factory() as db:
|
||||
await refresh_due_subscriptions(db, refresh_interval=refresh_interval)
|
||||
except Exception:
|
||||
logger.exception("Calendar background refresh cycle failed")
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=poll_seconds)
|
||||
except TimeoutError:
|
||||
pass
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
@@ -9,8 +9,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from . import calendar as calendar_service
|
||||
from .auth import current_user
|
||||
from .calendar_refresh import refresh_subscription_cache
|
||||
from .db import get_db
|
||||
from .models import CalendarSubscription, User, utcnow
|
||||
from .models import CalendarSubscription, User
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["calendar"])
|
||||
MAX_WINDOW = timedelta(days=366)
|
||||
@@ -90,37 +91,6 @@ async def _owned(db: AsyncSession, user_id: UUID, subscription_id: UUID) -> Cale
|
||||
return row
|
||||
|
||||
|
||||
async def _refresh(db: AsyncSession, row: CalendarSubscription) -> None:
|
||||
try:
|
||||
result = await __import__("asyncio").to_thread(
|
||||
calendar_service.fetch_calendar, row.url, etag=row.etag, last_modified=row.last_modified
|
||||
)
|
||||
if result.not_modified:
|
||||
if not row.ics_cache:
|
||||
raise HTTPException(502, "calendar returned not modified without cache")
|
||||
elif result.content is not None:
|
||||
# Parse before replacing a known-good cache.
|
||||
calendar_service.parse_ics_events(
|
||||
result.content,
|
||||
row.name,
|
||||
row.color,
|
||||
datetime.now(UTC) - timedelta(days=1),
|
||||
datetime.now(UTC) + timedelta(days=1),
|
||||
"UTC",
|
||||
)
|
||||
row.ics_cache = result.content.decode("utf-8-sig")
|
||||
row.etag = result.etag
|
||||
row.last_modified = result.last_modified
|
||||
row.refreshed_at = utcnow()
|
||||
row.last_error = None
|
||||
except Exception as exc:
|
||||
row.last_error = exc.detail if isinstance(exc, HTTPException) else str(exc)
|
||||
if not row.ics_cache:
|
||||
await db.rollback()
|
||||
raise HTTPException(502, row.last_error) from exc
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/calendar-subscriptions", response_model=list[SubscriptionOut])
|
||||
async def list_subscriptions(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.scalars(select(CalendarSubscription).where(
|
||||
@@ -139,7 +109,7 @@ async def create_subscription(
|
||||
row = CalendarSubscription(user_id=user.id, **payload.model_dump())
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
await _refresh(db, row)
|
||||
await refresh_subscription_cache(db, row)
|
||||
await db.refresh(row)
|
||||
return _out(row)
|
||||
|
||||
@@ -183,7 +153,7 @@ async def refresh_subscription(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
row = await _owned(db, user.id, subscription_id)
|
||||
await _refresh(db, row)
|
||||
await refresh_subscription_cache(db, row)
|
||||
await db.refresh(row)
|
||||
return _out(row)
|
||||
|
||||
@@ -209,7 +179,7 @@ async def calendar_events(
|
||||
sources = []
|
||||
for row in rows:
|
||||
if not row.ics_cache:
|
||||
await _refresh(db, row)
|
||||
await refresh_subscription_cache(db, row)
|
||||
try:
|
||||
parsed = calendar_service.parse_ics_events(
|
||||
row.ics_cache or "", row.name, row.color, start, end, user.timezone,
|
||||
|
||||
+7
-3
@@ -22,6 +22,12 @@ def get_engine():
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
get_engine()
|
||||
assert _session_factory is not None
|
||||
return _session_factory
|
||||
|
||||
|
||||
def reset_engine() -> None:
|
||||
global _engine, _session_factory
|
||||
_engine = None
|
||||
@@ -29,9 +35,7 @@ def reset_engine() -> None:
|
||||
|
||||
|
||||
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||
get_engine()
|
||||
assert _session_factory is not None
|
||||
async with _session_factory() as session:
|
||||
async with get_session_factory()() as session:
|
||||
yield session
|
||||
|
||||
|
||||
|
||||
+16
-2
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -30,8 +31,9 @@ from .auth import (
|
||||
verify_password,
|
||||
)
|
||||
from .backup import router as backup_router
|
||||
from .calendar_refresh import calendar_refresh_loop
|
||||
from .calendar_router import router as calendar_router
|
||||
from .db import create_schema, get_db
|
||||
from .db import create_schema, get_db, get_session_factory
|
||||
from .models import (
|
||||
AppState,
|
||||
Attachment,
|
||||
@@ -80,7 +82,19 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
if get_settings().auto_create_schema:
|
||||
await create_schema()
|
||||
yield
|
||||
stop_calendar_refresh = asyncio.Event()
|
||||
calendar_refresh_task = asyncio.create_task(
|
||||
calendar_refresh_loop(get_session_factory(), stop_calendar_refresh)
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop_calendar_refresh.set()
|
||||
calendar_refresh_task.cancel()
|
||||
try:
|
||||
await calendar_refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
||||
Reference in New Issue
Block a user