deer-flow/backend/tests/test_migration_0032_activity_and_task_events.py
Nan Gao 8d5f5fd0a8
feat(channels): share encrypted runtime channel config across Gateway instances (#6988)
* feat(channels): share encrypted runtime channel config across Gateway instances

IM bot credentials entered in the web UI lived in each host's plaintext
channels/runtime-config.json, and the router cached the merged config on
app.state for the life of the process. With several Gateway instances a
save or disconnect served by an instance that does not hold the
provider's leader lease never reached the transport holder: it kept the
old credentials until it next started the channel, and a disconnect
could leave the old bot connected.

- New shared table channel_runtime_configs (migration 0044): one row per
  provider with the config block encrypted under DEER_FLOW_CREDENTIALS_KEY,
  a plaintext disconnect tombstone, and a version bumped by every save.
- SqlChannelRuntimeConfigStore replaces the JSON file for sqlite/postgres
  (memory keeps the file); it is synchronous on app_sync_sqlalchemy_url
  because its callers already run in worker threads.
- The router re-reads the store on every request instead of caching the
  merged config, and refuses (503) to save credentials without a key.
- The leader-lease supervisor compares the versions on each renewal tick
  and restarts or stops the holder's transport when a provider's config
  changed; a takeover reads a disconnect instead of reviving cached
  credentials.
- The first start imports an existing runtime-config.json (rows already
  in the table win) and deletes the plaintext file.

* docs(changelog): reference #6988 for the shared runtime channel config

* ci: rerun checks after #6999 fixed the frontend guidance budget on main

* fix(channels): start a held transport that is down when its runtime config changes

When the lease holder's transport was down (a start that failed on the
old credentials), applying a peer-saved config only updated the cached
config, and the transport started on the next lease round: two renewal
ticks instead of the one the docs promise. The apply step now starts it
on the held lease right away, with the lease round's backoff bookkeeping.
2026-10-18 11:10:31 +08:00

183 lines
9.0 KiB
Python

"""Migration 0032: run origin column and index, read-state tables, task events."""
from __future__ import annotations
import asyncio
import os
import uuid
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import pytest
import sqlalchemy as sa
from alembic import command
from alembic.script import ScriptDirectory
from sqlalchemy.ext.asyncio import create_async_engine
from deerflow.persistence import bootstrap
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0032_activity_and_task_events"
NEXT = "0033_batch_result_artifact"
AFTER_NEXT = "0034_run_event_seq_watermark"
LOGIN_THROTTLE = "0035_login_throttle"
IDEMPOTENCY = "0036_run_idempotency_request"
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
CHANNEL_BINDINGS = "0038_channel_thread_bindings"
USER_DISABLED = "0039_user_disabled"
CURRENT_HEAD = "0044_channel_runtime_configs"
PREVIOUS = "0031_scheduled_streak_boundary"
TABLES = {"thread_read_markers", "thread_read_versions", "scheduled_task_events"}
INDEXES = {
"runs": {"ix_runs_thread_change_seq"},
"thread_read_markers": {"ix_thread_read_markers_thread_id"},
"scheduled_task_events": {"ix_scheduled_task_events_thread", "ix_scheduled_task_events_user_id"},
}
pytestmark = pytest.mark.asyncio
async def test_0032_remains_in_the_single_migration_chain():
script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR))
assert script.get_heads() == [CURRENT_HEAD]
assert script.get_revision(REVISION).down_revision == PREVIOUS
assert script.get_revision(NEXT).down_revision == REVISION
assert script.get_revision(AFTER_NEXT).down_revision == NEXT
assert script.get_revision(LOGIN_THROTTLE).down_revision == AFTER_NEXT
assert script.get_revision(IDEMPOTENCY).down_revision == LOGIN_THROTTLE
assert script.get_revision(DOCUMENT_SUMMARIES).down_revision == IDEMPOTENCY
assert script.get_revision(CHANNEL_BINDINGS).down_revision == DOCUMENT_SUMMARIES
assert script.get_revision(USER_DISABLED).down_revision == CHANNEL_BINDINGS
# alembic_version.version_num is VARCHAR(32).
assert len(REVISION) <= 32
def _engine(tmp_path, backend):
if backend == "sqlite":
return create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}"), None
uri = os.environ.get("TEST_POSTGRES_URI")
if not uri:
pytest.skip("requires TEST_POSTGRES_URI (real Postgres migration 0032)")
parts = urlsplit(uri)
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"}])
schema = f"activity_events_{uuid.uuid4().hex}"
return create_async_engine(urlunsplit(parts._replace(scheme=scheme, query=query)), connect_args=build_asyncpg_connect_args(schema)), schema
async def _shape(engine) -> dict:
async with engine.connect() as conn:
def read(sync):
inspector = sa.inspect(sync)
tables = set(inspector.get_table_names())
return {
"tables": tables,
"run_columns": {col["name"]: col for col in inspector.get_columns("runs")},
"indexes": {table: {index["name"] for index in inspector.get_indexes(table)} for table in INDEXES if table in tables},
"uniques": {constraint["name"]: constraint["column_names"] for constraint in inspector.get_unique_constraints("scheduled_task_events")} if "scheduled_task_events" in tables else {},
}
return await conn.run_sync(read)
async def _insert_legacy_run(engine) -> None:
async with engine.begin() as conn:
await conn.execute(
sa.text(
"INSERT INTO runs (run_id,thread_id,user_id,status,operation_kind,multitask_strategy,metadata_json,kwargs_json,message_count,"
"total_input_tokens,total_output_tokens,total_tokens,llm_call_count,lead_agent_tokens,subagent_tokens,middleware_tokens,"
"token_usage_by_model,change_seq,created_at,updated_at) "
"VALUES ('legacy-run','legacy-thread','owner','success','run','reject','{}','{}',0,0,0,0,0,0,0,0,'{}',7,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
)
)
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
async def test_0032_upgrade_keeps_runs_and_downgrade_drops_everything(tmp_path, backend):
engine, schema = _engine(tmp_path, backend)
cfg = bootstrap._get_alembic_config(engine, postgres_schema=schema or "")
try:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
await asyncio.to_thread(command.upgrade, cfg, PREVIOUS)
await _insert_legacy_run(engine)
await asyncio.to_thread(command.upgrade, cfg, REVISION)
shape = await _shape(engine)
assert TABLES <= shape["tables"]
assert shape["run_columns"]["origin_kind"]["nullable"] is True
assert shape["run_columns"]["origin_kind"]["default"] is None
for table, names in INDEXES.items():
assert names <= shape["indexes"][table], table
assert shape["uniques"]["uq_scheduled_task_event"] == ["task_id", "anchor", "event"]
async with engine.connect() as conn:
# Never backfilled: legacy runs stay "interactive or unknown".
row = (await conn.execute(sa.text("SELECT origin_kind, change_seq, status FROM runs WHERE run_id='legacy-run'"))).one()
assert tuple(row) == (None, 7, "success")
# The unique key deduplicates lifecycle events at the database level.
insert = sa.text(
"INSERT INTO scheduled_task_events (id,user_id,task_id,thread_id,occurrence_id,anchor,event,reason_code,after_run_id,payload_json,created_at) "
"VALUES (:id,'owner','task','origin','occ','occ','task_finished','max_runs',NULL,'{}',CURRENT_TIMESTAMP)"
)
async with engine.begin() as conn:
await conn.execute(insert, {"id": "evt-1"})
with pytest.raises(sa.exc.IntegrityError):
async with engine.begin() as conn:
await conn.execute(insert, {"id": "evt-2"})
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
shape = await _shape(engine)
assert TABLES.isdisjoint(shape["tables"])
assert "origin_kind" not in shape["run_columns"]
assert "ix_runs_thread_change_seq" not in shape["indexes"]["runs"]
async with engine.connect() as conn:
assert (await conn.execute(sa.text("SELECT change_seq FROM runs WHERE run_id='legacy-run'"))).scalar_one() == 7
# Downgrade then upgrade again is clean.
await asyncio.to_thread(command.upgrade, cfg, REVISION)
assert TABLES <= (await _shape(engine))["tables"]
finally:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await engine.dispose()
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
async def test_0032_completes_a_partially_applied_upgrade(tmp_path, backend):
"""One table and the column already exist, the runs index is missing."""
engine, schema = _engine(tmp_path, backend)
cfg = bootstrap._get_alembic_config(engine, postgres_schema=schema or "")
try:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
await asyncio.to_thread(command.upgrade, cfg, PREVIOUS)
async with engine.begin() as conn:
await conn.execute(sa.text("ALTER TABLE runs ADD COLUMN origin_kind VARCHAR(32)"))
await conn.execute(sa.text("CREATE TABLE thread_read_versions (user_id VARCHAR(64) NOT NULL, version BIGINT DEFAULT 0 NOT NULL, CONSTRAINT pk_thread_read_versions PRIMARY KEY (user_id))"))
await conn.execute(sa.text("INSERT INTO thread_read_versions (user_id, version) VALUES ('owner', 3)"))
await asyncio.to_thread(command.upgrade, cfg, REVISION)
shape = await _shape(engine)
assert TABLES <= shape["tables"]
for table, names in INDEXES.items():
assert names <= shape["indexes"][table], table
async with engine.connect() as conn:
assert (await conn.execute(sa.text("SELECT version FROM thread_read_versions WHERE user_id='owner'"))).scalar_one() == 3
assert (await conn.execute(sa.text("SELECT version_num FROM alembic_version"))).scalar_one() == REVISION
# A downgrade that already removed part of the schema still completes.
async with engine.begin() as conn:
await conn.execute(sa.text("DROP TABLE scheduled_task_events"))
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
shape = await _shape(engine)
assert TABLES.isdisjoint(shape["tables"])
assert "origin_kind" not in shape["run_columns"]
finally:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await engine.dispose()