deer-flow/backend/tests/test_pg_schema_integration.py
Ryker_Feng 063d62c3c3
feat(persistence): support custom postgres schema (#3442)
* feat(persistence): support custom postgres schema

* fix(persistence): address CI lint/test failures and review feedback

- Map missing psycopg import to actionable POSTGRES_INSTALL guidance in
  sync/async schema-creation helpers
- Accept SQLAlchemy compound DSN schemes (postgresql+asyncpg) when
  injecting search_path, normalizing to a libpq-consumable DSN
- Guard keyword-DSN tests with importorskip so they skip without psycopg
- Set database=None in sync checkpointer none-fix test to avoid MagicMock
  backend resolution
- Apply ruff import sort and format

* fix(persistence): address pg-schema review feedback

- Restrict postgres_schema regex to lowercase-only so the quoted CREATE
  SCHEMA matches the unquoted search_path (PG case-folds it), fixing the
  mixed-case bug where tables silently fell back to public.
- Replace shlex.join/split with libpq-correct backslash escaping for the
  options parameter so values containing spaces survive intact.
- Add normalize_libpq_dsn() and route the async checkpointer pool through
  dsn_with_search_path() so a +asyncpg suffix is stripped and existing DSN
  options (e.g. statement_timeout) are merged instead of overridden.
- Extract shared ensure_postgres_schema()/ensure_postgres_schema_async()
  helpers (mapping missing psycopg to the install hint) used by all four
  provider sites.
- Tests: reject mixed-case schemas, preserve space-containing libpq option,
  cover normalize_libpq_dsn, and assert pool search_path via DSN.

* fix(persistence): align pg-schema test with merged store API

The main merge moved the sync Store factory to the single-path
_resolve_store_config/_sync_store_cm design, dropping the PR's
_sync_store_from_database helper. The integration test still imported
the removed symbol, breaking test collection (backend-unit-tests).
Resolve the store config from a DatabaseConfig and drive it through
_sync_store_cm instead.

* fix(persistence): address pg-schema review feedback

- reject trailing/leading whitespace in postgres_schema via re.fullmatch
  (a $-anchored re.match let "deerflow\n" through, silently landing tables
  in public)
- re-escape all whitespace (TAB/CR/LF) when re-joining libpq options so a
  caller's pre-existing options value round-trips losslessly
- re-validate the identifier inside create_schema_sql as defense-in-depth
  at the SQL-emitting boundary
- accept the postgres:// short scheme in the alembic search_path injection
- close the sync psycopg connection explicitly (psycopg3 __exit__ does not
  close()), mirroring the async path
- drop the partial checkpointer/store reset on a database config change;
  database is restart-required and the ORM engine is not rebuilt, so a
  partial reset would half-migrate the deployment

* docs(config): complete the postgres_schema migration checklist

Address PR review (P1): the documented `public`->schema migration only
moved runs, run_events, threads_meta, feedback, and users. That strands
every other DeerFlow-owned table -- the four channel_* tables, both
scheduled_* tables, agents, and (critically) alembic_version -- in
`public`. On restart bootstrap treats the partially-populated target
schema as unversioned, re-baselines it, and replays migrations while the
real rows stay invisible in `public`.

List the full owned set explicitly, call out alembic_version as required,
and keep the "discover the rest" query for version-drift safety.

* refactor(checkpointer): drop test-only _sync_checkpointer_from_database

Address PR review: the helper was only reached by the env-gated
integration test and re-implemented the DatabaseConfig->CheckpointerConfig
backend resolution that _resolve_checkpointer_config already owns, so a
future backend added there would silently miss this path. Mirror the store
side of the same test, which reuses the production path directly:
_resolve_checkpointer_config(...) + _sync_checkpointer_cm(...).
2026-07-30 13:51:17 +08:00

113 lines
4.8 KiB
Python

"""Optional live PostgreSQL schema integration tests for issue #3380."""
from __future__ import annotations
import os
import uuid
from types import SimpleNamespace
import pytest
from sqlalchemy import text
from deerflow.config.database_config import DatabaseConfig
from deerflow.persistence.engine import close_engine, get_engine, init_engine_from_config
from deerflow.runtime.checkpointer.async_provider import make_checkpointer
from deerflow.runtime.checkpointer.provider import _resolve_checkpointer_config, _sync_checkpointer_cm
from deerflow.runtime.store.async_provider import make_store
from deerflow.runtime.store.provider import _resolve_store_config, _sync_store_cm
POSTGRES_URL = os.getenv("DEERFLOW_TEST_POSTGRES_URL")
pytestmark = pytest.mark.skipif(
not POSTGRES_URL,
reason="set DEERFLOW_TEST_POSTGRES_URL to run live PostgreSQL schema integration tests",
)
@pytest.mark.anyio
async def test_postgres_schema_places_orm_checkpointer_and_store_tables_together():
"""Verify a real PostgreSQL backend places all persistence tables in one schema."""
schema = f"deerflow_test_{uuid.uuid4().hex[:12]}"
db_config = DatabaseConfig(backend="postgres", postgres_url=POSTGRES_URL or "", postgres_schema=schema)
app_config = SimpleNamespace(checkpointer=None, database=db_config)
await init_engine_from_config(db_config)
engine = get_engine()
assert engine is not None
try:
async with make_checkpointer(app_config) as checkpointer:
assert checkpointer is not None
async with make_store(app_config) as store:
assert store is not None
async with engine.begin() as conn:
rows = (
await conn.execute(
text(
"""
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema IN (:schema, 'public')
ORDER BY table_schema, table_name
"""
),
{"schema": schema},
)
).all()
by_schema = {(row.table_schema, row.table_name) for row in rows}
orm_tables = {"runs", "run_events", "threads_meta", "feedback", "users"}
assert {("public", table) for table in orm_tables}.isdisjoint(by_schema)
assert {(schema, table) for table in orm_tables}.issubset(by_schema)
assert any(table_schema == schema and "checkpoint" in table_name for table_schema, table_name in by_schema)
assert any(table_schema == schema and ("store" in table_name or "migration" in table_name) for table_schema, table_name in by_schema)
finally:
async with engine.begin() as conn:
await conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await close_engine()
def test_sync_postgres_schema_places_checkpointer_and_store_tables_together():
"""Verify the sync (psycopg) path honours search_path via the DSN encoding.
This exercises ``dsn_with_search_path`` against a real psycopg connection,
guarding against regression of the ``%20`` vs ``+`` libpq encoding bug.
"""
import psycopg
schema = f"deerflow_test_{uuid.uuid4().hex[:12]}"
db_config = DatabaseConfig(
backend="postgres",
postgres_url=POSTGRES_URL or "",
postgres_schema=schema,
)
checkpointer_config = _resolve_checkpointer_config(SimpleNamespace(checkpointer=None, database=db_config))
store_config = _resolve_store_config(SimpleNamespace(checkpointer=None, database=db_config))
try:
with _sync_checkpointer_cm(checkpointer_config) as checkpointer:
assert checkpointer is not None
with _sync_store_cm(store_config) as store:
assert store is not None
with psycopg.connect(POSTGRES_URL or "", autocommit=True) as conn:
rows = conn.execute(
"""
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema IN (%s, 'public')
ORDER BY table_schema, table_name
""",
(schema,),
).fetchall()
by_schema = {(table_schema, table_name) for table_schema, table_name in rows}
assert any(table_schema == schema and "checkpoint" in table_name for table_schema, table_name in by_schema)
assert any(table_schema == schema and ("store" in table_name or "migration" in table_name) for table_schema, table_name in by_schema)
# The DeerFlow LangGraph tables must NOT leak into public.
assert not any(table_schema == "public" and ("checkpoint" in table_name or table_name == "store") for table_schema, table_name in by_schema)
finally:
with psycopg.connect(POSTGRES_URL or "", autocommit=True) as conn:
conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')