mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 22:16:19 +00:00
* feat(mcp): re-scope to MCP task claim lifecycle only Keep PR #4966 a small, closed MCP lease/cancellation state-machine change and move RunJournal and Run lifecycle work into dedicated follow-ups. This branch contains only the MCP task claim lifecycle: - mcp task release/snapshot fencing by owner + per-claim lease token - phase-level single-flight poll/cancel/notification owners with retained handoff - routine cancellation no longer persisted as a task failure diagnostic - bounded ordinary release ownership retention past the drain deadline - 0018_mcp_task_lease_tokens migration + migration/bootstrap head assertions - wait_for_task_until helper (MCP uses it); worker-specific capture helper moved to the run-finalization follow-up RunJournal (journal.py + test_run_journal.py) and run lifecycle (manager/worker/store/run sql + run tests) are preserved on backup/cancellation-safety-full and will be raised as separate follow-ups. * fix(mcp): unblock claims after ambiguous handoff resolves A phase-level single-flight owner only guards an ambiguous claim outcome. Once the claim resolves, the phase owner is released immediately; the handoff may continue releasing returned rows as bounded, service-owned background work (transferred to _compensation_tasks on timeout). Per-claim token fencing rejects a late release against a newer claim generation, so a stuck release no longer locks the whole phase until process restart. - README: drop the stale progress-snapshot sentence from the bounded ordinary release description. - service: pop the identity-checked phase owner as soon as the claim outcome is known, then release returned rows with the bounded path; carry the release in _compensation_tasks if it exceeds the drain deadline. - mcp/AGENTS.md: document that only an unresolved claim outcome (not the handoff) blocks later phase scans, and that returned-row releases may continue in the background once the owner is released. - tests: pin that the phase owner is released before a stuck release finishes while the release stays service strong-owned. * refactor(mcp): remove unused single-record claim wrappers _poll_one, _cancel_one, and _notify_one are unreachable in production: the worker always processes claimed records through _run_claimed_batch, so these wrappers preserved a second, dead single-record lifecycle (state is None) whose only observable behavior was a wrapper-specific cancellation release. Remove the three wrappers and migrate the regressions that guarded their cancel/release invariants to exercise the production _run_claimed_batch path (operation=_*_one_claimed, release=_release_*_after_cancellation). The single wrapper-only "state is None" contract (test_poll_release_hang_without_batch) is deleted; all 11 remaining invariants (CancelledError preservation, repeated cancellation, poll-only token-fenced lease release, notification claimed vs dispatched phase release, hung compensation -> service ownership, and background compensation exactly-once observation) are now covered through the real batch lifecycle. * fix(mcp): fence claim-owned mutations against stale generations The per-claim token check in the ORM release/apply paths was only in the SELECT; the final write went out by primary key. On SQLite (where with_for_update() is a no-op) a mutation from an older claim generation could therefore clear a claim that a newer generation had reclaimed after lease expiry — the exact distributed lease-fencing failure the per-claim token was meant to prevent. Make every claim-owned mutation a single atomic conditional UPDATE with the owner and per-claim token in the WHERE clause (rowcount 0 => stale, return False, no mutation): - release_claim: atomic fence; record the poll-failure event after the fence wins (same transaction, holding the write lock). - apply_snapshot / apply_cancel_snapshot: atomic fence; record the event after. - finish_notification_run: atomic fence; use a CASE on event_version >> dispatch_version to keep a newer event pending for redelivery instead of swallowing it as delivered. Add one regression per path: a stale generation's release/apply/finish after a same-worker reclaim is rejected and never clears the newer claim. * test(mcp): pin the migration chain head to the lease-token revision 0026_mcp_task_lease_tokens becomes the alembic head, so the chain-head pin in the 0025 repair test had to move on. Follow the 0023 precedent there (single head plus expected predecessor) instead of pinning a literal head, and give the new revision its own migration test, which owns the pin and covers the nullable claim-token columns on upgrade and their removal on downgrade. * refactor(mcp): close cancellation cleanup leftovers * fix(mcp): retain cancelled release diagnostics * test(mcp): remove obsolete settled compensation case * test(mcp): cover interleaved lease reclaim races --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
151 lines
6.7 KiB
Python
151 lines
6.7 KiB
Python
"""Migration tests for 0025_repair_run_change_seq (#5516).
|
|
|
|
``0023_run_change_seq`` was inserted ahead of the already-shipped
|
|
``0023_user_preferences`` revision, so databases stamped at that revision (or
|
|
later) never executed it and permanently lack the ``run_change_clock`` table
|
|
and ``runs.change_seq`` column. 0025 re-applies the same guarded DDL on
|
|
upgrade, heals those databases, and no-ops on healthy shapes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sqlite3
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from alembic import command
|
|
from alembic.script import ScriptDirectory
|
|
|
|
import deerflow.persistence.models # noqa: F401 -- registers ORM models
|
|
from deerflow.persistence.base import Base
|
|
from deerflow.persistence.bootstrap import _MIGRATIONS_DIR, _get_alembic_config, _get_head_revision
|
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
|
from deerflow.persistence.run import RunRepository
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
REVISION = "0025_repair_run_change_seq"
|
|
PREVIOUS = "0024_project_documents"
|
|
STAMP_BEFORE_INSERTION = "0023_user_preferences"
|
|
|
|
|
|
def _seed_database_that_skipped_0023(db_path) -> None:
|
|
"""Build the #5516 shape: stamped past 0023_run_change_seq without running it.
|
|
|
|
Mirrors a deployment that reached ``0023_user_preferences`` before
|
|
``0023_run_change_seq`` was inserted ahead of it: the version row says the
|
|
revision is applied, so alembic never runs it, and the schema it owns is
|
|
missing. Uses the synchronous ``sqlite3``-backed engine so the seed is
|
|
independent of the async engine under test.
|
|
"""
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
sync_engine = sa.create_engine(f"sqlite:///{db_path.as_posix()}")
|
|
try:
|
|
Base.metadata.create_all(sync_engine)
|
|
with sync_engine.begin() as conn:
|
|
# Remove everything 0023_run_change_seq owns.
|
|
conn.execute(sa.text("DROP INDEX IF EXISTS ix_runs_change_seq"))
|
|
conn.execute(sa.text("DROP INDEX IF EXISTS ix_runs_user_change_seq"))
|
|
conn.execute(sa.text("ALTER TABLE runs DROP COLUMN change_seq"))
|
|
conn.execute(sa.text("DROP TABLE IF EXISTS run_change_clock"))
|
|
# 0024 had not run at this stamp either.
|
|
conn.execute(sa.text("DROP TABLE IF EXISTS project_documents"))
|
|
# Stamp the position such a deployment sat at.
|
|
conn.execute(sa.text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)"))
|
|
conn.execute(sa.text(f"INSERT INTO alembic_version (version_num) VALUES ('{STAMP_BEFORE_INSERTION}')"))
|
|
finally:
|
|
sync_engine.dispose()
|
|
|
|
|
|
def _table_and_column_state(db_path) -> tuple[bool, bool, set[str], str | None]:
|
|
with sqlite3.connect(db_path) as raw:
|
|
tables = {row[0] for row in raw.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
|
run_columns = {row[1] for row in raw.execute("PRAGMA table_info(runs)").fetchall()}
|
|
run_indexes = {row[1] for row in raw.execute("PRAGMA index_list(runs)").fetchall()}
|
|
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
|
|
return "run_change_clock" in tables, "change_seq" in run_columns, run_indexes, version_row[0] if version_row else None
|
|
|
|
|
|
async def test_0025_chains_into_the_single_head():
|
|
script = ScriptDirectory(str(_MIGRATIONS_DIR))
|
|
assert len(script.get_heads()) == 1
|
|
# Later migrations may advance the head without removing this revision.
|
|
assert REVISION in {revision.revision for revision in script.walk_revisions()}
|
|
assert script.get_revision(REVISION).down_revision == PREVIOUS
|
|
|
|
|
|
async def test_0025_repairs_schema_skipped_by_the_0023_insertion(tmp_path):
|
|
db_path = tmp_path / "skipped-0023.db"
|
|
_seed_database_that_skipped_0023(db_path)
|
|
|
|
has_table, has_column, _, version = _table_and_column_state(db_path)
|
|
assert not has_table
|
|
assert not has_column
|
|
assert version == STAMP_BEFORE_INSERTION
|
|
|
|
url = f"sqlite+aiosqlite:///{db_path.as_posix()}"
|
|
await init_engine(backend="sqlite", url=url, sqlite_dir=str(tmp_path))
|
|
try:
|
|
has_table, has_column, run_indexes, version = _table_and_column_state(db_path)
|
|
assert has_table
|
|
assert has_column
|
|
assert {"ix_runs_change_seq", "ix_runs_user_change_seq"} <= run_indexes
|
|
assert version == _get_head_revision()
|
|
|
|
# The exact call that 500'd in #5516: bumping the change clock.
|
|
sf = get_session_factory()
|
|
assert sf is not None
|
|
async with sf() as session:
|
|
assert await RunRepository._next_change_seq(session) == 1
|
|
finally:
|
|
await close_engine()
|
|
|
|
|
|
async def test_0025_downgrade_preserves_ancestor_owned_schema_and_data(tmp_path):
|
|
"""Rolling back only this repair must not remove schema owned by 0023.
|
|
|
|
The change-clock schema belongs to ancestor ``0023_run_change_seq``; a
|
|
repair downgrade that dropped it would leave the database stamped at 0024
|
|
without 0023's schema — recreating the #5516 hole — and would discard
|
|
allocated clock positions.
|
|
"""
|
|
db_path = tmp_path / "downgrade.db"
|
|
url = f"sqlite+aiosqlite:///{db_path.as_posix()}"
|
|
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
|
try:
|
|
from deerflow.persistence.engine import get_engine
|
|
|
|
engine = get_engine()
|
|
assert engine is not None
|
|
cfg = _get_alembic_config(engine)
|
|
|
|
# Allocate a clock position so the data-preservation claim is real.
|
|
sf = get_session_factory()
|
|
assert sf is not None
|
|
async with sf() as session:
|
|
assert await RunRepository._next_change_seq(session) == 1
|
|
await session.commit()
|
|
|
|
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
|
|
has_table, has_column, run_indexes, version = _table_and_column_state(db_path)
|
|
assert has_table
|
|
assert has_column
|
|
assert {"ix_runs_change_seq", "ix_runs_user_change_seq"} <= run_indexes
|
|
assert version == PREVIOUS
|
|
with sqlite3.connect(db_path) as raw:
|
|
assert raw.execute("SELECT value FROM run_change_clock WHERE id = 1").fetchone()[0] == 1
|
|
|
|
# Downgrade is idempotent; re-upgrading re-runs the guarded repair as a no-op.
|
|
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
|
|
await asyncio.to_thread(command.upgrade, cfg, REVISION)
|
|
has_table, has_column, run_indexes, version = _table_and_column_state(db_path)
|
|
assert has_table
|
|
assert has_column
|
|
assert {"ix_runs_change_seq", "ix_runs_user_change_seq"} <= run_indexes
|
|
assert version == REVISION
|
|
with sqlite3.connect(db_path) as raw:
|
|
assert raw.execute("SELECT value FROM run_change_clock WHERE id = 1").fetchone()[0] == 1
|
|
finally:
|
|
await close_engine()
|