Skip to content

Commit 098e48d

Browse files
committed
Improve and add more actor tests
In particular we test that the new restart rules work as expected, and we also check the logs produced to have extra certainty that what we expect to happen is really happening. The restart tests are done using the `run()` function because we expect to move the restart logic there, so the tests are "forward-compatible". Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 3670243 commit 098e48d

File tree

1 file changed

+238
-19
lines changed

1 file changed

+238
-19
lines changed

tests/actor/test_actor.py

Lines changed: 238 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,114 @@
22
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH
33

44
"""Simple test for the BaseActor."""
5+
6+
import asyncio
7+
8+
import pytest
59
from frequenz.channels import Broadcast, Receiver, Sender
610
from frequenz.channels.util import select, selected_from
711

812
from frequenz.sdk.actor import Actor, run
913

14+
from ..conftest import actor_restart_limit
15+
16+
17+
class MyBaseException(BaseException):
18+
"""A base exception for testing purposes."""
19+
20+
21+
class BaseTestActor(Actor):
22+
"""A base actor for testing purposes."""
23+
24+
restart_count: int = -1
25+
26+
def inc_restart_count(self) -> None:
27+
"""Increment the restart count."""
28+
BaseTestActor.restart_count += 1
29+
30+
@classmethod
31+
def reset_restart_count(cls) -> None:
32+
"""Reset the restart count."""
33+
cls.restart_count = -1
34+
35+
36+
@pytest.fixture(autouse=True)
37+
def reset_restart_count() -> None:
38+
"""Reset the restart count before each test."""
39+
BaseTestActor.reset_restart_count()
40+
41+
42+
class NopActor(BaseTestActor):
43+
"""An actor that does nothing."""
44+
45+
def __init__(self) -> None:
46+
"""Create an instance."""
47+
super().__init__(name="test")
48+
49+
async def _run(self) -> None:
50+
"""Start the actor and crash upon receiving a message"""
51+
print(f"{self} started")
52+
self.inc_restart_count()
53+
print(f"{self} done")
1054

11-
class FaultyActor(Actor):
12-
"""A faulty actor that crashes as soon as it receives a message."""
55+
56+
class RaiseExceptionActor(BaseTestActor):
57+
"""A faulty actor that raises an Exception as soon as it receives a message."""
1358

1459
def __init__(
1560
self,
16-
name: str,
1761
recv: Receiver[int],
1862
) -> None:
19-
"""Create an instance of `FaultyActor`.
63+
"""Create an instance.
2064
2165
Args:
22-
name: Name of the actor.
2366
recv: A channel receiver for int data.
2467
"""
25-
super().__init__(name=name)
68+
super().__init__(name="test")
2669
self._recv = recv
2770

2871
async def _run(self) -> None:
2972
"""Start the actor and crash upon receiving a message"""
73+
print(f"{self} started")
74+
self.inc_restart_count()
3075
async for msg in self._recv:
76+
print(f"{self} is about to crash")
3177
_ = msg / 0
78+
print(f"{self} done (should not happen)")
79+
80+
81+
class RaiseBaseExceptionActor(BaseTestActor):
82+
"""A faulty actor that raises a BaseException as soon as it receives a message."""
83+
84+
def __init__(
85+
self,
86+
recv: Receiver[int],
87+
) -> None:
88+
"""Create an instance.
89+
90+
Args:
91+
recv: A channel receiver for int data.
92+
"""
93+
super().__init__(name="test")
94+
self._recv = recv
95+
96+
async def _run(self) -> None:
97+
"""Start the actor and crash upon receiving a message"""
98+
print(f"{self} started")
99+
self.inc_restart_count()
100+
async for _ in self._recv:
101+
print(f"{self} is about to crash")
102+
raise MyBaseException("This is a test")
103+
print(f"{self} done (should not happen)")
104+
32105

106+
ACTOR_INFO = ("frequenz.sdk.actor._actor", 20)
107+
ACTOR_ERROR = ("frequenz.sdk.actor._actor", 40)
108+
RUN_INFO = ("frequenz.sdk.actor._run_utils", 20)
109+
RUN_ERROR = ("frequenz.sdk.actor._run_utils", 40)
33110

34-
class EchoActor(Actor):
111+
112+
class EchoActor(BaseTestActor):
35113
"""An echo actor that whatever it receives into the output channel."""
36114

37115
def __init__(
@@ -59,53 +137,194 @@ async def _run(self) -> None:
59137
Args:
60138
output (Sender[OT]): A channel sender, to send actor's results to.
61139
"""
140+
print(f"{self} started")
141+
self.inc_restart_count()
62142

63143
channel_1 = self._recv1
64144
channel_2 = self._recv2
65145

66146
async for selected in select(channel_1, channel_2):
147+
print(f"{self} received message {selected.value!r}")
67148
if selected_from(selected, channel_1):
149+
print(f"{self} sending message received from channel_1")
68150
await self._output.send(selected.value)
69151
elif selected_from(selected, channel_2):
152+
print(f"{self} sending message received from channel_2")
70153
await self._output.send(selected.value)
71154

155+
print(f"{self} done (should not happen)")
156+
72157

73-
async def test_basic_actor() -> None:
158+
async def test_basic_actor(caplog: pytest.LogCaptureFixture) -> None:
74159
"""Initialize the TestActor send a message and wait for the response."""
160+
caplog.set_level("DEBUG", logger="frequenz.sdk.actor._actor")
75161

76162
input_chan_1: Broadcast[bool] = Broadcast("TestChannel1")
77163
input_chan_2: Broadcast[bool] = Broadcast("TestChannel2")
78164

79165
echo_chan: Broadcast[bool] = Broadcast("echo output")
166+
echo_rx = echo_chan.new_receiver()
80167

81168
async with EchoActor(
82169
"EchoActor",
83170
input_chan_1.new_receiver(),
84171
input_chan_2.new_receiver(),
85172
echo_chan.new_sender(),
86-
):
87-
echo_rx = echo_chan.new_receiver()
173+
) as actor:
174+
assert actor.is_running is True
175+
original_tasks = set(actor.tasks)
88176

89-
await input_chan_1.new_sender().send(True)
177+
# Start is a no-op if already started
178+
await actor.start()
179+
assert actor.is_running is True
180+
assert original_tasks == set(actor.tasks)
90181

182+
await input_chan_1.new_sender().send(True)
91183
msg = await echo_rx.receive()
92184
assert msg is True
93185

94186
await input_chan_2.new_sender().send(False)
95-
96187
msg = await echo_rx.receive()
97188
assert msg is False
98189

190+
assert actor.is_running is True
191+
192+
assert actor.is_running is False
193+
assert BaseTestActor.restart_count == 0
194+
assert caplog.record_tuples == [
195+
(*ACTOR_INFO, "Actor EchoActor[EchoActor]: Starting..."),
196+
(*ACTOR_INFO, "Actor EchoActor[EchoActor]: Cancelled."),
197+
]
198+
199+
200+
@pytest.mark.parametrize("restart_limit", [0, 1, 2, 10])
201+
async def test_restart_on_unhandled_exception(
202+
restart_limit: int, caplog: pytest.LogCaptureFixture
203+
) -> None:
204+
"""Create a faulty actor and expect it to restart because it raises an exception.
205+
206+
Also test this works with different restart limits.
99207
100-
async def test_actor_does_not_restart() -> None:
101-
"""Create a faulty actor and expect it to crash and stop running"""
208+
Args:
209+
restart_limit: The restart limit to use.
210+
"""
211+
caplog.set_level("DEBUG", logger="frequenz.sdk.actor._actor")
212+
caplog.set_level("DEBUG", logger="frequenz.sdk.actor._run_utils")
102213

103214
channel: Broadcast[int] = Broadcast("channel")
104215

105-
_faulty_actor = FaultyActor(
106-
"FaultyActor",
107-
channel.new_receiver(),
216+
async with asyncio.timeout(2.0):
217+
with actor_restart_limit(restart_limit):
218+
actor = RaiseExceptionActor(
219+
channel.new_receiver(),
220+
)
221+
for i in range(restart_limit + 1):
222+
await channel.new_sender().send(i)
223+
224+
await run(actor)
225+
226+
assert actor.is_running is False
227+
assert BaseTestActor.restart_count == restart_limit
228+
expected_log = [
229+
(*RUN_INFO, "Starting 1 actor(s)..."),
230+
(*ACTOR_INFO, "Actor RaiseExceptionActor[test]: Starting..."),
231+
]
232+
for i in range(restart_limit):
233+
expected_log.extend(
234+
[
235+
(
236+
*ACTOR_ERROR,
237+
"Actor RaiseExceptionActor[test]: Raised an unhandled exception.",
238+
),
239+
(
240+
*ACTOR_INFO,
241+
f"Actor test: Restarting ({i}/{restart_limit})...",
242+
),
243+
]
244+
)
245+
expected_log.extend(
246+
[
247+
(
248+
*ACTOR_ERROR,
249+
"Actor RaiseExceptionActor[test]: Raised an unhandled exception.",
250+
),
251+
(
252+
*ACTOR_INFO,
253+
"Actor RaiseExceptionActor[test]: Maximum restarts attempted "
254+
f"({restart_limit}/{restart_limit}), bailing out...",
255+
),
256+
(
257+
*RUN_INFO,
258+
"Actor RaiseExceptionActor[test]: Started normally.",
259+
),
260+
(
261+
*RUN_ERROR,
262+
"Actor RaiseExceptionActor[test]: Raised an exception while running.",
263+
),
264+
(*RUN_INFO, "All 1 actor(s) finished."),
265+
]
108266
)
267+
assert caplog.record_tuples == expected_log
268+
269+
270+
async def test_does_not_restart_on_normal_exit(
271+
actor_auto_restart_once: None, # pylint: disable=unused-argument
272+
caplog: pytest.LogCaptureFixture,
273+
) -> None:
274+
"""Create an actor that exists normally and expect it to not be restarted."""
275+
caplog.set_level("DEBUG", logger="frequenz.sdk.actor._actor")
276+
caplog.set_level("DEBUG", logger="frequenz.sdk.actor._run_utils")
277+
278+
channel: Broadcast[int] = Broadcast("channel")
279+
280+
actor = NopActor()
281+
282+
async with asyncio.timeout(1.0):
283+
await channel.new_sender().send(1)
284+
await run(actor)
285+
286+
assert BaseTestActor.restart_count == 0
287+
assert caplog.record_tuples == [
288+
(*RUN_INFO, "Starting 1 actor(s)..."),
289+
(*ACTOR_INFO, "Actor NopActor[test]: Starting..."),
290+
(*ACTOR_INFO, "Actor NopActor[test]: _run() returned without error."),
291+
(*ACTOR_INFO, "Actor NopActor[test]: Stopped."),
292+
(*RUN_INFO, "Actor NopActor[test]: Started normally."),
293+
(*RUN_INFO, "Actor NopActor[test]: Finished normally."),
294+
(*RUN_INFO, "All 1 actor(s) finished."),
295+
]
296+
297+
298+
async def test_does_not_restart_on_base_exception(
299+
actor_auto_restart_once: None, # pylint: disable=unused-argument
300+
caplog: pytest.LogCaptureFixture,
301+
) -> None:
302+
"""Create a faulty actor and expect it not to restart because it raises a base exception."""
303+
caplog.set_level("DEBUG", logger="frequenz.sdk.actor._actor")
304+
caplog.set_level("DEBUG", logger="frequenz.sdk.actor._run_utils")
305+
306+
channel: Broadcast[int] = Broadcast("channel")
307+
308+
actor = RaiseBaseExceptionActor(channel.new_receiver())
309+
310+
async with asyncio.timeout(1.0):
311+
await channel.new_sender().send(1)
312+
# We can't use pytest.raises() here because known BaseExceptions are handled
313+
# specially by pytest.
314+
try:
315+
await run(actor)
316+
except MyBaseException as error:
317+
assert str(error) == "This is a test"
109318

110-
await channel.new_sender().send(1)
111-
await run(_faulty_actor)
319+
assert BaseTestActor.restart_count == 0
320+
assert caplog.record_tuples == [
321+
(*RUN_INFO, "Starting 1 actor(s)..."),
322+
(*ACTOR_INFO, "Actor RaiseBaseExceptionActor[test]: Starting..."),
323+
(*ACTOR_ERROR, "Actor RaiseBaseExceptionActor[test]: Raised a BaseException."),
324+
(*RUN_INFO, "Actor RaiseBaseExceptionActor[test]: Started normally."),
325+
(
326+
*RUN_ERROR,
327+
"Actor RaiseBaseExceptionActor[test]: Raised an exception while running.",
328+
),
329+
(*RUN_INFO, "All 1 actor(s) finished."),
330+
]

0 commit comments

Comments
 (0)