feat: embedded flow-watcher in API lifespan with startup sync
This commit is contained in:
parent
c65e3ed332
commit
338cdcf108
3 changed files with 82 additions and 0 deletions
|
|
@ -1,8 +1,58 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .flows.api import approvals_router, flows_router
|
from .flows.api import approvals_router, flows_router
|
||||||
|
from .flows.sync import run_sync_blocking, watch_forever
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _blocking_startup_sync() -> None:
|
||||||
|
"""One-shot reconciliation at startup (fresh session, never fatal)."""
|
||||||
|
try:
|
||||||
|
report = run_sync_blocking(Path(settings.flows_dir))
|
||||||
|
logger.info(
|
||||||
|
"startup flow sync: +%d ~%d -%d skipped=%d errors=%d",
|
||||||
|
len(report.added),
|
||||||
|
len(report.updated),
|
||||||
|
len(report.deleted),
|
||||||
|
len(report.skipped),
|
||||||
|
len(report.errors),
|
||||||
|
)
|
||||||
|
for filename, reason in report.errors.items():
|
||||||
|
logger.warning("startup flow sync: %s: %s", filename, reason)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
logger.error("startup flow sync skipped: %s", exc)
|
||||||
|
except Exception: # noqa: BLE001 - API must start even if sync fails
|
||||||
|
logger.exception("startup flow sync failed")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
task: asyncio.Task | None = None
|
||||||
|
stop: asyncio.Event | None = None
|
||||||
|
if settings.flows_watch_enabled:
|
||||||
|
await asyncio.to_thread(_blocking_startup_sync)
|
||||||
|
stop = asyncio.Event()
|
||||||
|
task = asyncio.create_task(
|
||||||
|
watch_forever(stop, Path(settings.flows_dir)), name="flows-watcher"
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
if task is not None and stop is not None:
|
||||||
|
stop.set()
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except (asyncio.CancelledError, Exception): # noqa: BLE001 - shutdown
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Kirche Einfach API",
|
title="Kirche Einfach API",
|
||||||
description="KI-gestützte Verwaltungsplattform für Kirche",
|
description="KI-gestützte Verwaltungsplattform für Kirche",
|
||||||
|
|
@ -10,6 +60,7 @@ app = FastAPI(
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc",
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json",
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(flows_router)
|
app.include_router(flows_router)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ TEST_AREA = Path(tempfile.mkdtemp(prefix="kirche-tests-"))
|
||||||
# Must precede all `app.*` imports: Settings is instantiated at import time.
|
# Must precede all `app.*` imports: Settings is instantiated at import time.
|
||||||
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
|
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
|
||||||
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
|
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
|
||||||
|
# The embedded watcher + startup sync stay off in tests; sync is exercised
|
||||||
|
# directly (sync_from_directory) with per-test tmp dirs instead.
|
||||||
|
os.environ["FLOWS_WATCH_ENABLED"] = "false"
|
||||||
|
|
||||||
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""Sync engine tests: backend/flows/*.json -> DB + LFX (LFX real, DB sqlite)."""
|
"""Sync engine tests: backend/flows/*.json -> DB + LFX (LFX real, DB sqlite)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
@ -9,6 +11,7 @@ import pytest
|
||||||
|
|
||||||
from app.flows import store, sync
|
from app.flows import store, sync
|
||||||
from app.flows.lfx_client import LfxClient, LfxNotFound
|
from app.flows.lfx_client import LfxClient, LfxNotFound
|
||||||
|
from app.flows.schemas import SyncReport
|
||||||
from app.flows.sync import lfx_id_for
|
from app.flows.sync import lfx_id_for
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
|
|
@ -127,3 +130,28 @@ async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkey
|
||||||
|
|
||||||
response = await client.post("/api/flows/sync")
|
response = await client.post("/api/flows/sync")
|
||||||
assert response.json()["skipped"] == ["simple_rule"]
|
assert response.json()["skipped"] == ["simple_rule"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_watch_forever_triggers_on_file_change(tmp_path):
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
stop = asyncio.Event()
|
||||||
|
|
||||||
|
def run():
|
||||||
|
calls.append("sync")
|
||||||
|
return SyncReport()
|
||||||
|
|
||||||
|
task = asyncio.create_task(sync.watch_forever(stop, tmp_path, run_sync=run))
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.5) # let the watcher arm
|
||||||
|
(tmp_path / "neu.json").write_text("{}")
|
||||||
|
deadline = time.time() + 15
|
||||||
|
while not calls and time.time() < deadline:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
assert calls == ["sync"]
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue