feat: implement async scheduler with lifespan management and update README

This commit is contained in:
grillazz
2026-10-22 13:29:43 +02:00
parent 4ea1ef0fe5
commit d1661f6036
6 changed files with 106 additions and 44 deletions
+3 -13
View File
@@ -22,6 +22,7 @@ from app.middleware.profiler import ProfilingMiddleware
from app.redis import get_redis
from app.services.auth import AuthBearer
from app.services.chat_agent import build_chat_agent
from app.services.scheduler import scheduler_lifespan
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
@@ -45,7 +46,8 @@ async def lifespan(app: FastAPI):
# await app.logger.ainfo(
# "Postgres pool created", idle_size=app.postgres_pool.get_idle_size()
# )
yield
async with scheduler_lifespan():
yield
except Exception as e:
await app.logger.aerror("Error during app startup", error=repr(e))
raise
@@ -96,16 +98,4 @@ def create_app() -> FastAPI:
app = create_app()
# --- Unused/experimental code and TODOs ---
# from apscheduler import AsyncScheduler
# from apscheduler.datastores.sqlalchemy import SQLAlchemyDataStore
# from apscheduler.eventbrokers.redis import RedisEventBroker
# from app.database import engine
# from app.services.scheduler import SchedulerMiddleware
# _scheduler_data_store = SQLAlchemyDataStore(engine, schema="scheduler")
# _scheduler_event_broker = RedisEventBroker(client_or_url=global_settings.redis_url.unicode_string())
# _scheduler_himself = AsyncScheduler(_scheduler_data_store, _scheduler_event_broker)
# app.add_middleware(SchedulerMiddleware, scheduler=_scheduler_himself)
# TODO: every non-GET method should reset cache
# TODO: scheduler tasks needing DB should access connection pool via request
# TODO: https://stackoverflow.com/questions/16053364/make-sure-only-one-worker-launches-the-apscheduler-event-in-a-pyramid-web-app-ru
+22 -26
View File
@@ -1,13 +1,16 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import datetime
from apscheduler import AsyncScheduler
from apscheduler import AsyncScheduler, ConflictPolicy
from apscheduler.datastores.sqlalchemy import SQLAlchemyDataStore
from apscheduler.eventbrokers.redis import RedisEventBroker
from apscheduler.triggers.interval import IntervalTrigger
from attrs import define
from rotoger import get_logger
from sqlalchemy import text
from starlette.types import ASGIApp, Receive, Scope, Send
from app.database import AsyncSessionFactory
from app.config import settings as global_settings
from app.database import AsyncSessionFactory, engine
logger = get_logger()
@@ -21,26 +24,19 @@ async def tick():
return True
@define
class SchedulerMiddleware:
app: ASGIApp
scheduler: AsyncScheduler
@asynccontextmanager
async def scheduler_lifespan() -> AsyncIterator[None]:
scheduler = AsyncScheduler(
SQLAlchemyDataStore(engine, schema="scheduler"),
RedisEventBroker(client_or_url=global_settings.redis_url.unicode_string()),
)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""
Handles the incoming request and schedules tasks if the scope type is 'lifespan'.
Args:
scope (Scope): The ASGI scope dictionary containing request information.
receive (Receive): The ASGI receive callable.
send (Send): The ASGI send callable.
"""
if scope["type"] == "lifespan":
async with self.scheduler:
await self.scheduler.add_schedule(
tick, IntervalTrigger(seconds=25), id="tick-sql-25"
)
await self.scheduler.start_in_background()
await self.app(scope, receive, send)
else:
await self.app(scope, receive, send)
async with scheduler:
await scheduler.add_schedule(
tick,
IntervalTrigger(seconds=25),
id="tick-sql-25",
conflict_policy=ConflictPolicy.replace,
)
await scheduler.start_in_background()
yield