mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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.
183 lines
9.0 KiB
Python
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()
|