generated from john/python-template
job service
This commit is contained in:
@@ -7,27 +7,24 @@ from contextlib import asynccontextmanager
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from .api.errors import register_error_handlers
|
from .api.errors import register_error_handlers
|
||||||
from .api.health import router as health_router
|
from .api.health import router as health_router
|
||||||
from .config import configure_logging
|
from .config import configure_logging
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import cleanup_database
|
|
||||||
from .db import create_all
|
from .db import create_all
|
||||||
|
from .db import dispose_database_runtime
|
||||||
from .db import initialize_database_runtime
|
from .db import initialize_database_runtime
|
||||||
from .ui import register_pages
|
from .ui import register_pages
|
||||||
from .worker import run_worker_loop
|
from .worker import run_worker_loop
|
||||||
|
|
||||||
|
|
||||||
def _start_worker(app: FastAPI) -> None:
|
def _start_worker(app: FastAPI) -> None:
|
||||||
session_factory: async_sessionmaker[AsyncSession] = app.state.db_session_factory
|
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
wake_queue: asyncio.Queue[None] = asyncio.Queue()
|
wake_queue: asyncio.Queue[None] = asyncio.Queue()
|
||||||
worker_task = asyncio.create_task(
|
worker_task = asyncio.create_task(
|
||||||
run_worker_loop(
|
run_worker_loop(
|
||||||
session_factory=session_factory,
|
session_factory=app.state.runtime.session_factory,
|
||||||
stop_event=stop_event,
|
stop_event=stop_event,
|
||||||
wake_queue=wake_queue,
|
wake_queue=wake_queue,
|
||||||
poll_interval_seconds=1.0,
|
poll_interval_seconds=1.0,
|
||||||
@@ -63,12 +60,10 @@ async def _lifespan(app: FastAPI):
|
|||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
runtime = initialize_database_runtime(settings=settings)
|
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||||
app.state.db_engine = runtime.engine
|
|
||||||
app.state.db_session_factory = runtime.session_factory
|
|
||||||
|
|
||||||
if settings.should_bootstrap_schema:
|
if settings.should_bootstrap_schema:
|
||||||
await create_all(engine=runtime.engine)
|
await create_all(engine=app.state.runtime.engine)
|
||||||
|
|
||||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -78,7 +73,7 @@ async def _lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
await _stop_worker(app)
|
await _stop_worker(app)
|
||||||
await cleanup_database()
|
await dispose_database_runtime()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from .operations import create_all
|
||||||
|
from .runtime import dispose_database_runtime
|
||||||
|
from .runtime import get_session
|
||||||
|
from .runtime import initialize_database_runtime
|
||||||
|
|
||||||
|
__all__ = ["create_all", "dispose_database_runtime", "get_session", "initialize_database_runtime"]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..models import Job
|
||||||
|
from ..models import JobStatus
|
||||||
|
from .runtime import get_engine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
|
||||||
|
"""Get the next queued job, if any."""
|
||||||
|
result = await session.exec(
|
||||||
|
select(Job)
|
||||||
|
.where(Job.status == JobStatus.QUEUED)
|
||||||
|
.order_by(Job.created_at) # pyright: ignore[reportArgumentType]
|
||||||
|
.limit(1)
|
||||||
|
) # fmt: skip
|
||||||
|
return result.first()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_all(*, engine: AsyncEngine | None = None) -> None:
|
||||||
|
"""Create all tables on the selected engine."""
|
||||||
|
# Import models so SQLModel metadata is fully registered before bootstrap.
|
||||||
|
from transcription import models as _models # noqa: F401
|
||||||
|
|
||||||
|
active_engine = engine or get_engine()
|
||||||
|
async with active_engine.begin() as connection:
|
||||||
|
await connection.run_sync(SQLModel.metadata.create_all)
|
||||||
|
await connection.run_sync(_ensure_sqlite_compat_columns)
|
||||||
|
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sqlite_compat_columns(connection: Connection) -> None:
|
||||||
|
"""Apply lightweight dev/test SQLite compatibility column patches.
|
||||||
|
|
||||||
|
This keeps local bootstrap resilient when models evolve but no full
|
||||||
|
migration tooling is in place yet.
|
||||||
|
"""
|
||||||
|
if connection.engine.url.get_backend_name() != "sqlite":
|
||||||
|
return
|
||||||
|
|
||||||
|
inspector = inspect(connection)
|
||||||
|
table_names = set(inspector.get_table_names())
|
||||||
|
if "job" not in table_names:
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = {column["name"] for column in inspector.get_columns("job")}
|
||||||
|
if "retry_count" not in columns:
|
||||||
|
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
|
||||||
@@ -1,28 +1,16 @@
|
|||||||
"""Database runtime ownership, schema bootstrap, and session access.
|
|
||||||
|
|
||||||
V1 moves database resource ownership to explicit runtime initialization so
|
|
||||||
startup/shutdown behavior is predictable and lifespan-managed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.engine import Connection
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
from sqlmodel import SQLModel
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from .config import Settings
|
from ..config import Settings
|
||||||
from .config import get_settings
|
from ..config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,6 +26,15 @@ class DatabaseRuntime:
|
|||||||
_runtime: ContextVar[DatabaseRuntime | None] = ContextVar("database_runtime", default=None)
|
_runtime: ContextVar[DatabaseRuntime | None] = ContextVar("database_runtime", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispose_database_runtime() -> None:
|
||||||
|
"""Dispose lifespan-owned async database resources."""
|
||||||
|
runtime = _runtime.get()
|
||||||
|
if runtime is None:
|
||||||
|
return
|
||||||
|
await runtime.engine.dispose()
|
||||||
|
_runtime.set(None)
|
||||||
|
|
||||||
|
|
||||||
def _to_async_database_url(database_url: str) -> str:
|
def _to_async_database_url(database_url: str) -> str:
|
||||||
"""Normalize configured database URL to an async SQLAlchemy driver URL."""
|
"""Normalize configured database URL to an async SQLAlchemy driver URL."""
|
||||||
if database_url.startswith("sqlite://") and not database_url.startswith("sqlite+aiosqlite://"):
|
if database_url.startswith("sqlite://") and not database_url.startswith("sqlite+aiosqlite://"):
|
||||||
@@ -87,53 +84,7 @@ def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
|||||||
return runtime.session_factory
|
return runtime.session_factory
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_database() -> None:
|
@asynccontextmanager
|
||||||
"""Cleanup database runtime resources."""
|
|
||||||
await dispose_database_runtime()
|
|
||||||
|
|
||||||
|
|
||||||
async def dispose_database_runtime() -> None:
|
|
||||||
"""Dispose lifespan-owned async database resources."""
|
|
||||||
runtime = _runtime.get()
|
|
||||||
if runtime is None:
|
|
||||||
return
|
|
||||||
await runtime.engine.dispose()
|
|
||||||
_runtime.set(None)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_all(*, engine: AsyncEngine | None = None) -> None:
|
|
||||||
"""Create all tables on the selected engine."""
|
|
||||||
# Import models so SQLModel metadata is fully registered before bootstrap.
|
|
||||||
from transcription import models as _models # noqa: F401
|
|
||||||
|
|
||||||
active_engine = engine or get_engine()
|
|
||||||
async with active_engine.begin() as connection:
|
|
||||||
await connection.run_sync(SQLModel.metadata.create_all)
|
|
||||||
await connection.run_sync(_ensure_sqlite_compat_columns)
|
|
||||||
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_sqlite_compat_columns(connection: Connection) -> None:
|
|
||||||
"""Apply lightweight dev/test SQLite compatibility column patches.
|
|
||||||
|
|
||||||
This keeps local bootstrap resilient when models evolve but no full
|
|
||||||
migration tooling is in place yet.
|
|
||||||
"""
|
|
||||||
if connection.engine.url.get_backend_name() != "sqlite":
|
|
||||||
return
|
|
||||||
|
|
||||||
inspector = inspect(connection)
|
|
||||||
table_names = set(inspector.get_table_names())
|
|
||||||
if "job" not in table_names:
|
|
||||||
return
|
|
||||||
|
|
||||||
columns = {column["name"] for column in inspector.get_columns("job")}
|
|
||||||
if "retry_count" not in columns:
|
|
||||||
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
|
|
||||||
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
|
||||||
async def get_session(
|
async def get_session(
|
||||||
*,
|
*,
|
||||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||||
@@ -142,8 +93,3 @@ async def get_session(
|
|||||||
active_session_factory = session_factory or get_session_factory()
|
active_session_factory = session_factory or get_session_factory()
|
||||||
async with active_session_factory() as session:
|
async with active_session_factory() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
def should_bootstrap_schema(settings: Settings) -> bool:
|
|
||||||
"""Compatibility helper for explicit bootstrap checks."""
|
|
||||||
return settings.should_bootstrap_schema
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..db.runtime import get_session_factory
|
||||||
|
from ..models import Job
|
||||||
|
from ..models import JobStatus
|
||||||
|
|
||||||
|
|
||||||
|
class JobService:
|
||||||
|
"""Thin service class for managing jobs in the database."""
|
||||||
|
|
||||||
|
session_factory: async_sessionmaker[AsyncSession]
|
||||||
|
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession] | None = None):
|
||||||
|
self.session_factory = session_factory or get_session_factory()
|
||||||
|
|
||||||
|
async def create_job(self, job: Job) -> Job:
|
||||||
|
"""Create a new job in the database."""
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
session.add(job)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
async def read_job(self, job_id: UUID) -> Job:
|
||||||
|
"""Read an existing job from the database.
|
||||||
|
|
||||||
|
The selectinload option is used to eagerly load the related document for the job, which makes the full Document
|
||||||
|
model object available in the return Job object.
|
||||||
|
"""
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
job = await session.get(
|
||||||
|
Job,
|
||||||
|
job_id,
|
||||||
|
# Makes the full Document model object available in the return Job object
|
||||||
|
options=(selectinload(Job.document),), # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
if job is None:
|
||||||
|
raise ValueError(f"Job with id {job_id} not found")
|
||||||
|
return job
|
||||||
|
|
||||||
|
async def update_job(self, job: Job) -> Job:
|
||||||
|
"""Update an existing job in the database."""
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
await session.merge(job)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
async def delete_job(self, job: Job) -> None:
|
||||||
|
"""Delete a job from the database."""
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
await session.delete(job)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def query_jobs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: JobStatus | None = None,
|
||||||
|
filename: str | None = None,
|
||||||
|
) -> Sequence[Job]:
|
||||||
|
"""Query jobs from the database based on provided filters."""
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
query = select(Job)
|
||||||
|
if status is not None:
|
||||||
|
query = query.where(Job.status == status)
|
||||||
|
if filename is not None:
|
||||||
|
query = query.where(Job.document.filename == filename)
|
||||||
|
return (await session.exec(query)).all()
|
||||||
|
|
||||||
|
async def list_jobs(self) -> Sequence[Job]:
|
||||||
|
"""List all jobs in the database."""
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
return (await session.exec(select(Job))).all()
|
||||||
|
|
||||||
|
async def mark_job_status(self, job_id: UUID, status: JobStatus) -> Job:
|
||||||
|
"""Mark a job with a new status."""
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
job = await session.get(Job, job_id)
|
||||||
|
if job is None:
|
||||||
|
raise ValueError(f"Job with id {job_id} not found")
|
||||||
|
job.status = status
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(job)
|
||||||
|
return job
|
||||||
Reference in New Issue
Block a user