Skip to content

Commit d2728c9

Browse files
authored
Fix cleanup and missing location metadata (#145)
This PR resolves an issue where microgrid metadata would incorrectly default to a `(0,0)` location if the actual location was missing. If also ensures that broadcasters are properly stopped and cleaned up during client disconnection or exit, preventing potential resource leaks. Finally, it adds some tests for retrieving microgrid metadata.
2 parents 170fa14 + 71d21d5 commit d2728c9

File tree

3 files changed

+141
-53
lines changed

3 files changed

+141
-53
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@
1414

1515
## Bug Fixes
1616

17-
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
17+
- When retrieving the microgrid metadata using `metadata()`, if the location was empty in the protobuf message, a wrong location with long=0, lat=0 was used. Now the location will be properly set to `None` in that case.
18+
- The client now does some missing cleanup (stopping background tasks) when disconnecting (and when used as a context manager).

src/frequenz/client/microgrid/_client.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from frequenz.channels import Receiver
1717
from frequenz.client.base import channel, client, retry, streaming
1818
from google.protobuf.empty_pb2 import Empty
19+
from typing_extensions import override
1920

2021
from ._component import (
2122
Component,
@@ -108,6 +109,35 @@ def stub(self) -> microgrid_pb2_grpc.MicrogridAsyncStub:
108109
# type-checker, so it can only be used for type hints.
109110
return self._stub # type: ignore
110111

112+
@override
113+
async def __aexit__(
114+
self,
115+
exc_type: type[BaseException] | None,
116+
exc_val: BaseException | None,
117+
exc_tb: Any | None,
118+
) -> bool | None:
119+
"""Close the gRPC channel and stop all broadcasters."""
120+
exceptions = [
121+
exc
122+
for exc in await asyncio.gather(
123+
*(broadcaster.stop() for broadcaster in self._broadcasters.values()),
124+
return_exceptions=True,
125+
)
126+
if isinstance(exc, BaseException)
127+
]
128+
self._broadcasters.clear()
129+
130+
result = None
131+
try:
132+
result = await super().__aexit__(exc_type, exc_val, exc_tb)
133+
except Exception as exc: # pylint: disable=broad-except
134+
exceptions.append(exc)
135+
if exceptions:
136+
raise BaseExceptionGroup(
137+
"Error while disconnecting from the microgrid API", exceptions
138+
)
139+
return result
140+
111141
async def components( # noqa: DOC502 (raises ApiClientError indirectly)
112142
self,
113143
) -> Iterable[Component]:
@@ -173,7 +203,7 @@ async def metadata(self) -> Metadata:
173203
return Metadata()
174204

175205
location: Location | None = None
176-
if microgrid_metadata.location:
206+
if microgrid_metadata.HasField("location"):
177207
location = Location(
178208
latitude=microgrid_metadata.location.latitude,
179209
longitude=microgrid_metadata.location.longitude,

tests/test_client.py

Lines changed: 108 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import logging
77
from collections.abc import AsyncIterator
8-
from contextlib import AsyncExitStack
98
from typing import Any
109
from unittest import mock
1110

@@ -14,6 +13,7 @@
1413
from frequenz.api.common import components_pb2, metrics_pb2
1514
from frequenz.api.microgrid import grid_pb2, inverter_pb2, microgrid_pb2
1615
from frequenz.client.base import retry
16+
from google.protobuf.empty_pb2 import Empty
1717

1818
from frequenz.client.microgrid import (
1919
ApiClientError,
@@ -30,6 +30,7 @@
3030
InverterType,
3131
MeterData,
3232
MicrogridApiClient,
33+
MicrogridId,
3334
)
3435

3536

@@ -46,14 +47,23 @@ def __init__(self, *, retry_strategy: retry.Strategy | None = None) -> None:
4647
mock_stub.SetPowerReactive = mock.AsyncMock("SetPowerReactive")
4748
mock_stub.AddInclusionBounds = mock.AsyncMock("AddInclusionBounds")
4849
mock_stub.StreamComponentData = mock.Mock("StreamComponentData")
50+
mock_stub.GetMicrogridMetadata = mock.AsyncMock("GetMicrogridMetadata")
4951
super().__init__("grpc://mock_host:1234", retry_strategy=retry_strategy)
5052
self.mock_stub = mock_stub
5153
self._stub = mock_stub # pylint: disable=protected-access
5254

5355

54-
async def test_components() -> None:
56+
@pytest.fixture
57+
async def client() -> AsyncIterator[_TestClient]:
58+
"""Return a test client."""
59+
async with _TestClient(
60+
retry_strategy=retry.LinearBackoff(interval=0.0, jitter=0.0, limit=6)
61+
) as client_instance:
62+
yield client_instance
63+
64+
65+
async def test_components(client: _TestClient) -> None:
5566
"""Test the components() method."""
56-
client = _TestClient()
5767
server_response = microgrid_pb2.ComponentList()
5868
client.mock_stub.ListComponents.return_value = server_response
5969
assert set(await client.components()) == set()
@@ -212,9 +222,8 @@ async def test_components() -> None:
212222
}
213223

214224

215-
async def test_components_grpc_error() -> None:
225+
async def test_components_grpc_error(client: _TestClient) -> None:
216226
"""Test the components() method when the gRPC call fails."""
217-
client = _TestClient()
218227
client.mock_stub.ListComponents.side_effect = grpc.aio.AioRpcError(
219228
mock.MagicMock(name="mock_status"),
220229
mock.MagicMock(name="mock_initial_metadata"),
@@ -231,9 +240,8 @@ async def test_components_grpc_error() -> None:
231240
await client.components()
232241

233242

234-
async def test_connections() -> None:
243+
async def test_connections(client: _TestClient) -> None:
235244
"""Test the connections() method."""
236-
client = _TestClient()
237245

238246
def assert_filter(*, starts: set[int], ends: set[int]) -> None:
239247
client.mock_stub.ListConnections.assert_called_once()
@@ -370,9 +378,8 @@ def assert_filter(*, starts: set[int], ends: set[int]) -> None:
370378
assert_filter(starts={1, 2, 4}, ends={4, 5, 6})
371379

372380

373-
async def test_connections_grpc_error() -> None:
381+
async def test_connections_grpc_error(client: _TestClient) -> None:
374382
"""Test the components() method when the gRPC call fails."""
375-
client = _TestClient()
376383
client.mock_stub.ListConnections.side_effect = grpc.aio.AioRpcError(
377384
mock.MagicMock(name="mock_status"),
378385
mock.MagicMock(name="mock_initial_metadata"),
@@ -389,6 +396,71 @@ async def test_connections_grpc_error() -> None:
389396
await client.connections()
390397

391398

399+
async def test_metadata_success(client: _TestClient) -> None:
400+
"""Test the metadata() method with a successful gRPC call."""
401+
mock_metadata_response = microgrid_pb2.MicrogridMetadata(
402+
microgrid_id=123,
403+
location=microgrid_pb2.Location(latitude=40.7128, longitude=-74.0060),
404+
)
405+
client.mock_stub.GetMicrogridMetadata.return_value = mock_metadata_response
406+
407+
metadata = await client.metadata()
408+
409+
assert metadata.microgrid_id == MicrogridId(123)
410+
assert metadata.location is not None
411+
assert metadata.location.latitude == pytest.approx(40.7128)
412+
assert metadata.location.longitude == pytest.approx(-74.0060)
413+
client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60)
414+
415+
416+
async def test_metadata_no_location(client: _TestClient) -> None:
417+
"""Test the metadata() method when location is not set in the response."""
418+
mock_metadata_response = microgrid_pb2.MicrogridMetadata(microgrid_id=456)
419+
client.mock_stub.GetMicrogridMetadata.return_value = mock_metadata_response
420+
421+
metadata = await client.metadata()
422+
423+
assert metadata.microgrid_id == MicrogridId(456)
424+
assert metadata.location is None
425+
client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60)
426+
427+
428+
async def test_metadata_empty_response(client: _TestClient) -> None:
429+
"""Test the metadata() method when the server returns an empty response."""
430+
client.mock_stub.GetMicrogridMetadata.return_value = None
431+
432+
metadata = await client.metadata()
433+
434+
assert metadata.microgrid_id is None
435+
assert metadata.location is None
436+
client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60)
437+
438+
439+
async def test_metadata_grpc_error(
440+
client: _TestClient, caplog: pytest.LogCaptureFixture
441+
) -> None:
442+
"""Test the metadata() method when the gRPC call fails."""
443+
caplog.set_level(logging.WARNING)
444+
client.mock_stub.GetMicrogridMetadata.side_effect = grpc.aio.AioRpcError(
445+
mock.MagicMock(name="mock_status"),
446+
mock.MagicMock(name="mock_initial_metadata"),
447+
mock.MagicMock(name="mock_trailing_metadata"),
448+
"fake grpc details for metadata",
449+
"fake grpc debug_error_string for metadata",
450+
)
451+
452+
metadata = await client.metadata()
453+
454+
assert metadata.microgrid_id is None
455+
assert metadata.location is None
456+
client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60)
457+
assert len(caplog.records) == 1
458+
assert caplog.records[0].levelname == "ERROR"
459+
assert "The microgrid metadata is not available." in caplog.records[0].message
460+
assert caplog.records[0].exc_text is not None
461+
assert "fake grpc details for metadata" in caplog.records[0].exc_text
462+
463+
392464
@pytest.fixture
393465
def meter83() -> microgrid_pb2.Component:
394466
"""Return a test meter component."""
@@ -433,9 +505,8 @@ def component_list(
433505

434506

435507
@pytest.mark.parametrize("method", ["meter_data", "battery_data", "inverter_data"])
436-
async def test_data_component_not_found(method: str) -> None:
508+
async def test_data_component_not_found(method: str, client: _TestClient) -> None:
437509
"""Test the meter_data() method."""
438-
client = _TestClient()
439510
client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList()
440511

441512
# It should raise a ValueError for a missing component_id
@@ -456,9 +527,9 @@ async def test_data_bad_category(
456527
method: str,
457528
component_id: ComponentId,
458529
component_list: list[microgrid_pb2.Component],
530+
client: _TestClient,
459531
) -> None:
460532
"""Test the meter_data() method."""
461-
client = _TestClient()
462533
client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList(
463534
components=component_list
464535
)
@@ -484,9 +555,9 @@ async def test_component_data(
484555
component_id: ComponentId,
485556
component_class: type[ComponentData],
486557
component_list: list[microgrid_pb2.Component],
558+
client: _TestClient,
487559
) -> None:
488560
"""Test the meter_data() method."""
489-
client = _TestClient()
490561
client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList(
491562
components=component_list
492563
)
@@ -498,13 +569,9 @@ async def stream_data(
498569

499570
client.mock_stub.StreamComponentData.side_effect = stream_data
500571
receiver = await getattr(client, method)(component_id)
501-
async with AsyncExitStack() as stack:
502-
stack.push_async_callback(
503-
client._broadcasters[component_id].stop # pylint: disable=protected-access
504-
)
505-
latest = await receiver.receive()
506-
assert isinstance(latest, component_class)
507-
assert latest.component_id == component_id
572+
latest = await receiver.receive()
573+
assert isinstance(latest, component_class)
574+
assert latest.component_id == component_id
508575

509576

510577
@pytest.mark.parametrize(
@@ -516,18 +583,17 @@ async def stream_data(
516583
("ev_charger_data", ComponentId(101), EVChargerData),
517584
],
518585
)
586+
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
519587
async def test_component_data_grpc_error(
520588
method: str,
521589
component_id: ComponentId,
522590
component_class: type[ComponentData],
523591
component_list: list[microgrid_pb2.Component],
524592
caplog: pytest.LogCaptureFixture,
593+
client: _TestClient,
525594
) -> None:
526595
"""Test the components() method when the gRPC call fails."""
527596
caplog.set_level(logging.WARNING)
528-
client = _TestClient(
529-
retry_strategy=retry.LinearBackoff(interval=0.0, jitter=0.0, limit=6)
530-
)
531597
client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList(
532598
components=component_list
533599
)
@@ -551,21 +617,17 @@ async def stream_data(
551617

552618
client.mock_stub.StreamComponentData.side_effect = stream_data
553619
receiver = await getattr(client, method)(component_id)
554-
async with AsyncExitStack() as stack:
555-
stack.push_async_callback(
556-
client._broadcasters[component_id].stop # pylint: disable=protected-access
557-
)
558-
latest = await receiver.receive()
559-
assert isinstance(latest, component_class)
560-
assert latest.component_id == component_id
620+
latest = await receiver.receive()
621+
assert isinstance(latest, component_class)
622+
assert latest.component_id == component_id
561623

562-
latest = await receiver.receive()
563-
assert isinstance(latest, component_class)
564-
assert latest.component_id == component_id
624+
latest = await receiver.receive()
625+
assert isinstance(latest, component_class)
626+
assert latest.component_id == component_id
565627

566-
latest = await receiver.receive()
567-
assert isinstance(latest, component_class)
568-
assert latest.component_id == component_id
628+
latest = await receiver.receive()
629+
assert isinstance(latest, component_class)
630+
assert latest.component_id == component_id
569631

570632
# This is not super portable, it will change if the GrpcStreamBroadcaster changes,
571633
# but without this there isn't much to check by this test.
@@ -584,9 +646,10 @@ async def stream_data(
584646

585647

586648
@pytest.mark.parametrize("power_w", [0, 0.0, 12, -75, 0.1, -0.0001, 134.0])
587-
async def test_set_power_ok(power_w: float, meter83: microgrid_pb2.Component) -> None:
649+
async def test_set_power_ok(
650+
power_w: float, meter83: microgrid_pb2.Component, client: _TestClient
651+
) -> None:
588652
"""Test if charge is able to charge component."""
589-
client = _TestClient()
590653
client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList(
591654
components=[meter83]
592655
)
@@ -600,9 +663,8 @@ async def test_set_power_ok(power_w: float, meter83: microgrid_pb2.Component) ->
600663
)
601664

602665

603-
async def test_set_power_grpc_error() -> None:
666+
async def test_set_power_grpc_error(client: _TestClient) -> None:
604667
"""Test set_power() raises ApiClientError when the gRPC call fails."""
605-
client = _TestClient()
606668
client.mock_stub.SetPowerActive.side_effect = grpc.aio.AioRpcError(
607669
mock.MagicMock(name="mock_status"),
608670
mock.MagicMock(name="mock_initial_metadata"),
@@ -624,10 +686,9 @@ async def test_set_power_grpc_error() -> None:
624686
[0, 0.0, 12, -75, 0.1, -0.0001, 134.0],
625687
)
626688
async def test_set_reactive_power_ok(
627-
reactive_power_var: float, meter83: microgrid_pb2.Component
689+
reactive_power_var: float, meter83: microgrid_pb2.Component, client: _TestClient
628690
) -> None:
629691
"""Test if charge is able to charge component."""
630-
client = _TestClient()
631692
client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList(
632693
components=[meter83]
633694
)
@@ -643,9 +704,8 @@ async def test_set_reactive_power_ok(
643704
)
644705

645706

646-
async def test_set_reactive_power_grpc_error() -> None:
707+
async def test_set_reactive_power_grpc_error(client: _TestClient) -> None:
647708
"""Test set_power() raises ApiClientError when the gRPC call fails."""
648-
client = _TestClient()
649709
client.mock_stub.SetPowerReactive.side_effect = grpc.aio.AioRpcError(
650710
mock.MagicMock(name="mock_status"),
651711
mock.MagicMock(name="mock_initial_metadata"),
@@ -675,10 +735,9 @@ async def test_set_reactive_power_grpc_error() -> None:
675735
ids=str,
676736
)
677737
async def test_set_bounds_ok(
678-
bounds: metrics_pb2.Bounds, inverter99: microgrid_pb2.Component
738+
bounds: metrics_pb2.Bounds, inverter99: microgrid_pb2.Component, client: _TestClient
679739
) -> None:
680740
"""Test if charge is able to charge component."""
681-
client = _TestClient()
682741
client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList(
683742
components=[inverter99]
684743
)
@@ -704,10 +763,9 @@ async def test_set_bounds_ok(
704763
ids=str,
705764
)
706765
async def test_set_bounds_fail(
707-
bounds: metrics_pb2.Bounds, inverter99: microgrid_pb2.Component
766+
bounds: metrics_pb2.Bounds, inverter99: microgrid_pb2.Component, client: _TestClient
708767
) -> None:
709768
"""Test if charge is able to charge component."""
710-
client = _TestClient()
711769
client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList(
712770
components=[inverter99]
713771
)
@@ -717,9 +775,8 @@ async def test_set_bounds_fail(
717775
client.mock_stub.AddInclusionBounds.assert_not_called()
718776

719777

720-
async def test_set_bounds_grpc_error() -> None:
721-
"""Test the components() method when the gRPC call fails."""
722-
client = _TestClient()
778+
async def test_set_bounds_grpc_error(client: _TestClient) -> None:
779+
"""Test set_bounds() raises ApiClientError when the gRPC call fails."""
723780
client.mock_stub.AddInclusionBounds.side_effect = grpc.aio.AioRpcError(
724781
mock.MagicMock(name="mock_status"),
725782
mock.MagicMock(name="mock_initial_metadata"),

0 commit comments

Comments
 (0)