mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 02:46:02 +00:00
* 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(...).
123 lines
4.7 KiB
Python
123 lines
4.7 KiB
Python
"""Alembic environment for DeerFlow application tables.
|
|
|
|
ONLY manages DeerFlow's tables (runs, threads_meta, feedback, users,
|
|
run_events, channel_connections, channel_credentials, channel_oauth_states,
|
|
channel_conversations).
|
|
|
|
LangGraph's checkpointer tables (``checkpoints``, ``checkpoint_blobs``,
|
|
``checkpoint_writes``, ``checkpoint_migrations``) are managed by LangGraph
|
|
itself -- they have their own schema lifecycle and must not be touched by
|
|
Alembic. The ``include_object`` filter below explicitly excludes them so a
|
|
future ``alembic revision --autogenerate`` will not emit ``drop_table`` for
|
|
tables it does not own.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from logging.config import fileConfig
|
|
|
|
from alembic import context
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from deerflow.persistence.base import Base
|
|
from deerflow.persistence.migrations._env_filters import (
|
|
LANGGRAPH_OWNED_TABLES,
|
|
include_object,
|
|
)
|
|
|
|
# Re-export under the module namespace for any consumer that addresses them
|
|
# via ``env.LANGGRAPH_OWNED_TABLES`` / ``env.include_object``.
|
|
__all__ = ["LANGGRAPH_OWNED_TABLES", "include_object"]
|
|
|
|
# Import all models so metadata is populated.
|
|
try:
|
|
import deerflow.persistence.models as models # register ORM models with Base.metadata
|
|
|
|
_ = models
|
|
except ImportError:
|
|
# Models not available — migration will work with existing metadata only.
|
|
logging.getLogger(__name__).warning("Could not import deerflow.persistence.models; Alembic may not detect all tables")
|
|
|
|
config = context.config
|
|
if config.config_file_name is not None:
|
|
fileConfig(config.config_file_name)
|
|
|
|
target_metadata = Base.metadata
|
|
|
|
|
|
def run_migrations_offline() -> None:
|
|
url = config.get_main_option("sqlalchemy.url")
|
|
context.configure(
|
|
url=url,
|
|
target_metadata=target_metadata,
|
|
literal_binds=True,
|
|
render_as_batch=True,
|
|
include_object=include_object,
|
|
)
|
|
with context.begin_transaction():
|
|
context.run_migrations()
|
|
|
|
|
|
def do_run_migrations(connection):
|
|
context.configure(
|
|
connection=connection,
|
|
target_metadata=target_metadata,
|
|
render_as_batch=True, # Required for SQLite ALTER TABLE support
|
|
include_object=include_object,
|
|
)
|
|
with context.begin_transaction():
|
|
context.run_migrations()
|
|
|
|
|
|
async def run_migrations_online() -> None:
|
|
url = config.get_main_option("sqlalchemy.url")
|
|
# When a custom Postgres schema is configured, pin the alembic-spawned
|
|
# engine's search_path to it. This engine is built from the bare URL and
|
|
# does NOT inherit the asyncpg ``server_settings`` the app engine sets via
|
|
# ``connect_args``, so without this both ``alembic_version`` and every
|
|
# migration's DDL would land in the default (``public``) schema while the
|
|
# ORM tables land in the custom schema. ``init_engine`` has already created
|
|
# the schema (``CREATE SCHEMA IF NOT EXISTS``) before bootstrap runs.
|
|
pg_schema = config.get_main_option("deerflow_pg_schema")
|
|
connect_args: dict = {}
|
|
# Accept both the canonical ``postgresql`` scheme and libpq's ``postgres``
|
|
# short scheme (with or without a SQLAlchemy ``+driver`` suffix) so a
|
|
# ``postgres://`` DSN still gets its search_path pinned instead of silently
|
|
# writing ``alembic_version`` + migration DDL to the default schema.
|
|
if pg_schema and url and url.split("+", 1)[0].split(":", 1)[0] in {"postgresql", "postgres"}:
|
|
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
|
|
|
|
connect_args = build_asyncpg_connect_args(pg_schema)
|
|
|
|
connectable = create_async_engine(url, connect_args=connect_args)
|
|
|
|
# Cross-process bootstrap safety for SQLite: every connection alembic
|
|
# opens needs a wide ``busy_timeout`` so that when another process holds
|
|
# the file write lock (e.g. mid-bootstrap), our writes wait instead of
|
|
# raising ``database is locked``. The production engine in
|
|
# ``deerflow.persistence.engine`` sets this on its own connections, but
|
|
# alembic spawns its OWN engine here -- those connections wouldn't inherit
|
|
# anything unless we wire the same hook on this one.
|
|
if connectable.url.drivername.startswith("sqlite"):
|
|
from sqlalchemy import event
|
|
|
|
@event.listens_for(connectable.sync_engine, "connect")
|
|
def _alembic_sqlite_busy_timeout(dbapi_conn, _record): # noqa: ARG001
|
|
cursor = dbapi_conn.cursor()
|
|
try:
|
|
cursor.execute("PRAGMA busy_timeout=30000;")
|
|
finally:
|
|
cursor.close()
|
|
|
|
async with connectable.connect() as connection:
|
|
await connection.run_sync(do_run_migrations)
|
|
await connectable.dispose()
|
|
|
|
|
|
if context.is_offline_mode():
|
|
run_migrations_offline()
|
|
else:
|
|
asyncio.run(run_migrations_online())
|