85742ab165
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
278 lines
8.9 KiB
Python
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 == {}
|