diff --git a/backend/packages/harness/deerflow/config/database_config.py b/backend/packages/harness/deerflow/config/database_config.py index 84875a2f2..ee984a5fe 100644 --- a/backend/packages/harness/deerflow/config/database_config.py +++ b/backend/packages/harness/deerflow/config/database_config.py @@ -309,6 +309,14 @@ class DatabaseConfig(BaseModel): return f"sqlite:///{self.sqlite_path}" if self.backend == "postgres": url = self.postgres_url + if self.postgres_schema: + # The synchronous agent stores use psycopg via SQLAlchemy, + # so they need the same search_path as the async ORM engine + # and LangGraph stores. Keep the option merge in the shared + # PostgreSQL helper so existing libpq options are preserved. + from deerflow.persistence.postgres_schema import dsn_with_search_path + + url = dsn_with_search_path(url, self.postgres_schema) if url.startswith("postgresql+asyncpg://"): url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1) elif url.startswith("postgresql://"): diff --git a/backend/packages/harness/deerflow/persistence/AGENTS.md b/backend/packages/harness/deerflow/persistence/AGENTS.md index 74bd638e3..850696418 100644 --- a/backend/packages/harness/deerflow/persistence/AGENTS.md +++ b/backend/packages/harness/deerflow/persistence/AGENTS.md @@ -1,3 +1,5 @@ # Persistence lifecycle Postgres bootstrap owns its session-scoped advisory lock until `pg_advisory_unlock` completes. Drain that unlock across host cancellation before leaving the SQLAlchemy connection context; repeated cancellation must not return a pooled session while it still holds the bootstrap mutex. Ordinary database errors remain best-effort and are logged. + +When `database.postgres_schema` is configured, both async ORM connections and the synchronous SQLAlchemy connections used by DB-backed custom agents and managed subagents must use the same `search_path`; preserve this invariant when adding another persistence entry point. diff --git a/backend/packages/harness/deerflow/persistence/postgres_schema.py b/backend/packages/harness/deerflow/persistence/postgres_schema.py index ba8ff5a8c..9d07f81b5 100644 --- a/backend/packages/harness/deerflow/persistence/postgres_schema.py +++ b/backend/packages/harness/deerflow/persistence/postgres_schema.py @@ -7,9 +7,9 @@ uses expect different mechanisms: - **asyncpg** (app ORM engine): only honours ``server_settings`` passed via SQLAlchemy ``connect_args``. It does not understand libpq's ``options=-c ...`` syntax. -- **psycopg** (LangGraph checkpointer/store): uses the libpq - ``options=-c search_path=...`` connection parameter, either as a pool - kwarg or encoded into the DSN query string. +- **psycopg** (LangGraph checkpointer/store and synchronous agent stores): uses + the libpq ``options=-c search_path=...`` connection parameter, either as a + pool kwarg or encoded into the DSN query string. Schema names are validated upstream by :class:`deerflow.config.database_config.DatabaseConfig` to be plain diff --git a/backend/tests/test_persistence_scaffold.py b/backend/tests/test_persistence_scaffold.py index 77ebaa466..b415d74d8 100644 --- a/backend/tests/test_persistence_scaffold.py +++ b/backend/tests/test_persistence_scaffold.py @@ -115,6 +115,21 @@ class TestDatabaseConfig: assert "deerflow" not in url.replace("/db", "") assert url.startswith("postgresql+asyncpg://") + def test_sync_postgres_url_uses_configured_schema(self): + c = DatabaseConfig(backend="postgres", postgres_url="postgresql://u:p@h:5432/db", postgres_schema="deerflow") + url = c.app_sync_sqlalchemy_url + assert url.startswith("postgresql+psycopg://") + assert "options=-c%20search_path%3Ddeerflow" in url + + def test_sync_postgres_url_preserves_existing_libpq_options(self): + c = DatabaseConfig( + backend="postgres", + postgres_url="postgresql://u:p@h:5432/db?options=-c%20statement_timeout%3D5000", + postgres_schema="deerflow", + ) + url = c.app_sync_sqlalchemy_url + assert "options=-c%20statement_timeout%3D5000%20-c%20search_path%3Ddeerflow" in url + # -- MemoryRunStore --