Skip to content

Commit f22ddd2

Browse files
authored
Fix unreachable-power formula leak in the power distributor (#1411)
The power distributor runs a formula for each set of unreachable components. They were created on the shared logical-meter pool, which never evicts unused formulas. On teardown only the cache was stopped, not the formula — so a formula-evaluating actor leaked per distinct set, and CPU climbed over time until a restart. ## Changes * `FormulaPool`: `stop()` now also stops string formulas; new `stop_string_formula()` stops + evicts a single one. * Each `ComponentManager` lazily owns a dedicated `FormulaPool` (new `_DataPipeline.new_formula_pool`) and stops its formulas on teardown, via a per-type `_unreachable_power_formula` hook. Battery and PV use it. * Fix `from_reactive_power_formula` checking the wrong cache dict, which re-created and leaked the grid reactive-power formula on every access. ## Breaking * PV distribution now subtracts the power of unreachable PV inverters from the target, so PV results differ when some inverters are unreachable.
2 parents 110b8bf + 570d69b commit f22ddd2

9 files changed

Lines changed: 814 additions & 137 deletions

File tree

RELEASE_NOTES.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
## Upgrading
88

99
* Update microgrid client to v0.18.3+, which fixes a problem with missing steam boilers on formula generation.
10+
* The PV inverter manager now subtracts the power measured on unreachable PV inverters from the distribution target, so the power sent to the reachable inverters can change when some requested PV inverters are unreachable.
1011

1112
## New Features
1213

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
14+
* The PV inverter manager now accounts for the power measured on unreachable PV inverters when distributing power, so the reachable inverters compensate for it (matching the battery manager's behavior).
1415

1516
## Bug Fixes
1617

17-
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
18+
* Fixed a resource leak in the power distributor: the formulas created for unreachable batteries were never stopped, so CPU usage slowly climbed over time until the application was restarted.
19+
* Fixed the grid reactive-power formula being recreated and leaked on every access instead of being reused from the cache.

src/frequenz/sdk/microgrid/_data_pipeline.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import logging
1414
import typing
15+
import uuid
1516
from collections import abc
1617
from dataclasses import dataclass
1718
from datetime import timedelta
@@ -28,6 +29,9 @@
2829
from ._power_managing._base_classes import DefaultPower, PowerManagerAlgorithm
2930
from ._power_wrapper import PowerWrapper
3031

32+
if typing.TYPE_CHECKING:
33+
from ..timeseries.formulas._formula_pool import FormulaPool
34+
3135
# A number of imports had to be done inside functions where they are used, to break
3236
# import cycles.
3337
#
@@ -184,6 +188,31 @@ def logical_meter(self) -> LogicalMeter:
184188
)
185189
return self._logical_meter
186190

191+
def new_formula_pool(self, namespace: str) -> FormulaPool:
192+
"""Create a new, empty formula pool.
193+
194+
The caller owns the returned pool and is responsible for stopping it.
195+
196+
Args:
197+
namespace: A label describing what the pool is for; a unique suffix is
198+
appended so its resampler subscriptions don't collide with other
199+
pools that share the channel registry.
200+
201+
Returns:
202+
A new, empty formula pool.
203+
"""
204+
# Imported here (not at module level) to avoid an import cycle, mirroring
205+
# `logical_meter()` above.
206+
from ..timeseries.formulas._formula_pool import ( # pylint: disable=import-outside-toplevel
207+
FormulaPool,
208+
)
209+
210+
return FormulaPool(
211+
f"{namespace}-{uuid.uuid4()}",
212+
self._channel_registry,
213+
self._resampling_request_sender(),
214+
)
215+
187216
def consumer(self) -> Consumer:
188217
"""Return the consumption measuring point of the microgrid."""
189218
from ..timeseries.consumer import Consumer
@@ -557,6 +586,19 @@ def logical_meter() -> LogicalMeter:
557586
return _get().logical_meter()
558587

559588

589+
def _new_formula_pool(namespace: str) -> FormulaPool:
590+
"""Create a new, empty formula pool owned by the caller.
591+
592+
Args:
593+
namespace: A label describing what the pool is for (a unique suffix is
594+
appended).
595+
596+
Returns:
597+
A new, empty formula pool.
598+
"""
599+
return _get().new_formula_pool(namespace)
600+
601+
560602
def consumer() -> Consumer:
561603
"""Return the [`Consumption`][frequenz.sdk.timeseries.consumer.Consumer] measuring point."""
562604
return _get().consumer()

src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py

Lines changed: 10 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,9 @@
1414
from frequenz.client.common.microgrid.components import ComponentId
1515
from frequenz.client.microgrid import ApiClientError, OperationOutOfRange
1616
from frequenz.client.microgrid.component import Battery, Inverter
17-
from frequenz.client.microgrid.metrics import Metric
1817
from frequenz.quantities import Power
1918
from typing_extensions import override
2019

21-
from ....timeseries import Sample
2220
from ... import connection_manager
2321
from ..._old_component_data import BatteryData, InverterData
2422
from .._component_pool_status_tracker import ComponentPoolStatusTracker
@@ -146,6 +144,7 @@ def __init__(
146144
api_power_request_timeout: Timeout to use when making power requests to
147145
the microgrid API.
148146
"""
147+
super().__init__()
149148
self._results_sender = results_sender
150149
self._api_power_request_timeout = api_power_request_timeout
151150
self._batteries = connection_manager.get().component_graph.components(
@@ -163,20 +162,6 @@ def __init__(
163162
self._battery_caches: dict[ComponentId, LatestValueCache[BatteryData]] = {}
164163
self._inverter_caches: dict[ComponentId, LatestValueCache[InverterData]] = {}
165164

166-
self._unreachable_battery_powers: dict[
167-
frozenset[ComponentId],
168-
tuple[collections.abc.Set[ComponentId], LatestValueCache[Sample[Power]]],
169-
] = {}
170-
"""Map from battery sets to data from inaccessible battery subset.
171-
172-
When batteries are inaccessible, for example, due to network issues and can't be
173-
controlled, they might still be producing or consuming power. This power needs
174-
to be considered when distributing power to the other batteries.
175-
176-
So for each battery set, we track of the inaccessible subset of this battery set
177-
and the result of the power formulas for the inaccessible subset here.
178-
"""
179-
180165
self._component_pool_status_tracker = ComponentPoolStatusTracker(
181166
component_ids=set(self._battery_ids),
182167
component_status_sender=component_pool_status_sender,
@@ -219,6 +204,7 @@ async def stop(self) -> None:
219204
for inv_cache in self._inverter_caches.values():
220205
await inv_cache.stop()
221206
await self._component_pool_status_tracker.stop()
207+
await self._stop_all_unreachable_power_subscriptions()
222208

223209
@override
224210
async def distribute_power(self, request: Request) -> None:
@@ -536,76 +522,12 @@ def nan_metric_in_list(data: list[DataType], metrics: list[str]) -> bool:
536522

537523
return InvBatPair(AggregatedBatteryData(battery_data), inverter_data)
538524

539-
async def _subscribe_to_unreachable_battery_power(
540-
self,
541-
requested_battery_ids: collections.abc.Set[ComponentId],
542-
working_battery_ids: collections.abc.Set[ComponentId],
543-
) -> None:
544-
requested_battery_ids = frozenset(requested_battery_ids)
545-
unreachable_battery_ids = requested_battery_ids - working_battery_ids
546-
547-
if not unreachable_battery_ids:
548-
if unreachable_power := self._unreachable_battery_powers.pop(
549-
requested_battery_ids, None
550-
):
551-
_logger.debug(
552-
"All batteries are reachable, stopping unreachable battery power "
553-
+ "subscription for batteries %s",
554-
self._str_ids(requested_battery_ids),
555-
)
556-
await unreachable_power[1].stop()
557-
return
558-
559-
if unreachable_power := self._unreachable_battery_powers.get(
560-
requested_battery_ids
561-
):
562-
if unreachable_power[0] == unreachable_battery_ids:
563-
return
564-
_logger.debug(
565-
"Unreachable battery set for batteries %s changed from %s to %s, "
566-
+ "restarting subscription",
567-
self._str_ids(requested_battery_ids),
568-
self._str_ids(unreachable_power[0]),
569-
self._str_ids(unreachable_battery_ids),
570-
)
571-
await unreachable_power[1].stop()
572-
573-
formula = connection_manager.get().component_graph.battery_formula(
574-
unreachable_battery_ids
575-
)
576-
_logger.debug(
577-
"Subscribing to unreachable battery power for batteries %s with formula: %s",
578-
self._str_ids(unreachable_battery_ids),
579-
formula,
580-
)
581-
582-
# This is to avoid a circular import. Pylint doesn't detect that this
583-
# is not a circular import, so we need to disable the warning also.
584-
#
585-
# pylint: disable-next=import-outside-toplevel,cyclic-import
586-
from ... import (
587-
logical_meter,
588-
)
589-
590-
formula_cache = LatestValueCache(
591-
logical_meter()
592-
.start_formula(formula, Metric.AC_ACTIVE_POWER)
593-
.new_receiver()
594-
.map(
595-
lambda sample: Sample[Power](
596-
sample.timestamp,
597-
(
598-
Power.from_watts(sample.value.base_value)
599-
if sample.value is not None
600-
else None
601-
),
602-
)
603-
)
604-
)
605-
self._unreachable_battery_powers[requested_battery_ids] = (
606-
unreachable_battery_ids,
607-
formula_cache,
608-
)
525+
@override
526+
def _unreachable_power_formula(
527+
self, component_ids: collections.abc.Set[ComponentId]
528+
) -> str:
529+
"""Return the formula for the active power of the given batteries."""
530+
return connection_manager.get().component_graph.battery_formula(component_ids)
609531

610532
async def _get_components_data(
611533
self, batteries: collections.abc.Set[ComponentId]
@@ -629,7 +551,7 @@ async def _get_components_data(
629551
batteries
630552
)
631553

632-
await self._subscribe_to_unreachable_battery_power(
554+
await self._subscribe_to_unreachable_power(
633555
batteries,
634556
working_batteries,
635557
)
@@ -676,16 +598,9 @@ async def _get_components_data(
676598
assert len(data.inverter) > 0
677599
pairs_data.append(data)
678600

679-
unreachable_power: Power | None = None
680-
if unreachable_power_cache := self._unreachable_battery_powers.get(
681-
frozenset(batteries)
682-
):
683-
if unreachable_power_cache[1].has_value():
684-
unreachable_power = unreachable_power_cache[1].get().value
685-
686601
return BatteryComponentsData(
687602
inv_bat_pairs=pairs_data,
688-
unreachable_power=unreachable_power,
603+
unreachable_power=self._unreachable_power(batteries),
689604
)
690605

691606
def _str_ids(self, ids: collections.abc.Set[ComponentId]) -> str:

0 commit comments

Comments
 (0)