mirror of
https://github.com/grillazz/fastapi-sqlalchemy-asyncpg.git
synced 2026-11-01 01:24:45 +03:00
feat: implement async scheduler with lifespan management and update README
This commit is contained in:
@@ -130,11 +130,13 @@ In `pl.read_excel()` “calamine” engine can be used for reading all major typ
|
|||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
### Worker aware async scheduler
|
### Worker aware async scheduler
|
||||||
The project uses the APScheduler library to schedule tasks in the background.
|
The app starts an `AsyncScheduler` as part of FastAPI's lifespan. Each worker shares
|
||||||
The APScheduler library is a powerful and flexible in-process task scheduler with Cron-like capabilities.
|
the persistent SQLAlchemy data store in the `scheduler` schema and uses Redis as the
|
||||||
It allows you to schedule jobs to run at specific times or intervals, and it supports multiple job stores, triggers, and executors.
|
event broker, allowing APScheduler to coordinate scheduling across workers. The
|
||||||
The library is designed to be easy to use and highly configurable, making it suitable for a wide range of use cases.
|
`tick-sql-25` schedule runs `app.services.scheduler.tick` every 25 seconds and is
|
||||||
It was added to project in version 4.0.0a5 with Redis as event broker and SQLAlchemy as data store.
|
replaced on startup so its definition stays current. The project uses APScheduler
|
||||||
|
4.0.0a6, a pre-release; see the
|
||||||
|
[ASGI/FastAPI integration example](https://github.com/agronholm/apscheduler/blob/4.0.0a6/examples/web/asgi_fastapi.py).
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
|
|||||||
+3
-13
@@ -22,6 +22,7 @@ from app.middleware.profiler import ProfilingMiddleware
|
|||||||
from app.redis import get_redis
|
from app.redis import get_redis
|
||||||
from app.services.auth import AuthBearer
|
from app.services.auth import AuthBearer
|
||||||
from app.services.chat_agent import build_chat_agent
|
from app.services.chat_agent import build_chat_agent
|
||||||
|
from app.services.scheduler import scheduler_lifespan
|
||||||
|
|
||||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ async def lifespan(app: FastAPI):
|
|||||||
# await app.logger.ainfo(
|
# await app.logger.ainfo(
|
||||||
# "Postgres pool created", idle_size=app.postgres_pool.get_idle_size()
|
# "Postgres pool created", idle_size=app.postgres_pool.get_idle_size()
|
||||||
# )
|
# )
|
||||||
yield
|
async with scheduler_lifespan():
|
||||||
|
yield
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await app.logger.aerror("Error during app startup", error=repr(e))
|
await app.logger.aerror("Error during app startup", error=repr(e))
|
||||||
raise
|
raise
|
||||||
@@ -96,16 +98,4 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
app = create_app()
|
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: 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
@@ -1,13 +1,16 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
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 apscheduler.triggers.interval import IntervalTrigger
|
||||||
from attrs import define
|
|
||||||
from rotoger import get_logger
|
from rotoger import get_logger
|
||||||
from sqlalchemy import text
|
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()
|
logger = get_logger()
|
||||||
|
|
||||||
@@ -21,26 +24,19 @@ async def tick():
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@define
|
@asynccontextmanager
|
||||||
class SchedulerMiddleware:
|
async def scheduler_lifespan() -> AsyncIterator[None]:
|
||||||
app: ASGIApp
|
scheduler = AsyncScheduler(
|
||||||
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:
|
async with scheduler:
|
||||||
"""
|
await scheduler.add_schedule(
|
||||||
Handles the incoming request and schedules tasks if the scope type is 'lifespan'.
|
tick,
|
||||||
|
IntervalTrigger(seconds=25),
|
||||||
Args:
|
id="tick-sql-25",
|
||||||
scope (Scope): The ASGI scope dictionary containing request information.
|
conflict_policy=ConflictPolicy.replace,
|
||||||
receive (Receive): The ASGI receive callable.
|
)
|
||||||
send (Send): The ASGI send callable.
|
await scheduler.start_in_background()
|
||||||
"""
|
yield
|
||||||
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)
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ dependencies = [
|
|||||||
"websockets==17.3",
|
"websockets==17.3",
|
||||||
"jinja2==3.1.6",
|
"jinja2==3.1.6",
|
||||||
"whenever==0.10.5",
|
"whenever==0.10.5",
|
||||||
|
"sniffio==1.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import pytest
|
||||||
|
from apscheduler import ConflictPolicy
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
|
import app.services.scheduler as scheduler_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_scheduler_lifespan_starts_and_stops_scheduler(monkeypatch) -> None:
|
||||||
|
events = []
|
||||||
|
data_store = object()
|
||||||
|
event_broker = object()
|
||||||
|
|
||||||
|
class SchedulerStub:
|
||||||
|
async def __aenter__(self):
|
||||||
|
events.append("enter")
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args: object) -> None:
|
||||||
|
events.append("exit")
|
||||||
|
|
||||||
|
async def add_schedule(
|
||||||
|
self, func, trigger, *, id: str, conflict_policy: ConflictPolicy
|
||||||
|
) -> None:
|
||||||
|
events.append(("schedule", func, trigger, id, conflict_policy))
|
||||||
|
|
||||||
|
async def start_in_background(self) -> None:
|
||||||
|
events.append("start")
|
||||||
|
|
||||||
|
scheduler = SchedulerStub()
|
||||||
|
|
||||||
|
def create_data_store(actual_engine, *, schema):
|
||||||
|
assert actual_engine is scheduler_service.engine
|
||||||
|
assert schema == "scheduler"
|
||||||
|
return data_store
|
||||||
|
|
||||||
|
def create_event_broker(*, client_or_url):
|
||||||
|
assert client_or_url == (
|
||||||
|
scheduler_service.global_settings.redis_url.unicode_string()
|
||||||
|
)
|
||||||
|
return event_broker
|
||||||
|
|
||||||
|
def create_scheduler(actual_data_store, actual_event_broker):
|
||||||
|
assert actual_data_store is data_store
|
||||||
|
assert actual_event_broker is event_broker
|
||||||
|
return scheduler
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler_service, "SQLAlchemyDataStore", create_data_store)
|
||||||
|
monkeypatch.setattr(scheduler_service, "RedisEventBroker", create_event_broker)
|
||||||
|
monkeypatch.setattr(scheduler_service, "AsyncScheduler", create_scheduler)
|
||||||
|
|
||||||
|
async with scheduler_service.scheduler_lifespan():
|
||||||
|
assert events[0] == "enter"
|
||||||
|
event_type, func, trigger, schedule_id, conflict_policy = events[1]
|
||||||
|
assert event_type == "schedule"
|
||||||
|
assert func is scheduler_service.tick
|
||||||
|
assert isinstance(trigger, IntervalTrigger)
|
||||||
|
assert schedule_id == "tick-sql-25"
|
||||||
|
assert conflict_policy is ConflictPolicy.replace
|
||||||
|
assert events[2:] == ["start"]
|
||||||
|
|
||||||
|
assert events[-1] == "exit"
|
||||||
@@ -341,6 +341,7 @@ dependencies = [
|
|||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "rotoger" },
|
{ name = "rotoger" },
|
||||||
|
{ name = "sniffio" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
{ name = "uvloop" },
|
{ name = "uvloop" },
|
||||||
@@ -385,6 +386,7 @@ requires-dist = [
|
|||||||
{ name = "redis", specifier = "==8.1.0" },
|
{ name = "redis", specifier = "==8.1.0" },
|
||||||
{ name = "rich", specifier = "==15.0.0" },
|
{ name = "rich", specifier = "==15.0.0" },
|
||||||
{ name = "rotoger", specifier = "==0.3.0" },
|
{ name = "rotoger", specifier = "==0.3.0" },
|
||||||
|
{ name = "sniffio", specifier = "==1.3.1" },
|
||||||
{ name = "sqlalchemy", specifier = "==2.1.6" },
|
{ name = "sqlalchemy", specifier = "==2.1.6" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.54.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = "==0.54.0" },
|
||||||
{ name = "uvloop", specifier = "==0.23.0" },
|
{ name = "uvloop", specifier = "==0.23.0" },
|
||||||
@@ -1177,6 +1179,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sniffio"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.1.6"
|
version = "2.1.6"
|
||||||
|
|||||||
Reference in New Issue
Block a user