fix bd
This commit is contained in:
10
.env.example
10
.env.example
@@ -20,6 +20,16 @@ DEFAULT_LLM_BASE_URL=http://host.docker.internal:1234/v1
|
|||||||
DEFAULT_LLM_API_KEY=dummy
|
DEFAULT_LLM_API_KEY=dummy
|
||||||
DEFAULT_LLM_MODEL=local-model
|
DEFAULT_LLM_MODEL=local-model
|
||||||
|
|
||||||
|
# === Default embeddings / RAG (overridable via admin panel) ===
|
||||||
|
# provider: "hash" (offline fallback, no semantic quality) or "openai" (real /embeddings endpoint).
|
||||||
|
# When provider=openai and base_url/api_key are empty, they fall back to the LLM settings above.
|
||||||
|
DEFAULT_EMBEDDING_PROVIDER=hash
|
||||||
|
DEFAULT_EMBEDDING_BASE_URL=
|
||||||
|
DEFAULT_EMBEDDING_API_KEY=
|
||||||
|
DEFAULT_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
DEFAULT_EMBEDDING_DIM=0
|
||||||
|
DEFAULT_EMBEDDING_REQUEST_TIMEOUT=60
|
||||||
|
|
||||||
# === Frontend ===
|
# === Frontend ===
|
||||||
VITE_API_URL=http://localhost:8000
|
VITE_API_URL=http://localhost:8000
|
||||||
FRONTEND_BUILD_TARGET=dev
|
FRONTEND_BUILD_TARGET=dev
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
"""Database initialization: create all tables and seed defaults."""
|
"""Database initialization: create all tables and seed defaults.
|
||||||
|
|
||||||
|
Idempotent: safe to call from multiple processes (backend lifespan + worker
|
||||||
|
startup) thanks to a PostgreSQL advisory lock that serializes the seeding
|
||||||
|
phase. `create_all` itself is already `CREATE TABLE IF NOT EXISTS`, so the
|
||||||
|
only race is on seed inserts — guarded by `pg_advisory_xact_lock` plus
|
||||||
|
per-row IntegrityError handling.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -6,6 +13,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from app.db import AsyncSessionLocal, Base, engine
|
from app.db import AsyncSessionLocal, Base, engine
|
||||||
from app.models import GlossaryEntry, Preset, Setting, User
|
from app.models import GlossaryEntry, Preset, Setting, User
|
||||||
@@ -17,6 +25,11 @@ from app.prompts.fantasy_preset import FANTASY_PRESET_RU, FANTASY_PRESET_EN
|
|||||||
log = get_logger("migrations")
|
log = get_logger("migrations")
|
||||||
|
|
||||||
|
|
||||||
|
# Stable advisory lock key so backend + worker don't race on seeding.
|
||||||
|
# (key1, key2) — arbitrary 64-bit integers, kept constant across runs.
|
||||||
|
_ADVISORY_LOCK_KEY = (42424201, 1)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_SETTINGS = [
|
DEFAULT_SETTINGS = [
|
||||||
("llm.base_url", settings.default_llm_base_url, "OpenAI-compatible base URL"),
|
("llm.base_url", settings.default_llm_base_url, "OpenAI-compatible base URL"),
|
||||||
("llm.api_key", settings.default_llm_api_key, "API key for LLM endpoint"),
|
("llm.api_key", settings.default_llm_api_key, "API key for LLM endpoint"),
|
||||||
@@ -46,38 +59,31 @@ DEFAULT_SETTINGS = [
|
|||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
setup_logging()
|
setup_logging()
|
||||||
log.info("creating_tables")
|
log.info("creating_tables")
|
||||||
|
# CREATE TABLE IF NOT EXISTS — safe to run concurrently.
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
log.info("tables_ready")
|
log.info("tables_ready")
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
# Seed phase: serialize across processes via PG advisory transaction lock.
|
||||||
# Seed settings
|
# On non-PG backends (SQLite for tests) the lock statement is a no-op
|
||||||
result = await session.execute(select(Setting).limit(1))
|
# (we catch the error and proceed without locking).
|
||||||
if result.scalars().first() is None:
|
try:
|
||||||
for key, value, desc in DEFAULT_SETTINGS:
|
async with AsyncSessionLocal() as session:
|
||||||
session.add(Setting(key=key, value=value, description=desc))
|
await session.execute(
|
||||||
|
text("SELECT pg_advisory_xact_lock(:k1, :k2)").bindparams(
|
||||||
|
k1=_ADVISORY_LOCK_KEY[0], k2=_ADVISORY_LOCK_KEY[1]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await _seed_settings(session)
|
||||||
|
await _seed_builtin_presets(session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
log.info("settings_seeded", count=len(DEFAULT_SETTINGS))
|
except Exception as e:
|
||||||
else:
|
# Non-PG backend (SQLite) or transient error — retry without the lock.
|
||||||
log.info("settings_already_exist")
|
log.warning("advisory_lock_unavailable_proceeding", error=f"{type(e).__name__}: {e}")
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
# Seed built-in Fantasy preset
|
await _seed_settings(session)
|
||||||
result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True)))
|
await _seed_builtin_presets(session)
|
||||||
if result.scalars().first() is None:
|
|
||||||
for preset_def in (FANTASY_PRESET_RU, FANTASY_PRESET_EN):
|
|
||||||
session.add(Preset(
|
|
||||||
slug=preset_def["slug"],
|
|
||||||
title=preset_def["title"],
|
|
||||||
description=preset_def["description"],
|
|
||||||
language=preset_def["language"],
|
|
||||||
is_public=True,
|
|
||||||
is_builtin=True,
|
|
||||||
payload=preset_def["payload"],
|
|
||||||
))
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
log.info("builtin_presets_seeded")
|
|
||||||
else:
|
|
||||||
log.info("builtin_presets_already_exist")
|
|
||||||
|
|
||||||
# Ensure admin_setup_token is set; if empty, generate and print
|
# Ensure admin_setup_token is set; if empty, generate and print
|
||||||
token = settings.admin_setup_token.strip()
|
token = settings.admin_setup_token.strip()
|
||||||
@@ -89,15 +95,73 @@ async def init_db() -> None:
|
|||||||
existing_obj = existing.scalars().first()
|
existing_obj = existing.scalars().first()
|
||||||
if existing_obj is None:
|
if existing_obj is None:
|
||||||
session.add(Setting(key="admin.setup_token", value=token, description="One-time token for /admin/setup"))
|
session.add(Setting(key="admin.setup_token", value=token, description="One-time token for /admin/setup"))
|
||||||
await session.commit()
|
try:
|
||||||
print("=" * 60)
|
await session.commit()
|
||||||
print("ADMIN SETUP TOKEN (use at /admin/setup):")
|
print("=" * 60)
|
||||||
print(token)
|
print("ADMIN SETUP TOKEN (use at /admin/setup):")
|
||||||
print("=" * 60)
|
print(token)
|
||||||
log.info("admin_setup_token_generated")
|
print("=" * 60)
|
||||||
|
log.info("admin_setup_token_generated")
|
||||||
|
except IntegrityError:
|
||||||
|
# Another process inserted it concurrently — fine.
|
||||||
|
await session.rollback()
|
||||||
|
log.info("admin_setup_token_already_set")
|
||||||
else:
|
else:
|
||||||
log.info("admin_setup_token_already_set")
|
log.info("admin_setup_token_already_set")
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_settings(session) -> None:
|
||||||
|
"""Insert default settings that don't yet exist (per-row, race-safe)."""
|
||||||
|
result = await session.execute(select(Setting).limit(1))
|
||||||
|
if result.scalars().first() is not None:
|
||||||
|
log.info("settings_already_exist")
|
||||||
|
return
|
||||||
|
seeded = 0
|
||||||
|
for key, value, desc in DEFAULT_SETTINGS:
|
||||||
|
# Check existence per-row to avoid IntegrityError on concurrent inserts
|
||||||
|
existing = await session.execute(select(Setting).where(Setting.key == key))
|
||||||
|
if existing.scalars().first() is not None:
|
||||||
|
continue
|
||||||
|
session.add(Setting(key=key, value=value, description=desc))
|
||||||
|
seeded += 1
|
||||||
|
if seeded:
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
log.info("settings_seeded", count=seeded)
|
||||||
|
except IntegrityError:
|
||||||
|
await session.rollback()
|
||||||
|
log.info("settings_seed_skipped_concurrent")
|
||||||
|
else:
|
||||||
|
log.info("settings_already_exist")
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_builtin_presets(session) -> None:
|
||||||
|
"""Insert built-in presets if none exist yet."""
|
||||||
|
result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True)))
|
||||||
|
if result.scalars().first() is not None:
|
||||||
|
log.info("builtin_presets_already_exist")
|
||||||
|
return
|
||||||
|
for preset_def in (FANTASY_PRESET_RU, FANTASY_PRESET_EN):
|
||||||
|
# Check by slug to avoid race on unique constraint
|
||||||
|
existing = await session.execute(select(Preset).where(Preset.slug == preset_def["slug"]))
|
||||||
|
if existing.scalars().first() is not None:
|
||||||
|
continue
|
||||||
|
session.add(Preset(
|
||||||
|
slug=preset_def["slug"],
|
||||||
|
title=preset_def["title"],
|
||||||
|
description=preset_def["description"],
|
||||||
|
language=preset_def["language"],
|
||||||
|
is_public=True,
|
||||||
|
is_builtin=True,
|
||||||
|
payload=preset_def["payload"],
|
||||||
|
))
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
log.info("builtin_presets_seeded")
|
||||||
|
except IntegrityError:
|
||||||
|
await session.rollback()
|
||||||
|
log.info("builtin_presets_seed_skipped_concurrent")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(init_db())
|
asyncio.run(init_db())
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
"""Worker entrypoint: runs trigger checker + future background jobs."""
|
"""Worker entrypoint: runs trigger checker + future background jobs.
|
||||||
|
|
||||||
|
Waits for the database (and required tables) to be ready before starting
|
||||||
|
the background loops. This prevents the worker from crashing when the
|
||||||
|
backend hasn't finished running `init_db()` yet (typical docker-compose
|
||||||
|
race condition where both services start in parallel).
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from app.db_wait import wait_for_db_or_exit
|
||||||
from app.logging_setup import get_logger, setup_logging
|
from app.logging_setup import get_logger, setup_logging
|
||||||
from app.workers.trigger_runner import main_loop as trigger_loop
|
from app.workers.trigger_runner import main_loop as trigger_loop
|
||||||
|
|
||||||
@@ -12,6 +19,13 @@ log = get_logger("worker")
|
|||||||
async def main():
|
async def main():
|
||||||
setup_logging()
|
setup_logging()
|
||||||
log.info("worker_starting")
|
log.info("worker_starting")
|
||||||
|
|
||||||
|
# Wait until DB is reachable and the `settings` table exists.
|
||||||
|
# The backend's lifespan runs `init_db()` which creates tables; if the
|
||||||
|
# worker comes up first, this loop will retry until that completes.
|
||||||
|
await wait_for_db_or_exit(max_retries=60, delay=2.0)
|
||||||
|
|
||||||
|
log.info("worker_db_ready_starting_loops")
|
||||||
# Run all background loops concurrently
|
# Run all background loops concurrently
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
trigger_loop(),
|
trigger_loop(),
|
||||||
|
|||||||
@@ -155,21 +155,34 @@ async def _fire_trigger(db, trigger: DeferredTrigger, session: Session, world: W
|
|||||||
|
|
||||||
|
|
||||||
async def main_loop():
|
async def main_loop():
|
||||||
"""Main worker loop. Polls every N seconds for due triggers."""
|
"""Main worker loop. Polls every N seconds for due triggers.
|
||||||
|
|
||||||
|
Resilient to transient DB errors: any error inside an iteration is logged
|
||||||
|
and the loop sleeps for a fallback interval before retrying, instead of
|
||||||
|
crashing the worker process.
|
||||||
|
"""
|
||||||
setup_logging()
|
setup_logging()
|
||||||
log.info("trigger_worker_started")
|
log.info("trigger_worker_started")
|
||||||
|
fallback_interval = 30 # used when settings table is unreadable
|
||||||
while True:
|
while True:
|
||||||
|
interval = fallback_interval
|
||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
enabled = await _get_setting(db, "triggers.enabled", True)
|
enabled = await _get_setting(db, "triggers.enabled", True)
|
||||||
interval = int(await _get_setting(db, "triggers.check_interval", 30))
|
interval = int(await _get_setting(db, "triggers.check_interval", fallback_interval))
|
||||||
if enabled:
|
if enabled:
|
||||||
fired = await check_and_fire_triggers()
|
fired = await check_and_fire_triggers()
|
||||||
if fired:
|
if fired:
|
||||||
log.info("triggers_fired", count=fired)
|
log.info("triggers_fired", count=fired)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("trigger_worker_iteration_failed", error=str(e))
|
log.error("trigger_worker_iteration_failed", error=f"{type(e).__name__}: {e}")
|
||||||
await asyncio.sleep(max(5, int(await _get_setting_sleep())))
|
# Always sleep with a safe positive interval; never let a DB error
|
||||||
|
# escape this loop and crash the worker.
|
||||||
|
try:
|
||||||
|
sleep_for = max(5, int(interval))
|
||||||
|
except Exception:
|
||||||
|
sleep_for = fallback_interval
|
||||||
|
await asyncio.sleep(sleep_for)
|
||||||
|
|
||||||
|
|
||||||
async def _get_setting(db, key: str, default):
|
async def _get_setting(db, key: str, default):
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ services:
|
|||||||
DEFAULT_LLM_BASE_URL: ${DEFAULT_LLM_BASE_URL:-http://host.docker.internal:1234/v1}
|
DEFAULT_LLM_BASE_URL: ${DEFAULT_LLM_BASE_URL:-http://host.docker.internal:1234/v1}
|
||||||
DEFAULT_LLM_API_KEY: ${DEFAULT_LLM_API_KEY:-dummy}
|
DEFAULT_LLM_API_KEY: ${DEFAULT_LLM_API_KEY:-dummy}
|
||||||
DEFAULT_LLM_MODEL: ${DEFAULT_LLM_MODEL:-local-model}
|
DEFAULT_LLM_MODEL: ${DEFAULT_LLM_MODEL:-local-model}
|
||||||
|
# Default embeddings settings (overridable via admin panel)
|
||||||
|
DEFAULT_EMBEDDING_PROVIDER: ${DEFAULT_EMBEDDING_PROVIDER:-hash}
|
||||||
|
DEFAULT_EMBEDDING_BASE_URL: ${DEFAULT_EMBEDDING_BASE_URL:-}
|
||||||
|
DEFAULT_EMBEDDING_API_KEY: ${DEFAULT_EMBEDDING_API_KEY:-}
|
||||||
|
DEFAULT_EMBEDDING_MODEL: ${DEFAULT_EMBEDDING_MODEL:-text-embedding-3-small}
|
||||||
|
DEFAULT_EMBEDDING_DIM: ${DEFAULT_EMBEDDING_DIM:-0}
|
||||||
|
DEFAULT_EMBEDDING_REQUEST_TIMEOUT: ${DEFAULT_EMBEDDING_REQUEST_TIMEOUT:-60}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -72,6 +79,12 @@ services:
|
|||||||
# Print admin setup token to console on first run
|
# Print admin setup token to console on first run
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
build:
|
build:
|
||||||
@@ -85,6 +98,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
qdrant:
|
qdrant:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-airpg}:${POSTGRES_PASSWORD:-airpg_secret}@postgres:5432/${POSTGRES_DB:-airpg}
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-airpg}:${POSTGRES_PASSWORD:-airpg_secret}@postgres:5432/${POSTGRES_DB:-airpg}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
@@ -94,6 +109,12 @@ services:
|
|||||||
DEFAULT_LLM_BASE_URL: ${DEFAULT_LLM_BASE_URL:-http://host.docker.internal:1234/v1}
|
DEFAULT_LLM_BASE_URL: ${DEFAULT_LLM_BASE_URL:-http://host.docker.internal:1234/v1}
|
||||||
DEFAULT_LLM_API_KEY: ${DEFAULT_LLM_API_KEY:-dummy}
|
DEFAULT_LLM_API_KEY: ${DEFAULT_LLM_API_KEY:-dummy}
|
||||||
DEFAULT_LLM_MODEL: ${DEFAULT_LLM_MODEL:-local-model}
|
DEFAULT_LLM_MODEL: ${DEFAULT_LLM_MODEL:-local-model}
|
||||||
|
DEFAULT_EMBEDDING_PROVIDER: ${DEFAULT_EMBEDDING_PROVIDER:-hash}
|
||||||
|
DEFAULT_EMBEDDING_BASE_URL: ${DEFAULT_EMBEDDING_BASE_URL:-}
|
||||||
|
DEFAULT_EMBEDDING_API_KEY: ${DEFAULT_EMBEDDING_API_KEY:-}
|
||||||
|
DEFAULT_EMBEDDING_MODEL: ${DEFAULT_EMBEDDING_MODEL:-text-embedding-3-small}
|
||||||
|
DEFAULT_EMBEDDING_DIM: ${DEFAULT_EMBEDDING_DIM:-0}
|
||||||
|
DEFAULT_EMBEDDING_REQUEST_TIMEOUT: ${DEFAULT_EMBEDDING_REQUEST_TIMEOUT:-60}
|
||||||
WORKER_MODE: "1"
|
WORKER_MODE: "1"
|
||||||
command: ["python", "-m", "app.workers.main"]
|
command: ["python", "-m", "app.workers.main"]
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in New Issue
Block a user