deer-flow/backend/tests/test_migration_0025_repair_run_change_seq.py
spud 906c3d4554
fix(mcp): make durable task claims cancellation-safe (#4966)
* 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>
2026-09-20 19:11:42 +08:00

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()