mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* feat(ci): split backend unit tests into parallel CI shards Split the single offline backend `make test` job into four GitHub Actions matrix shards (SPLITS=4, GROUP=1..4) via pytest-split, so the ~12k-test suite runs in parallel instead of in one 15-minute job. Each shard runs on its own runner with its own Postgres/Redis services; fail-fast: false lets a failing shard report its owned tests without cancelling its peers. `make test` stays the canonical full-suite entry point; CI now calls the new `make test-shard SPLITS=4 GROUP=N`. tests/blocking_io remains owned solely by the dedicated blocking-I/O workflow (excluded via --ignore), extending #5105. Fixes #5088 * test(ci): make backend test shards duration-aware and pin the contract Make `make test-shard` an explicit least_duration split that READS backend/.test_durations (read-only for shards, so concurrent CI jobs never race writes on it), and add `make test-shard-durations` to regenerate that file from the full offline suite. Update the CI unit-test workflow contract to call `make test-shard SPLITS=4 GROUP=<n>` and assert the shard command carries --splits 4, --group 2, -m "not live", --ignore=tests/blocking_io and --splitting-algorithm least_duration. Verified on the real 13,140-test normal suite that the four shards are pairwise disjoint and their union equals the unsplit suite. Refs #5088 * test(ci): fail fast when the duration baseline is missing `make test-shard` now requires backend/.test_durations and exits with a clear error instead of letting pytest-split silently degrade to an even (count-based) split. Harden the CI contract test to pin `--durations-path=.test_durations` and to assert the repo ships the committed duration baseline. Refs #5088 * docs: trim backend/AGENTS.md within guidance budget * test(ci): add backend test duration baseline Add the duration baseline generated by a full offline backend run on a GitHub-hosted ubuntu-latest runner (the same runner type the shards use), so `make test-shard` balances the four matrix shards by real wall-clock cost. Refs #5088 * test(ci): make the duration writer honor DURATIONS_FILE `test-shard-durations` now writes `--durations-path=$(DURATIONS_FILE)` instead of a hard-coded .test_durations, so the reader and writer stay consistent when the path is overridden. Refs #5088 * test(ci): address sharding review feedback * test: isolate subagent execution capacity state --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
138 lines
4.8 KiB
Python
138 lines
4.8 KiB
Python
import asyncio
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
|
|
from deerflow.subagents.capacity import (
|
|
SubagentCapacityRejected,
|
|
SubagentCapacityTimeout,
|
|
configure_subagent_execution_capacity,
|
|
configured_subagent_max_running,
|
|
get_subagent_execution_capacity,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def restore_default_execution_capacity():
|
|
"""Keep capacity scenarios from changing admission for later tests."""
|
|
yield
|
|
configure_subagent_execution_capacity(SubagentRuntimeConfig())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capacity_queues_without_starting_more_than_configured_slots() -> None:
|
|
configure_subagent_execution_capacity(SubagentRuntimeConfig(max_running=1, max_queued=2, queue_timeout_seconds=5))
|
|
capacity = get_subagent_execution_capacity()
|
|
release = asyncio.Event()
|
|
started: list[str] = []
|
|
|
|
async def work(name: str) -> None:
|
|
async with capacity.slot():
|
|
started.append(name)
|
|
if name == "first":
|
|
await release.wait()
|
|
|
|
first = asyncio.create_task(work("first"))
|
|
await asyncio.sleep(0)
|
|
second = asyncio.create_task(work("second"))
|
|
await asyncio.sleep(0)
|
|
|
|
assert started == ["first"]
|
|
assert capacity.snapshot().running == 1
|
|
assert capacity.snapshot().queued == 1
|
|
|
|
release.set()
|
|
await asyncio.gather(first, second)
|
|
assert started == ["first", "second"]
|
|
assert capacity.snapshot().running == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capacity_rejects_immediately_when_configured() -> None:
|
|
configure_subagent_execution_capacity(SubagentRuntimeConfig(max_running=1, max_queued=10, admission_policy="reject"))
|
|
capacity = get_subagent_execution_capacity()
|
|
async with capacity.slot():
|
|
with pytest.raises(SubagentCapacityRejected, match="capacity is full"):
|
|
async with capacity.slot():
|
|
raise AssertionError("unreachable")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capacity_enforces_queue_bound() -> None:
|
|
configure_subagent_execution_capacity(SubagentRuntimeConfig(max_running=1, max_queued=1, queue_timeout_seconds=5))
|
|
capacity = get_subagent_execution_capacity()
|
|
release = asyncio.Event()
|
|
|
|
async def holder() -> None:
|
|
async with capacity.slot():
|
|
await release.wait()
|
|
|
|
first = asyncio.create_task(holder())
|
|
await asyncio.sleep(0)
|
|
queued = asyncio.create_task(holder())
|
|
await asyncio.sleep(0)
|
|
with pytest.raises(SubagentCapacityRejected, match="1 queued"):
|
|
async with capacity.slot():
|
|
raise AssertionError("unreachable")
|
|
release.set()
|
|
await asyncio.gather(first, queued)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capacity_timeout_removes_waiter_and_releases_slot() -> None:
|
|
configure_subagent_execution_capacity(SubagentRuntimeConfig(max_running=1, max_queued=1, queue_timeout_seconds=1))
|
|
capacity = get_subagent_execution_capacity()
|
|
async with capacity.slot():
|
|
with pytest.raises(SubagentCapacityTimeout, match="Timed out"):
|
|
async with capacity.slot():
|
|
raise AssertionError("unreachable")
|
|
assert capacity.snapshot().queued == 0
|
|
assert capacity.snapshot().running == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capacity_cancelled_waiter_does_not_leak_queue_or_slot() -> None:
|
|
configure_subagent_execution_capacity(SubagentRuntimeConfig(max_running=1, max_queued=2, queue_timeout_seconds=5))
|
|
capacity = get_subagent_execution_capacity()
|
|
release = asyncio.Event()
|
|
|
|
async def holder() -> None:
|
|
async with capacity.slot():
|
|
await release.wait()
|
|
|
|
first = asyncio.create_task(holder())
|
|
await asyncio.sleep(0)
|
|
waiting = asyncio.create_task(holder())
|
|
await asyncio.sleep(0)
|
|
waiting.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await waiting
|
|
assert capacity.snapshot().queued == 0
|
|
release.set()
|
|
await first
|
|
assert capacity.snapshot().running == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_installing_same_startup_config_does_not_reset_live_capacity() -> None:
|
|
config = SubagentRuntimeConfig(max_running=1, max_queued=2, queue_timeout_seconds=5)
|
|
configure_subagent_execution_capacity(config)
|
|
capacity = get_subagent_execution_capacity()
|
|
|
|
async with capacity.slot():
|
|
configure_subagent_execution_capacity(config)
|
|
assert get_subagent_execution_capacity() is capacity
|
|
assert capacity.snapshot().running == 1
|
|
|
|
assert configured_subagent_max_running() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_installing_different_config_while_active_is_rejected() -> None:
|
|
configure_subagent_execution_capacity(SubagentRuntimeConfig(max_running=1))
|
|
capacity = get_subagent_execution_capacity()
|
|
|
|
async with capacity.slot():
|
|
with pytest.raises(RuntimeError, match="while executions are active"):
|
|
configure_subagent_execution_capacity(SubagentRuntimeConfig(max_running=2))
|