Files
wehub-resource-sync 85742ab165
CPU Test / Lint - next (push) Waiting to run
Dashboard / Chromatic (push) Waiting to run
CPU Test / Lint - fast (push) Waiting to run
CPU Test / Build documentation (push) Waiting to run
CPU Test / Test (Store, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Weave, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Others, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Store, latest, Python 3.13) (push) Waiting to run
CPU Test / Lint - slow (push) Waiting to run
CPU Test / Lint - JavaScript (push) Waiting to run
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Others, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Weave, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (JavaScript) (push) Waiting to run
Deploy Documentation / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

278 lines
8.9 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import time
from typing import List, Optional, cast
from unittest.mock import patch
import pytest
from agentlightning.store.utils import rollout_status_from_attempt, scan_unhealthy_rollouts
from agentlightning.types import (
Attempt,
AttemptedRollout,
AttemptStatus,
RolloutConfig,
)
pytestmark = [pytest.mark.store]
# Tests for rollout_status_from_attempt function
@pytest.mark.parametrize(
"status,expected_status",
[
("preparing", "preparing"),
("running", "running"),
("succeeded", "succeeded"),
],
)
@pytest.mark.asyncio
async def test_rollout_status_from_attempt_direct_statuses(
status: AttemptStatus, expected_status: AttemptStatus
) -> None:
"""Test rollout_status_from_attempt directly propagates preparing/running/succeeded statuses."""
attempt = Attempt(
rollout_id="test-rollout", attempt_id="test-attempt", sequence_id=1, start_time=time.time(), status=status
)
config = RolloutConfig()
result = await rollout_status_from_attempt(attempt, config)
assert result == expected_status
@pytest.mark.parametrize(
"status,in_retry_condition,sequence_id,max_attempts,expected_status",
[
("failed", True, 1, 3, "requeuing"), # Should retry
("failed", True, 3, 3, "failed"), # Max attempts reached
("failed", False, 1, 3, "failed"), # Not in retry condition
("timeout", True, 2, 3, "requeuing"), # Should retry
("timeout", False, 1, 3, "failed"), # Not in retry condition
("unresponsive", True, 1, 2, "requeuing"), # Should retry
],
)
@pytest.mark.asyncio
async def test_rollout_status_from_attempt_retry_logic(
status: AttemptStatus, in_retry_condition: bool, sequence_id: int, max_attempts: int, expected_status: AttemptStatus
) -> None:
"""Test rollout_status_from_attempt retry logic for different combinations."""
attempt = Attempt(
rollout_id="test-rollout",
attempt_id="test-attempt",
sequence_id=sequence_id,
start_time=time.time(),
status=status,
)
retry_condition: List[AttemptStatus] = [status] if in_retry_condition else []
config = RolloutConfig(max_attempts=max_attempts, retry_condition=retry_condition)
result = await rollout_status_from_attempt(attempt, config)
assert result == expected_status
@pytest.mark.asyncio
async def test_rollout_status_from_attempt_invalid_status() -> None:
"""Test rollout_status_from_attempt raises error for invalid status."""
# Create a valid attempt first, then modify its status
attempt = Attempt(
rollout_id="test-rollout", attempt_id="test-attempt", sequence_id=1, start_time=time.time(), status="failed"
)
# Bypass Pydantic validation by directly setting the attribute
attempt.status = cast(AttemptStatus, "invalid_status") # Invalid status
config = RolloutConfig()
with pytest.raises(ValueError, match="Invalid attempt status: invalid_status"):
await rollout_status_from_attempt(attempt, config)
# Tests for scan_unhealthy_rollouts function
@pytest.mark.asyncio
async def test_scan_unhealthy_rollouts_empty_list() -> None:
"""Test scan_unhealthy_rollouts handles empty rollouts list gracefully."""
updates = await scan_unhealthy_rollouts([])
assert updates == {}
@pytest.mark.asyncio
async def test_scan_unhealthy_rollouts_multiple_rollouts_different_timeouts() -> None:
"""Test scan_unhealthy_rollouts handles multiple rollouts with different timeout configs."""
current_time = time.time()
# Rollout 1: Short timeout, should timeout
config1 = RolloutConfig(timeout_seconds=1.0)
attempt1 = Attempt(
rollout_id="rollout-1",
attempt_id="attempt-1",
sequence_id=1,
start_time=current_time - 2.0,
status="running", # 2 seconds ago
)
rollout1 = AttemptedRollout(
rollout_id="rollout-1",
input={"test": 1},
status="running",
start_time=current_time,
config=config1,
attempt=attempt1,
)
# Rollout 2: Long timeout, should not timeout
config2 = RolloutConfig(timeout_seconds=10.0)
attempt2 = Attempt(
rollout_id="rollout-2",
attempt_id="attempt-2",
sequence_id=1,
start_time=current_time - 2.0,
status="running", # 2 seconds ago
)
rollout2 = AttemptedRollout(
rollout_id="rollout-2",
input={"test": 2},
status="running",
start_time=current_time,
config=config2,
attempt=attempt2,
)
with patch("time.time", return_value=current_time):
updates = await scan_unhealthy_rollouts([rollout1, rollout2])
# Only rollout1 should be marked as timeout
assert updates == {("rollout-1", "attempt-1"): "timeout"}
@pytest.mark.parametrize(
"timeout_seconds,unresponsive_seconds,should_timeout,should_unresponsive",
[
(None, None, False, False), # No timeouts configured
(None, 1.0, False, True), # Only unresponsive timeout
(1.0, None, True, False), # Only regular timeout
(0.5, 1.0, True, False), # Timeout triggers first
(2.0, 0.5, False, True), # Unresponsive triggers first
],
)
@pytest.mark.asyncio
async def test_scan_unhealthy_rollouts_timeout_configurations(
timeout_seconds: Optional[float],
unresponsive_seconds: Optional[float],
should_timeout: bool,
should_unresponsive: bool,
) -> None:
"""Test scan_unhealthy_rollouts with various timeout configurations."""
current_time = time.time()
config = RolloutConfig(timeout_seconds=timeout_seconds, unresponsive_seconds=unresponsive_seconds)
attempt = Attempt(
rollout_id="test-rollout",
attempt_id="test-attempt",
sequence_id=1,
start_time=current_time - 1.5,
status="running", # 1.5 seconds ago
last_heartbeat_time=None, # No heartbeat for unresponsive detection
)
rollout = AttemptedRollout(
rollout_id="test-rollout",
input={"test": 1},
status="running",
start_time=current_time,
config=config,
attempt=attempt,
)
with patch("time.time", return_value=current_time):
updates = await scan_unhealthy_rollouts([rollout])
expected_updates = {}
if should_timeout:
expected_updates[("test-rollout", "test-attempt")] = "timeout"
elif should_unresponsive:
expected_updates[("test-rollout", "test-attempt")] = "unresponsive"
assert updates == expected_updates
@pytest.mark.asyncio
async def test_scan_unhealthy_rollouts_unresponsive_with_heartbeat_timing() -> None:
"""Test unresponsive detection considers heartbeat timing correctly."""
current_time = time.time()
config = RolloutConfig(unresponsive_seconds=1.0)
# Case 1: Recent heartbeat - should not be unresponsive
attempt_recent = Attempt(
rollout_id="rollout-recent",
attempt_id="attempt-recent",
sequence_id=1,
start_time=current_time - 5.0,
status="running",
last_heartbeat_time=current_time - 0.5, # Recent heartbeat
)
rollout_recent = AttemptedRollout(
rollout_id="rollout-recent",
input={"test": 1},
status="running",
start_time=current_time,
config=config,
attempt=attempt_recent,
)
# Case 2: Old heartbeat - should be unresponsive
attempt_old = Attempt(
rollout_id="rollout-old",
attempt_id="attempt-old",
sequence_id=1,
start_time=current_time - 5.0,
status="running",
last_heartbeat_time=current_time - 2.0, # Old heartbeat
)
rollout_old = AttemptedRollout(
rollout_id="rollout-old",
input={"test": 2},
status="running",
start_time=current_time,
config=config,
attempt=attempt_old,
)
with patch("time.time", return_value=current_time):
updates = await scan_unhealthy_rollouts([rollout_recent, rollout_old])
# Only the old heartbeat should trigger unresponsive
assert updates == {("rollout-old", "attempt-old"): "unresponsive"}
@pytest.mark.asyncio
async def test_scan_unhealthy_rollouts_skips_rollouts_without_attempts() -> None:
"""Test scan_unhealthy_rollouts gracefully skips rollouts with no attempts."""
config = RolloutConfig()
# Create a valid attempt first, then set it to None
attempt = Attempt(
rollout_id="test-rollout", attempt_id="test-attempt", sequence_id=1, start_time=time.time(), status="running"
)
rollout = AttemptedRollout(
rollout_id="test-rollout",
input={"test": 1},
status="running",
start_time=time.time(),
config=config,
attempt=attempt,
)
# Bypass Pydantic validation by directly setting the attribute
rollout.attempt = cast(Attempt, None) # No attempt
updates = await scan_unhealthy_rollouts([rollout])
# Should not include rollout without attempts
assert updates == {}