|
| 1 | +import datetime |
| 2 | +from unittest.mock import MagicMock |
| 3 | + |
1 | 4 | import pytest |
2 | 5 | import ray |
3 | 6 |
|
4 | 7 | from rock.deployments.config import LocalDeploymentConfig |
5 | 8 | from rock.logger import init_logger |
| 9 | +from rock.sandbox.base_actor import BaseActor |
6 | 10 | from rock.sandbox.sandbox_actor import SandboxActor |
7 | 11 |
|
8 | 12 | logger = init_logger(__name__) |
@@ -131,3 +135,110 @@ async def test_user_defined_tags_with_empty_dict(ray_init_shutdown): |
131 | 135 | logger.info(f"Empty dict set successfully: {result}") |
132 | 136 | finally: |
133 | 137 | ray.kill(sandbox_actor) |
| 138 | + |
| 139 | + |
| 140 | +class ConcreteBaseActor(BaseActor): |
| 141 | + """Minimal concrete subclass used only for unit testing BaseActor.""" |
| 142 | + |
| 143 | + async def get_sandbox_statistics(self): |
| 144 | + return {"cpu": 10.0, "mem": 20.0, "disk": 30.0, "net": 40.0} |
| 145 | + |
| 146 | + |
| 147 | +def _make_actor() -> ConcreteBaseActor: |
| 148 | + """Create a ConcreteBaseActor with lightweight mocked dependencies.""" |
| 149 | + config = MagicMock() |
| 150 | + config.container_name = "test-container" |
| 151 | + config.auto_clear_time = None # skip DockerDeploymentConfig branch |
| 152 | + |
| 153 | + deployment = MagicMock() |
| 154 | + deployment.__class__ = object # make isinstance(deployment, DockerDeployment) return False |
| 155 | + |
| 156 | + actor = ConcreteBaseActor(config, deployment) |
| 157 | + actor.host = "127.0.0.1" |
| 158 | + # Pre-populate all gauges with mocks so tests can override selectively |
| 159 | + for key in ("cpu", "mem", "disk", "net", "rt"): |
| 160 | + actor._gauges[key] = MagicMock() |
| 161 | + return actor |
| 162 | + |
| 163 | + |
| 164 | +@pytest.mark.asyncio |
| 165 | +async def test_life_span_rt_gauge_is_set_during_metrics_collection(): |
| 166 | + """life_span_rt gauge must be set with the elapsed timedelta after collection.""" |
| 167 | + actor = _make_actor() |
| 168 | + mock_rt_gauge = MagicMock() |
| 169 | + actor._gauges["rt"] = mock_rt_gauge |
| 170 | + |
| 171 | + await actor._collect_sandbox_metrics("test-container") |
| 172 | + |
| 173 | + assert mock_rt_gauge.set.called, "life_span_rt gauge.set() was never called" |
| 174 | + life_span_rt_value = mock_rt_gauge.set.call_args[0][0] |
| 175 | + assert isinstance(life_span_rt_value, float), f"Expected float, got {type(life_span_rt_value)}" |
| 176 | + assert life_span_rt_value >= 0, "life_span_rt must be non-negative" |
| 177 | + |
| 178 | + |
| 179 | +@pytest.mark.asyncio |
| 180 | +async def test_life_span_rt_increases_over_time(): |
| 181 | + """life_span_rt reported on a second call must be >= the first call's value.""" |
| 182 | + actor = _make_actor() |
| 183 | + mock_rt_gauge = MagicMock() |
| 184 | + actor._gauges["rt"] = mock_rt_gauge |
| 185 | + |
| 186 | + await actor._collect_sandbox_metrics("test-container") |
| 187 | + first_rt: datetime.timedelta = mock_rt_gauge.set.call_args[0][0] |
| 188 | + |
| 189 | + await actor._collect_sandbox_metrics("test-container") |
| 190 | + second_rt: datetime.timedelta = mock_rt_gauge.set.call_args[0][0] |
| 191 | + |
| 192 | + assert second_rt >= first_rt, f"life_span_rt should be non-decreasing: first={first_rt}, second={second_rt}" |
| 193 | + |
| 194 | + |
| 195 | +@pytest.mark.asyncio |
| 196 | +async def test_life_span_rt_attributes_contain_expected_keys(): |
| 197 | + """Attributes passed to life_span_rt gauge must include all standard dimension keys.""" |
| 198 | + actor = _make_actor() |
| 199 | + actor._env = "prod" |
| 200 | + actor._role = "worker" |
| 201 | + actor._user_id = "user-42" |
| 202 | + actor._experiment_id = "exp-7" |
| 203 | + actor._namespace = "ns-test" |
| 204 | + actor.host = "10.0.0.1" |
| 205 | + |
| 206 | + mock_rt_gauge = MagicMock() |
| 207 | + actor._gauges["rt"] = mock_rt_gauge |
| 208 | + |
| 209 | + await actor._collect_sandbox_metrics("test-container") |
| 210 | + |
| 211 | + attributes = mock_rt_gauge.set.call_args[1]["attributes"] |
| 212 | + expected_keys = {"sandbox_id", "env", "role", "host", "ip", "user_id", "experiment_id", "namespace"} |
| 213 | + assert expected_keys.issubset(attributes.keys()), f"Missing attribute keys: {expected_keys - attributes.keys()}" |
| 214 | + assert attributes["env"] == "prod" |
| 215 | + assert attributes["role"] == "worker" |
| 216 | + assert attributes["user_id"] == "user-42" |
| 217 | + assert attributes["experiment_id"] == "exp-7" |
| 218 | + assert attributes["namespace"] == "ns-test" |
| 219 | + |
| 220 | + |
| 221 | +@pytest.mark.asyncio |
| 222 | +async def test_life_span_rt_set_even_when_no_cpu_metrics(): |
| 223 | + """life_span_rt must be reported even when get_sandbox_statistics returns no cpu data.""" |
| 224 | + |
| 225 | + class NoCpuActor(BaseActor): |
| 226 | + async def get_sandbox_statistics(self): |
| 227 | + return {} # cpu key absent |
| 228 | + |
| 229 | + config = MagicMock() |
| 230 | + config.container_name = "no-cpu-container" |
| 231 | + config.auto_clear_time = None |
| 232 | + deployment = MagicMock() |
| 233 | + deployment.__class__ = object |
| 234 | + |
| 235 | + actor = NoCpuActor(config, deployment) |
| 236 | + actor.host = "127.0.0.1" |
| 237 | + for key in ("cpu", "mem", "disk", "net", "rt"): |
| 238 | + actor._gauges[key] = MagicMock() |
| 239 | + |
| 240 | + mock_rt_gauge = actor._gauges["rt"] |
| 241 | + |
| 242 | + await actor._collect_sandbox_metrics("no-cpu-container") |
| 243 | + |
| 244 | + assert mock_rt_gauge.set.called, "life_span_rt gauge.set() must be called even when cpu metrics are absent" |
0 commit comments