deer-flow/backend/tests/test_migration_0019_thread_incarnations.py
RongJie G 06c827903a
feat(persistence): add expand-phase thread incarnation storage (#5216)
* feat(persistence): expand thread incarnation storage

Add nullable thread and MCP task incarnation columns while preserving mixed-version writes. New thread records receive stable incarnation IDs, and new task rows copy the matching owned or shared thread incarnation without changing any read, claim, session, or deletion behavior.

* test(persistence): pin incarnation rollback compatibility

* test(api): pin internal thread response boundary

* fix(persistence): rebase incarnation rollout after projects

---------

Co-authored-by: CorgiBoyG <CorgiBoyG@users.noreply.github.com>
2026-09-09 15:52:09 +08:00

357 lines
17 KiB
Python

from __future__ import annotations
import asyncio
import importlib
import os
import uuid
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import pytest
import sqlalchemy as sa
from alembic import command as alembic_command
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.schema import CreateTable
from deerflow.persistence.bootstrap import _FORWARD_COMPATIBLE_REVISION, _get_alembic_config
_PREVIOUS = "0021_batch_acceptance"
_REVISION = "0019_thread_incarnations"
_MIGRATION_MODULE = "deerflow.persistence.migrations.versions.0019_thread_incarnations"
def _asyncpg_url(url: str | None) -> str | None:
if not url:
return url
parts = urlsplit(url)
scheme = "postgresql+asyncpg" if parts.scheme in {"postgres", "postgresql"} else parts.scheme
query = urlencode([(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key not in {"sslmode", "channel_binding"}])
return urlunsplit(parts._replace(scheme=scheme, query=query))
_POSTGRES_URL = _asyncpg_url(os.getenv("DEERFLOW_TEST_POSTGRES_URL") or os.getenv("TEST_POSTGRES_URI"))
def test_0019_matches_reviewed_rollback_contract(monkeypatch: pytest.MonkeyPatch) -> None:
migration = importlib.import_module(_MIGRATION_MODULE)
events: list[tuple[str, str, str, int | None, bool, object]] = []
def capture_preflight(table: str, column_name: str) -> None:
events.append(("preflight", table, column_name, None, True, None))
def capture_add(table: str, column: sa.Column) -> None:
assert isinstance(column.type, sa.VARCHAR)
events.append(("add", table, str(column.name), column.type.length, bool(column.nullable), column.server_default))
class NoAdditionalOperations:
def __getattr__(self, name: str):
raise AssertionError(f"0019 rollback contract does not allow direct Alembic operation: {name}")
monkeypatch.setattr(migration, "_assert_existing_column_compatible", capture_preflight)
monkeypatch.setattr(migration, "safe_add_column", capture_add)
monkeypatch.setattr(migration, "op", NoAdditionalOperations())
migration.upgrade()
assert migration.revision == _FORWARD_COMPATIBLE_REVISION == _REVISION
assert migration.down_revision == _PREVIOUS
assert events == [
("preflight", "threads_meta", "incarnation", None, True, None),
("preflight", "mcp_tasks", "thread_incarnation", None, True, None),
("add", "threads_meta", "incarnation", 32, True, None),
("add", "mcp_tasks", "thread_incarnation", 32, True, None),
]
@pytest.mark.asyncio
async def test_sqlite_0019_adds_and_drops_nullable_columns(tmp_path: Path) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}")
cfg = _get_alembic_config(engine)
try:
await asyncio.to_thread(alembic_command.upgrade, cfg, _PREVIOUS)
await asyncio.to_thread(alembic_command.upgrade, cfg, _REVISION)
async with engine.connect() as conn:
thread_columns = {column["name"]: column for column in await conn.run_sync(lambda sync: sa.inspect(sync).get_columns("threads_meta"))}
task_columns = {column["name"]: column for column in await conn.run_sync(lambda sync: sa.inspect(sync).get_columns("mcp_tasks"))}
version = await conn.scalar(sa.text("SELECT version_num FROM alembic_version"))
assert version == _REVISION
assert thread_columns["incarnation"]["nullable"] is True
assert task_columns["thread_incarnation"]["nullable"] is True
await asyncio.to_thread(alembic_command.downgrade, cfg, _PREVIOUS)
async with engine.connect() as conn:
thread_columns = {column["name"] for column in await conn.run_sync(lambda sync: sa.inspect(sync).get_columns("threads_meta"))}
task_columns = {column["name"] for column in await conn.run_sync(lambda sync: sa.inspect(sync).get_columns("mcp_tasks"))}
assert "incarnation" not in thread_columns
assert "thread_incarnation" not in task_columns
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_sqlite_0019_round_trip_preserves_parent_schema_data(tmp_path: Path) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'projects.db'}")
cfg = _get_alembic_config(engine)
try:
await asyncio.to_thread(alembic_command.upgrade, cfg, _PREVIOUS)
async with engine.begin() as connection:
await connection.execute(
sa.text(
"INSERT INTO projects "
"(id, user_id, name, instructions, presentation, status, created_at, updated_at) "
"VALUES ('project-1', 'user-1', 'Project', 'Keep me', "
"'{\"theme\":\"dark\"}', 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
)
)
await connection.execute(
sa.text("INSERT INTO threads_meta (thread_id, user_id, status, metadata_json, project_id, created_at, updated_at) VALUES ('thread-1', 'user-1', 'idle', '{}', 'project-1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
)
await connection.execute(
sa.text(
"INSERT INTO subagent_batches "
"(id, user_id, thread_id, submission_key, title, subagent_type, "
"status, total_items, max_live_items, max_running_items, "
"max_attempts, execution_spec, created_at, updated_at) "
"VALUES ('batch-1', 'user-1', 'thread-1', 'submission-1', "
"'Batch', 'general-purpose', 'completed', 1, 1, 1, 2, '{}', "
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
)
)
await connection.execute(
sa.text(
"INSERT INTO subagent_batch_items "
"(id, batch_id, item_key, position, prompt, acceptance_criteria, "
"acceptance_verdict, status, attempt, result_truncated, created_at, updated_at) "
"VALUES ('item-1', 'batch-1', 'item', 0, 'Prompt', "
":criteria, :verdict, 'succeeded', 1, 0, "
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
),
{
"criteria": '["must pass"]',
"verdict": '{"passed":true}',
},
)
async def assert_preserved(*, incarnation_columns: bool) -> None:
async with engine.connect() as connection:
project = (await connection.execute(sa.text("SELECT id, user_id, name, instructions, presentation, status FROM projects WHERE id = 'project-1'"))).one()
membership = await connection.scalar(sa.text("SELECT project_id FROM threads_meta WHERE thread_id = 'thread-1'"))
indexes = {index["name"] for index in await connection.run_sync(lambda sync: sa.inspect(sync).get_indexes("threads_meta"))}
thread_columns = {column["name"] for column in await connection.run_sync(lambda sync: sa.inspect(sync).get_columns("threads_meta"))}
task_columns = {column["name"] for column in await connection.run_sync(lambda sync: sa.inspect(sync).get_columns("mcp_tasks"))}
acceptance = (await connection.execute(sa.text("SELECT json_extract(acceptance_criteria, '$[0]'), json_extract(acceptance_verdict, '$.passed') FROM subagent_batch_items WHERE id = 'item-1'"))).one()
assert tuple(project[:4]) == ("project-1", "user-1", "Project", "Keep me")
assert project.status == "active"
assert '"theme"' in str(project.presentation) and '"dark"' in str(project.presentation)
assert membership == "project-1"
assert tuple(acceptance) == ("must pass", 1)
assert "ix_threads_meta_project_id" in indexes
assert ("incarnation" in thread_columns) is incarnation_columns
assert ("thread_incarnation" in task_columns) is incarnation_columns
await asyncio.to_thread(alembic_command.upgrade, cfg, _REVISION)
await assert_preserved(incarnation_columns=True)
await asyncio.to_thread(alembic_command.downgrade, cfg, _PREVIOUS)
await assert_preserved(incarnation_columns=False)
await asyncio.to_thread(alembic_command.upgrade, cfg, _REVISION)
await assert_preserved(incarnation_columns=True)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_sqlite_0019_reapply_does_not_report_varchar_drift(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}")
cfg = _get_alembic_config(engine)
migration = importlib.import_module(_MIGRATION_MODULE)
try:
await asyncio.to_thread(alembic_command.upgrade, cfg, _REVISION)
def reapply(sync_connection) -> None:
context = MigrationContext.configure(sync_connection)
with Operations.context(context):
migration.upgrade()
with caplog.at_level("WARNING", logger="deerflow.persistence.migrations._helpers"):
async with engine.begin() as connection:
await connection.run_sync(reapply)
assert "drifts from the model definition" not in caplog.text
finally:
await engine.dispose()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"column_sql",
[
"VARCHAR(16) NULL",
"TEXT NULL",
'VARCHAR(32) NOT NULL DEFAULT "legacy"',
"VARCHAR(32) NULL DEFAULT 'legacy'",
],
)
async def test_sqlite_0019_fails_fast_on_incompatible_existing_column(tmp_path: Path, column_sql: str) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}")
cfg = _get_alembic_config(engine)
try:
await asyncio.to_thread(alembic_command.upgrade, cfg, _PREVIOUS)
async with engine.begin() as connection:
await connection.execute(sa.text(f"ALTER TABLE mcp_tasks ADD COLUMN thread_incarnation {column_sql}"))
with pytest.raises(RuntimeError, match="with no default or DEFAULT NULL"):
await asyncio.to_thread(alembic_command.upgrade, cfg, _REVISION)
async with engine.connect() as connection:
version = await connection.scalar(sa.text("SELECT version_num FROM alembic_version"))
thread_columns = {column["name"] for column in await connection.run_sync(lambda sync: sa.inspect(sync).get_columns("threads_meta"))}
assert version == _PREVIOUS
assert "incarnation" not in thread_columns
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_sqlite_0019_accepts_wider_nullable_varchar(tmp_path: Path) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}")
cfg = _get_alembic_config(engine)
try:
await asyncio.to_thread(alembic_command.upgrade, cfg, _PREVIOUS)
async with engine.begin() as connection:
await connection.execute(sa.text("ALTER TABLE mcp_tasks ADD COLUMN thread_incarnation VARCHAR(64) NULL"))
await asyncio.to_thread(alembic_command.upgrade, cfg, _REVISION)
async with engine.connect() as connection:
columns = {column["name"]: column for column in await connection.run_sync(lambda sync: sa.inspect(sync).get_columns("mcp_tasks"))}
assert columns["thread_incarnation"]["type"].length == 64
assert columns["thread_incarnation"]["nullable"] is True
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_sqlite_0019_accepts_default_null_and_legacy_writer_omission(tmp_path: Path) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}")
cfg = _get_alembic_config(engine)
try:
await asyncio.to_thread(alembic_command.upgrade, cfg, _PREVIOUS)
async with engine.begin() as connection:
await connection.execute(sa.text("ALTER TABLE threads_meta ADD COLUMN incarnation VARCHAR(32) NULL DEFAULT NULL"))
await asyncio.to_thread(alembic_command.upgrade, cfg, _REVISION)
async with engine.begin() as connection:
await connection.execute(sa.text("INSERT INTO threads_meta (thread_id, status, metadata_json, created_at, updated_at) VALUES ('legacy-writer', 'idle', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"))
incarnation = await connection.scalar(sa.text("SELECT incarnation FROM threads_meta WHERE thread_id = 'legacy-writer'"))
assert incarnation is None
finally:
await engine.dispose()
def test_postgresql_0019_column_ddl_compiles_without_value_default() -> None:
table = sa.Table(
"incarnation_compile_check",
sa.MetaData(),
sa.Column("incarnation", sa.VARCHAR(length=32), nullable=True),
)
ddl = str(CreateTable(table).compile(dialect=postgresql.dialect()))
assert "incarnation VARCHAR(32)" in ddl
assert "DEFAULT" not in ddl
def test_postgresql_reflected_default_null_cast_is_semantically_null() -> None:
migration = importlib.import_module(_MIGRATION_MODULE)
assert migration._is_null_server_default("NULL::character varying") is True
assert migration._is_null_server_default("(NULL)::character varying") is True
assert migration._is_null_server_default("'legacy'::character varying") is False
assert migration._is_null_server_default("NULL::integer IS NULL") is False
def test_postgresql_ci_url_is_normalized_for_asyncpg() -> None:
assert _asyncpg_url("postgresql://user:pass@localhost/db?sslmode=disable") == "postgresql+asyncpg://user:pass@localhost/db"
@pytest.mark.asyncio
@pytest.mark.skipif(not _POSTGRES_URL, reason="set TEST_POSTGRES_URI or DEERFLOW_TEST_POSTGRES_URL to run live PostgreSQL tests")
async def test_postgresql_0019_accepts_default_null_and_legacy_writer_omission() -> None:
schema = f"deerflow_0019_{uuid.uuid4().hex[:12]}"
engine = create_async_engine(_POSTGRES_URL or "")
cfg = _get_alembic_config(engine, postgres_schema=schema)
try:
async with engine.begin() as connection:
await connection.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
await asyncio.to_thread(alembic_command.upgrade, cfg, _PREVIOUS)
async with engine.begin() as connection:
await connection.execute(sa.text(f'ALTER TABLE "{schema}".threads_meta ADD COLUMN incarnation VARCHAR(32) NULL DEFAULT NULL'))
await asyncio.to_thread(alembic_command.upgrade, cfg, _REVISION)
async with engine.begin() as connection:
await connection.execute(sa.text(f"INSERT INTO \"{schema}\".threads_meta (thread_id, status, metadata_json, created_at, updated_at) VALUES ('legacy-writer', 'idle', '{{}}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"))
incarnation = await connection.scalar(sa.text(f"SELECT incarnation FROM \"{schema}\".threads_meta WHERE thread_id = 'legacy-writer'"))
assert incarnation is None
finally:
async with engine.begin() as connection:
await connection.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await engine.dispose()
@pytest.mark.asyncio
async def test_sqlite_0019_downgrade_retry_cleans_failed_batch_table(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}")
cfg = _get_alembic_config(engine)
migration = importlib.import_module(_MIGRATION_MODULE)
try:
await asyncio.to_thread(alembic_command.upgrade, cfg, _REVISION)
def fail_then_retry(sync_connection) -> None:
context = MigrationContext.configure(sync_connection)
original_safe_drop = migration.safe_drop_column
injected = False
def fail_after_batch_temp_create(table: str, column_name: str) -> None:
nonlocal injected
if table == "mcp_tasks" and not injected:
injected = True
sync_connection.exec_driver_sql("CREATE TABLE _alembic_tmp_mcp_tasks AS SELECT * FROM mcp_tasks")
raise RuntimeError("injected batch downgrade failure")
original_safe_drop(table, column_name)
with Operations.context(context):
monkeypatch.setattr(migration, "safe_drop_column", fail_after_batch_temp_create)
with pytest.raises(RuntimeError, match="injected"):
migration.downgrade()
sync_connection.commit()
monkeypatch.setattr(migration, "safe_drop_column", original_safe_drop)
migration.downgrade()
sync_connection.commit()
tables = set(sa.inspect(sync_connection).get_table_names())
assert "_alembic_tmp_mcp_tasks" not in tables
assert "thread_incarnation" not in {column["name"] for column in sa.inspect(sync_connection).get_columns("mcp_tasks")}
assert "incarnation" not in {column["name"] for column in sa.inspect(sync_connection).get_columns("threads_meta")}
async with engine.connect() as connection:
await connection.run_sync(fail_then_retry)
finally:
await engine.dispose()