""" Database Base Configuration =========================== CTO-201: Async SQLAlchemy setup Features: - SQLAlchemy 2.0 async engine - PostgreSQL (asyncpg) - 188 PostgreSQL - Session dependency injection 統帥鐵律 2026-03-23: - 絕對禁止 SQLite - 所有 Episodic Memory 必須使用 PostgreSQL """ import asyncio from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from fastapi import HTTPException from sqlalchemy import text from sqlalchemy.exc import DBAPIError from sqlalchemy.exc import TimeoutError as SQLAlchemyTimeoutError from sqlalchemy.ext.asyncio import ( AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine, ) from sqlalchemy.orm import DeclarativeBase from src.core.config import settings from src.core.context import get_current_project_context from src.core.logging import get_logger # ============================================================================= # Base Model # ============================================================================= class Base(DeclarativeBase): """SQLAlchemy declarative base""" pass # ============================================================================= # Engine & Session Factory # ============================================================================= _engine: AsyncEngine | None = None _session_factory: async_sessionmaker[AsyncSession] | None = None logger = get_logger("awoooi.db") def _raise_unauthorized_db_context(msg: str) -> None: context = get_current_project_context() logger.error( "db_context_missing", reason=msg, project_id=context.get("project_id"), project_id_source=context.get("source"), request_id=context.get("request_id"), ) raise HTTPException(status_code=401, detail="Missing tenant context: project_id is required") def get_engine() -> AsyncEngine: """ Get or create async engine 統帥鐵律 2026-03-23: - 使用 DATABASE_URL (PostgreSQL) - 絕對禁止 SQLite """ global _engine if _engine is None: database_url = settings.DATABASE_URL # 統帥鐵律: 禁止 SQLite (AWOOOI 憲法) # 🔴 違反此規則必須立即報錯,禁止 fallback if "sqlite" in database_url.lower(): import structlog logger = structlog.get_logger(__name__) logger.error("sqlite_forbidden", url=database_url) raise ValueError( "SQLite is FORBIDDEN by AWOOOI Constitution. " "Set DATABASE_URL to PostgreSQL: postgresql+asyncpg://user:pass@host:5432/db" ) _engine = create_async_engine( database_url, echo=settings.DEBUG, pool_size=settings.DATABASE_POOL_SIZE, max_overflow=settings.DATABASE_MAX_OVERFLOW, pool_timeout=settings.DATABASE_POOL_TIMEOUT_SECONDS, pool_pre_ping=True, ) return _engine def get_session_factory() -> async_sessionmaker[AsyncSession]: """Get or create session factory""" global _session_factory if _session_factory is None: _session_factory = async_sessionmaker( bind=get_engine(), class_=AsyncSession, expire_on_commit=False, autoflush=False, ) return _session_factory # ============================================================================= # Dependency Injection # ============================================================================= async def get_db() -> AsyncGenerator[AsyncSession, None]: """ FastAPI dependency for database session Usage: @router.get("/items") async def get_items(db: AsyncSession = Depends(get_db)): ... """ factory = get_session_factory() async with factory() as session: try: from src.core.context import get_current_project_id # AwoooP Phase 2.3 (2026-05-04 ogt): SET LOCAL app.project_id 讓 RLS Policy 生效 # Fail-Closed RLS: 遇到未授權情境拋出錯誤而非回退到 "awoooi" pid = get_current_project_id() if not pid: _raise_unauthorized_db_context( "Unauthorized: project_id is missing in context (Fail-Closed RLS)" ) await session.execute( text("SELECT set_config('app.project_id', :pid, TRUE)"), {"pid": pid}, ) yield session await session.commit() except Exception: await session.rollback() raise @asynccontextmanager async def get_db_context(project_id: str | None = None) -> AsyncGenerator[AsyncSession, None]: """ Context manager for database session (non-FastAPI usage) AwoooP Phase 2.3/2.4: 優先序 — 明確參數 > contextvar(缺失則 fail-closed) - Phase 2.3: 啟用 RLS tenant isolation(SET LOCAL app.project_id) - Phase 2.4: 從 asyncio contextvar 讀取 background loop 的 project_id Usage: async with get_db_context() as db: # 繼承 contextvar(缺失將 fail-closed) ... async with get_db_context("other-tenant") as db: # 明確指定 tenant ... """ from src.core.context import get_current_project_id effective_pid = project_id if project_id is not None else get_current_project_id() if not effective_pid: _raise_unauthorized_db_context("Unauthorized: project_id is missing in context (Fail-Closed RLS)") factory = get_session_factory() async with factory() as session: try: await session.execute( text("SELECT set_config('app.project_id', :pid, TRUE)"), {"pid": effective_pid}, ) yield session await session.commit() except Exception: await session.rollback() raise # ============================================================================= # Initialization # ============================================================================= _DB_BOOTSTRAP_LOCK_NAME = "awoooi:init_db:ddl" _DB_BOOTSTRAP_LOCK_WAIT_SECONDS = 45.0 _DB_BOOTSTRAP_LOCK_POLL_SECONDS = 2.0 _DB_BOOTSTRAP_DDL_WAIT_SECONDS = 120.0 def _is_database_connection_budget_error(exc: BaseException) -> bool: """Return True for PostgreSQL role/pool exhaustion without hiding other DB errors.""" seen: set[int] = set() current: BaseException | None = exc budget_markers = ( "too many connections", "remaining connection slots are reserved", "connection limit", "queuepool limit", ) while current is not None and id(current) not in seen: seen.add(id(current)) message = f"{type(current).__name__}: {current}".lower() if any(marker in message for marker in budget_markers): return True current = ( getattr(current, "orig", None) or getattr(current, "__cause__", None) or getattr(current, "__context__", None) ) return False def _is_database_bootstrap_ddl_timeout(exc: BaseException) -> bool: """Return True only for optional bootstrap DDL canceled by DB timeout.""" seen: set[int] = set() current: BaseException | None = exc timeout_markers = ( "querycancelederror", "canceling statement due to statement timeout", "statement timeout", ) while current is not None and id(current) not in seen: seen.add(id(current)) message = f"{type(current).__name__}: {current}".lower() if any(marker in message for marker in timeout_markers): return True current = ( getattr(current, "orig", None) or getattr(current, "__cause__", None) or getattr(current, "__context__", None) ) return False async def init_db() -> None: """ Initialize database tables Call this at application startup. """ engine = get_engine() try: async with engine.connect() as lock_conn: # 2026-05-24 ogt + Codex: 兩個 API replica 同時啟動時,PostgreSQL 會在 # ALTER TABLE ... IF NOT EXISTS 上互相等待並 deadlock。整段 bootstrap # DDL 必須序列化。 # 2026-06-30 Codex: production rollout 觀察到新 API pod 卡在 init_db() # 超過 startup probe 預算。bootstrap DDL 是冪等補欄流程,不能再用 # 無界 pg_advisory_lock 把新版 API route 擋成永久舊版;改為有界等待, # 逾時則 fail-visible skip,讓 serving path 先恢復,再由 verifier 回報。 # 2026-07-01 Codex: production DB role has CONNECTION LIMIT 2. Keep # bootstrap on the lock connection so pool_size=1 can still start. lock_acquired = await _try_acquire_bootstrap_lock(lock_conn) if not lock_acquired: return try: try: await asyncio.wait_for( _run_init_db_ddl(lock_conn), timeout=_DB_BOOTSTRAP_DDL_WAIT_SECONDS, ) except TimeoutError: logger.warning( "database_bootstrap_ddl_timeout_skipped", timeout_seconds=_DB_BOOTSTRAP_DDL_WAIT_SECONDS, lock_name=_DB_BOOTSTRAP_LOCK_NAME, ) except DBAPIError as exc: if not _is_database_bootstrap_ddl_timeout(exc): raise logger.warning( "database_bootstrap_statement_timeout_skipped", error_type=type(exc).__name__, lock_name=_DB_BOOTSTRAP_LOCK_NAME, ) finally: await lock_conn.execute( text("SELECT pg_advisory_unlock(hashtext(:lock_name))"), {"lock_name": _DB_BOOTSTRAP_LOCK_NAME}, ) await lock_conn.commit() except (DBAPIError, SQLAlchemyTimeoutError, OSError, RuntimeError) as exc: if _is_database_connection_budget_error(exc): logger.warning( "database_bootstrap_connection_budget_exhausted_skipped", error_type=type(exc).__name__, lock_name=_DB_BOOTSTRAP_LOCK_NAME, pool_size=settings.DATABASE_POOL_SIZE, max_overflow=settings.DATABASE_MAX_OVERFLOW, ) return raise async def _try_acquire_bootstrap_lock(lock_conn: object) -> bool: loop = asyncio.get_running_loop() deadline = loop.time() + _DB_BOOTSTRAP_LOCK_WAIT_SECONDS while True: result = await lock_conn.execute( text("SELECT pg_try_advisory_lock(hashtext(:lock_name))"), {"lock_name": _DB_BOOTSTRAP_LOCK_NAME}, ) if bool(result.scalar()): logger.info( "database_bootstrap_lock_acquired", lock_name=_DB_BOOTSTRAP_LOCK_NAME, ) return True remaining = deadline - loop.time() if remaining <= 0: logger.warning( "database_bootstrap_lock_timeout_skipped", timeout_seconds=_DB_BOOTSTRAP_LOCK_WAIT_SECONDS, lock_name=_DB_BOOTSTRAP_LOCK_NAME, ) return False await asyncio.sleep(min(_DB_BOOTSTRAP_LOCK_POLL_SECONDS, remaining)) async def _run_init_db_ddl(conn: AsyncConnection) -> None: """ Run idempotent DB bootstrap DDL while caller holds the bootstrap advisory lock. """ # 2026-04-15 ogt: 多 replica 並行啟動競爭修復 # 問題:單一大 transaction 裡兩個 pod 同時建 table → 其中一個 CREATE INDEX 失敗 # PostgreSQL 中 transaction 內任何錯誤導致整個 transaction ROLLBACK # → table + index 全消失 → 下次重啟同樣問題 → 無限 CrashLoop # 修法:每個 table 獨立 transaction;先 DROP INDEX IF EXISTS 清殘留孤兒 index; # 捕捉 "already exists" 讓並行 pod 優雅跳過 existing = set(await conn.run_sync( lambda c: __import__('sqlalchemy', fromlist=['inspect']).inspect(c).get_table_names() )) await conn.commit() for table in Base.metadata.sorted_tables: if table.name not in existing: try: async with conn.begin(): # 先清殘留孤兒 index(前次 CrashLoop 留下的部分狀態) for index in table.indexes: await conn.execute(text(f'DROP INDEX IF EXISTS "{index.name}"')) await conn.run_sync(table.create) except Exception as exc: if "already exists" in str(exc).lower(): pass # 並行 pod 已建好,忽略 else: raise async with conn.begin(): # 2026-04-02 Claude Code: 確保 risklevel enum 包含 'high' 值 # Phase 23 新增,避免舊 DB 缺少此值導致 InvalidTextRepresentation await conn.execute( text(""" DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_enum WHERE enumtypid = 'risklevel'::regtype AND enumlabel = 'high' ) THEN ALTER TYPE risklevel ADD VALUE 'high'; END IF; END $$; """) ) # 2026-04-09 Claude Sonnet 4.6: Sprint 5R C1 修復 — 批准執行閉環 Telegram 訊息持久化欄位 # create_all 不做 ALTER,需手動補欄位 await conn.execute( text(""" ALTER TABLE approval_records ADD COLUMN IF NOT EXISTS telegram_message_id INTEGER, ADD COLUMN IF NOT EXISTS telegram_chat_id INTEGER; """) ) # 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3.5 Playbook PostgreSQL 持久化 # ADR-085: AI 學習成果不可存 Cache — trust_score、EWMA 必須永久保存 # playbooks 表已存在(15 筆舊資料),補加新欄位 await conn.execute( text(""" ALTER TABLE playbooks ADD COLUMN IF NOT EXISTS trust_score FLOAT NOT NULL DEFAULT 0.3, ADD COLUMN IF NOT EXISTS requires_approval_level VARCHAR(20) NOT NULL DEFAULT 'auto', ADD COLUMN IF NOT EXISTS stateful_targets JSONB NOT NULL DEFAULT '[]', ADD COLUMN IF NOT EXISTS requires_pre_backup BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1, ADD COLUMN IF NOT EXISTS parent_playbook_id VARCHAR(36), ADD COLUMN IF NOT EXISTS supersedes_playbook_id VARCHAR(36), ADD COLUMN IF NOT EXISTS version_reason TEXT; """) ) await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_playbook_lineage " "ON playbooks(parent_playbook_id, version);" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_playbook_supersedes " "ON playbooks(supersedes_playbook_id) WHERE supersedes_playbook_id IS NOT NULL;" )) await conn.execute(text( "UPDATE playbooks SET parent_playbook_id = playbook_id WHERE parent_playbook_id IS NULL;" )) # 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 8D 感官升級 # ADR-084: EvidenceSnapshot 加入 Phase 4 動態異常上下文(anomaly_context) await conn.execute( text(""" ALTER TABLE incident_evidence ADD COLUMN IF NOT EXISTS anomaly_context JSONB; """) ) # W2 PR-V1: SelfHealingValidator 補欄 (2026-04-28 ogt + Claude Sonnet 4.6) # incident_evidence 加 self_healing_score + self_healing_detail # create_all 不做 ALTER,防禦性補加(prod 已存在的表不會自動加欄) await conn.execute( text(""" ALTER TABLE incident_evidence ADD COLUMN IF NOT EXISTS self_healing_score FLOAT, ADD COLUMN IF NOT EXISTS self_healing_detail JSONB; """) ) # 2026-04-29 ogt + Claude Opus 4.7: PR-K1 防禦性 ALTER (db-expert finding) # P1.6 (2026-04-24) ORM 已加 timeline_events.incident_id,但 prod 若在 P1.6 前 # 已建表,create_all 跳過已存在的表 → ALTER 不會跑 → ORM 寫入 SELECT 找不到欄位 # 補防禦性 IF NOT EXISTS(已有 column 為 no-op,安全) await conn.execute( text(""" ALTER TABLE timeline_events ADD COLUMN IF NOT EXISTS incident_id VARCHAR(64); """) ) # 2026-04-29 ogt + Claude Opus 4.7: M4 KMWriter 反查鏈 + 冪等補欄 # CD #1115-1117 全 failure 根因:commit c22e5f33 加 ORM 欄位但無對應 ALTER # 錯誤:column "related_approval_id" of relation "knowledge_entries" does not exist # 2026-07-09 Codex: 舊 prod schema 也可能缺 symptoms_hash,會讓 KM list/search # 只剩 source-backed fallback;同一段補齊,避免正式 KM 看似歸零。 # 補防禦性 ALTER(同 timeline_events 模式)+ 對應 index await conn.execute( text(""" ALTER TABLE knowledge_entries ADD COLUMN IF NOT EXISTS related_approval_id VARCHAR(64), ADD COLUMN IF NOT EXISTS path_type VARCHAR(50), ADD COLUMN IF NOT EXISTS symptoms_hash VARCHAR; """) ) # M3 冪等 unique index (incident_id + path_type) await conn.execute(text( "CREATE UNIQUE INDEX IF NOT EXISTS uix_knowledge_incident_path " "ON knowledge_entries(related_incident_id, path_type) " "WHERE related_incident_id IS NOT NULL AND path_type IS NOT NULL;" )) # M4 反查鏈 partial index(approval_id → KM 反查) await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_knowledge_related_approval " "ON knowledge_entries(related_approval_id) " "WHERE related_approval_id IS NOT NULL;" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_knowledge_symptoms_hash " "ON knowledge_entries(symptoms_hash) " "WHERE symptoms_hash IS NOT NULL;" )) # W2 PR-L1 2026-04-28 ogt + Claude Sonnet 4.6: KM→Playbook 互饋回路(飛輪 C3 修復) # PlaybookRecord 新增 review_required 欄位 # 已存在表不會被 create_all 重建,必須手動 ALTER await conn.execute( text(""" ALTER TABLE playbooks ADD COLUMN IF NOT EXISTS review_required BOOLEAN NOT NULL DEFAULT FALSE; """) ) await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_playbook_review_required " "ON playbooks(review_required) WHERE review_required = true;" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_timeline_incident_id " "ON timeline_events(incident_id);" )) # AwoooP Phase 2.6 (2026-05-04 ogt): budget_ledger 建表(ADR-120 Token Budget Hard Kill) await conn.execute(text(""" CREATE TABLE IF NOT EXISTS budget_ledger ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, project_id VARCHAR(64) NOT NULL DEFAULT 'awoooi', agent_id VARCHAR(128), run_id UUID, model VARCHAR(64), provider VARCHAR(32), prompt_tokens INT, completion_tokens INT, cost_usd NUMERIC(10, 4) NOT NULL DEFAULT 0.0000, recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); """)) await conn.execute(text( "CREATE INDEX IF NOT EXISTS idx_budget_ledger_project_date " "ON budget_ledger(project_id, recorded_at DESC);" )) # AwoooP Phase 2.3 (2026-05-04 ogt): 四表加 project_id(RLS 多租戶隔離) # 防禦性 ALTER — 已存在欄位為 no-op,安全。 # Batch 1 RLS migration 執行後,app.project_id 由 get_db_context() 自動設置。 await conn.execute(text( "ALTER TABLE incidents " "ADD COLUMN IF NOT EXISTS project_id VARCHAR(64) NOT NULL DEFAULT 'awoooi';" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS idx_incidents_project_id " "ON incidents (project_id);" )) await conn.execute(text( "ALTER TABLE knowledge_entries " "ADD COLUMN IF NOT EXISTS project_id VARCHAR(64) NOT NULL DEFAULT 'awoooi';" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS idx_knowledge_entries_project_id " "ON knowledge_entries (project_id);" )) await conn.execute(text( "ALTER TABLE playbooks " "ADD COLUMN IF NOT EXISTS project_id VARCHAR(64) NOT NULL DEFAULT 'awoooi';" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS idx_playbooks_project_id " "ON playbooks (project_id);" )) await conn.execute(text( "ALTER TABLE audit_logs " "ADD COLUMN IF NOT EXISTS project_id VARCHAR(64) NOT NULL DEFAULT 'awoooi';" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS idx_audit_logs_project_id " "ON audit_logs (project_id);" )) # 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 6 自我治理閉環 # ADR-087: ai_governance_events 不可變 Event Sourcing 表 # asyncpg 不允許 prepared statement 內多條指令,必須分開 execute await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_ai_governance_event_type " "ON ai_governance_events (event_type);" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_ai_governance_triggered_at " "ON ai_governance_events (triggered_at);" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_ai_governance_resolved " "ON ai_governance_events (resolved);" )) async def close_db() -> None: """ Close database connections Call this at application shutdown. """ global _engine, _session_factory if _engine is not None: await _engine.dispose() _engine = None _session_factory = None