feat/58 - improve Backpressure algorithm

This commit is contained in:
Stanislav Hejny
2025-07-15 16:40:02 +01:00
parent 7d4e358ce5
commit 7cd2960fda
6 changed files with 230 additions and 247 deletions
+143 -144
View File
@@ -3,6 +3,7 @@ import json
import time
from asyncio import AbstractEventLoop
from threading import Thread
from typing import Optional
import psutil
from aio_pika import Message
@@ -94,9 +95,8 @@ class RunningAverage:
class BackpressureHandler:
# Track the number of messages currently being processed
current_parallel_executions = 0
current_load = 0
# helps detect IDLE condition
last_data_message_time = 0
# helps prevent flooding the system with backpressure events
last_backpressure_event_time = 0
last_backpressure_event = ScalingRequestAlert.UPDATE
@@ -116,93 +116,74 @@ class BackpressureHandler:
self.exchange = None
self.swarm_service_id = self.config.amq_adapter.swarm_service_id
self.swarm_task_id = self.config.amq_adapter.swarm_task_id
self.parallel_workers = self.config.backpressure.threshold_threads
self.maximum_load = self.config.backpressure.threshold_load
self.average_cpu_usage = None
self.do_loop = -1
self._resource_usage_state = RESOURCE_UNSET
self._resource_usage_changed = 0
self._resource_average_value = 0
self._last_resource_max_value = 100 # Default max value for CPU usage
def _do_loop(self) -> bool:
"""
Check if the loop should continue running.
Positive value of do_loop indicates the number of iterations left.
Negative value indicates the loop should run indefinitely.
"""
_val = self.do_loop != 0
if self.do_loop > 0:
self.do_loop -= 1
return _val
def increase_parallel_executions(self):
"""Increase the number of parallel executions"""
def increase_current_load(self):
"""Increase the number of parallel executions, or current load"""
logging_info(
"Backpressure: Increase parallel executions, current=%s",
self.current_parallel_executions,
self.current_load,
)
self.current_parallel_executions += 1
self.current_load += 1
def decrease_parallel_executions(self):
"""Decrease the number of parallel executions"""
def decrease_current_load(self):
"""Decrease the number of parallel executions, or current load"""
logging_info(
"Backpressure: Decrease parallel executions, current=%s",
self.current_parallel_executions,
self.current_load,
)
if self.current_parallel_executions > 0:
self.current_parallel_executions -= 1
if self.current_load > 0:
self.current_load -= 1
def update_parallel_executions(self, count: int):
"""Update the number of parallel executions"""
self.current_parallel_executions = count
def update_current_load(self, count: int):
"""Update the number of parallel executions, or current load"""
self.current_load = count
def update_last_data_message_time(self):
logging_info("Backpressure: Update last data message time")
# logging_info("Backpressure: Update last data message time")
"""Update the last data message time"""
self.last_data_message_time = time.time()
self.last_backpressure_event_time = time.time()
async def update_backpressure_value(self, current: int, maximum: int):
"""
Update the current backpressure value and check for overload conditions.
This method is called by the AMQService.backpressure() method to update
the current parallel executions count and check for overload conditions.
Args:
current: Current value of the backpressure metric
maximum: Maximum value of the backpressure metric
"""
logging_info(
"Backpressure: Updating backpressure value, current=%s, maximum=%s",
current,
maximum
"Backpressure: Updating backpressure value, current=%s, maximum=%s", current, maximum
)
if maximum > 0 and maximum > self.maximum_load:
self.maximum_load = maximum
# Update the current parallel executions count
self.update_parallel_executions(current)
# Update the last data message time to indicate activity
self.update_last_data_message_time()
self.update_current_load(current)
# Check for overload conditions
await self.check_overload_condition()
# Update the last data message time to indicate activity
self.update_last_data_message_time()
# If maximum has changed, update the parallel workers threshold
if maximum != self.parallel_workers and maximum > 0:
if maximum != self.maximum_load and maximum > 0:
logging_info(
"Backpressure: Updating maximum parallel workers from %s to %s",
self.parallel_workers,
maximum
self.maximum_load,
maximum,
)
self.parallel_workers = maximum
self.maximum_load = maximum
def start_backpressure_monitor(self) -> Thread:
# Check if CPU monitoring is enabled in configuration
cpu_monitoring_enabled = self.config.backpressure.cpu_monitoring_enabled
if not cpu_monitoring_enabled:
logging_info("Backpressure CPU monitoring is disabled by configuration")
return None
def start_backpressure_monitor(self) -> Optional[Thread]:
# Start the Backpressure monitor loop
self.thread = Thread(target=self.backpressure_monitor_loop)
self.thread.daemon = True # This makes it a daemon thread
@@ -215,50 +196,54 @@ class BackpressureHandler:
or if the service has been idle for too long (IDLE).
"""
current_time = time.time()
self.update_last_data_message_time()
last_event_delta = current_time - self.last_backpressure_event_time
logging_info(
"Backpressure: Check conditions, current=%s, max=%s, last_activity=%s",
self.current_parallel_executions,
self.parallel_workers,
current_time - self.last_data_message_time,
self.current_load,
self.maximum_load,
last_event_delta,
)
# Check for OVERLOAD condition
if self.current_parallel_executions >= self.parallel_workers - 1:
if self.current_load >= round(0.8 * self.maximum_load):
# Check if the last backpressure event was not an overload
if self.last_backpressure_event != ScalingRequestAlert.OVERLOAD:
# Trigger the overload event
await self.handle_backpressure_overload_event()
# update / reset time-window so that the OVERLOAD is not sent too often
self.last_backpressure_event_time = current_time
self.last_backpressure_event = ScalingRequestAlert.OVERLOAD
# Check for IDLE condition
elif self.current_parallel_executions == 0:
elif self.current_load == 0:
idle_duration = self.config.backpressure.idle_duration
# Check if service has been idle for longer than the configured duration
if (current_time - self.last_data_message_time > idle_duration and
(current_time - self.last_backpressure_event_time > self.config.backpressure.time_window or
self.last_backpressure_event != ScalingRequestAlert.IDLE)):
if (
last_event_delta > idle_duration
and self.last_backpressure_event == ScalingRequestAlert.IDLE
):
logging_info(
"Backpressure: Service has been idle for %s seconds (threshold: %s seconds)",
current_time - self.last_data_message_time,
idle_duration
last_event_delta,
idle_duration,
)
# Trigger the idle event
await self._handle_backpressure_idle_event()
# update / reset time-window so that the IDLE is not sent too often
self.last_backpressure_event_time = current_time
else:
# Don't send IDLE right away when the usage drops to zero / below threshold, but wait for a while
self.last_backpressure_event = ScalingRequestAlert.IDLE
# If neither OVERLOAD nor IDLE, and it's time for an update, send UPDATE event
elif (current_time - self.last_backpressure_event_time > self.config.backpressure.time_window):
elif last_event_delta > self.config.backpressure.time_window:
_scaling_request: ScaleRequestV1 = ScaleRequestV1(
self.swarm_service_id,
self.swarm_task_id,
self.parallel_workers,
self.parallel_workers - self.current_parallel_executions,
self.maximum_load,
self.maximum_load - self.current_load,
ScalingRequestAlert.UPDATE,
)
# Address the message to any adapter capable of supporting BACKPRESSURE request
@@ -266,8 +251,12 @@ class BackpressureHandler:
self.last_backpressure_event_time = current_time
self.last_backpressure_event = ScalingRequestAlert.UPDATE
self.update_last_data_message_time()
async def _backpressure_monitor(self):
"""Monitor the backpressure conditions and trigger events accordingly"""
# Check if CPU monitoring is enabled in configuration
cpu_monitoring_enabled = self.config.backpressure.cpu_monitoring_enabled
_time_window_millis = self.config.backpressure.time_window
_idle_duration_millis = self.config.backpressure.idle_duration
_overload_duration_millis = self.config.backpressure.cpu_overload_duration
@@ -281,73 +270,76 @@ class BackpressureHandler:
_cpu_usage_state = CPU_ACTIVE
_cpu_usage_changed = time.time()
while self._do_loop():
_current_cpu_usage = psutil.cpu_percent(interval=None)
self.average_cpu_usage.add(_current_cpu_usage)
_average_cpu_usage = self.average_cpu_usage.get_average()
_now = time.time()
# logging_info(
# "Backpressure: current CPU[pct]=%s, avg=%s, state=%s, last dataMsg=%s, last event=%s, last eventTime=%s",
# _current_cpu_usage,
# _average_cpu_usage,
# _cpu_usage_state,
# self.last_data_message_time,
# self.last_backpressure_event,
# self.last_backpressure_event_time,
# )
if _average_cpu_usage < IDLE_THRESHOLD:
if _cpu_usage_state != CPU_IDLE:
_cpu_usage_changed = _now
_cpu_usage_state = CPU_IDLE
elif _average_cpu_usage > OVERLOAD_THRESHOLD:
if _cpu_usage_state != CPU_OVERLOAD:
_cpu_usage_changed = _now
_cpu_usage_state = CPU_OVERLOAD
else:
if _cpu_usage_state != CPU_ACTIVE:
_cpu_usage_changed = _now
_cpu_usage_state = CPU_ACTIVE
if cpu_monitoring_enabled:
_current_cpu_usage = psutil.cpu_percent(interval=None)
self.average_cpu_usage.add(_current_cpu_usage)
_average_cpu_usage = self.average_cpu_usage.get_average()
_now = time.time()
# logging_info(
# "Backpressure: current CPU[pct]=%s, avg=%s, state=%s, last dataMsg=%s, last event=%s, last eventTime=%s",
# _current_cpu_usage,
# _average_cpu_usage,
# _cpu_usage_state,
# self.last_backpressure_event,
# self.last_backpressure_event_time,
# )
if _average_cpu_usage < IDLE_THRESHOLD:
if _cpu_usage_state != CPU_IDLE:
_cpu_usage_changed = _now
_cpu_usage_state = CPU_IDLE
elif _average_cpu_usage > OVERLOAD_THRESHOLD:
if _cpu_usage_state != CPU_OVERLOAD:
_cpu_usage_changed = _now
_cpu_usage_state = CPU_OVERLOAD
else:
if _cpu_usage_state != CPU_ACTIVE:
_cpu_usage_changed = _now
_cpu_usage_state = CPU_ACTIVE
# Check if the current time exceeds the last backpressure event time
if (
_cpu_usage_state == CPU_OVERLOAD
and _now - _cpu_usage_changed > _overload_duration_millis
and (
_now - self.last_backpressure_event_time > _time_window_millis
or self.last_backpressure_event != ScalingRequestAlert.OVERLOAD
)
):
# Trigger the overload event
await self.handle_backpressure_overload_event()
self.last_backpressure_event_time = _now
self.last_backpressure_event = ScalingRequestAlert.OVERLOAD
elif (
_cpu_usage_state == CPU_IDLE
and _now - _cpu_usage_changed > _idle_duration_millis
and _now - self.last_data_message_time > _idle_duration_millis
and (
_now - self.last_backpressure_event_time > _time_window_millis
or self.last_backpressure_event != ScalingRequestAlert.IDLE
)
):
# Trigger the idle event
await self._handle_backpressure_idle_event()
self.last_backpressure_event = ScalingRequestAlert.IDLE
self.last_backpressure_event_time = _now
else:
# Trigger update event
if _now - self.last_backpressure_event_time > _time_window_millis:
# Trigger the update event
_scaling_request: ScaleRequestV1 = ScaleRequestV1(
self.swarm_service_id,
self.swarm_task_id,
100,
_average_cpu_usage,
ScalingRequestAlert.UPDATE,
# Check if the current time exceeds the last backpressure event time
if (
_cpu_usage_state == CPU_OVERLOAD
and _now - _cpu_usage_changed > _overload_duration_millis
and (
_now - self.last_backpressure_event_time > _time_window_millis
or self.last_backpressure_event != ScalingRequestAlert.OVERLOAD
)
# Address the message to any adapter capable of supporting BACKPRESSURE request
await self.publish_backpressure_request(_scaling_request)
):
# Trigger the overload event
await self.handle_backpressure_overload_event()
self.last_backpressure_event_time = _now
self.last_backpressure_event = ScalingRequestAlert.UPDATE
self.last_backpressure_event = ScalingRequestAlert.OVERLOAD
elif (
_cpu_usage_state == CPU_IDLE
and _now - _cpu_usage_changed > _idle_duration_millis
and (
_now - self.last_backpressure_event_time > _time_window_millis
or self.last_backpressure_event != ScalingRequestAlert.IDLE
)
):
# Trigger the idle event
await self._handle_backpressure_idle_event()
self.last_backpressure_event = ScalingRequestAlert.IDLE
self.last_backpressure_event_time = _now
else:
# Trigger update event
if _now - self.last_backpressure_event_time > _time_window_millis:
# Trigger the update event
_scaling_request: ScaleRequestV1 = ScaleRequestV1(
self.swarm_service_id,
self.swarm_task_id,
100,
_average_cpu_usage,
ScalingRequestAlert.UPDATE,
)
# Address the message to any adapter capable of supporting BACKPRESSURE request
await self.publish_backpressure_request(_scaling_request)
self.last_backpressure_event_time = _now
self.last_backpressure_event = ScalingRequestAlert.UPDATE
else:
# If CPU monitoring is disabled, just check the current load
await self.check_overload_condition()
await asyncio.sleep(_monitor_interval)
def backpressure_monitor_loop(self):
@@ -360,14 +352,12 @@ class BackpressureHandler:
scaling_request: ScaleRequestV1 = ScaleRequestV1(
self.swarm_service_id,
self.swarm_task_id,
self.parallel_workers,
self.parallel_workers - self.current_parallel_executions,
self.maximum_load,
self.maximum_load - self.current_load,
ScalingRequestAlert.OVERLOAD,
)
# Address the message to any management service instance capable of supporting BACKPRESSURE request
await self.publish_backpressure_request(scaling_request)
# update / reset time-window so that the OVERLOAD is not sent too often
self.last_data_message_time = time.time()
async def _handle_backpressure_idle_event(self):
logging_warning("Backpressure: Service is idle.")
@@ -375,14 +365,12 @@ class BackpressureHandler:
scaling_request: ScaleRequestV1 = ScaleRequestV1(
self.swarm_service_id,
self.swarm_task_id,
self.parallel_workers,
self.parallel_workers - self.current_parallel_executions,
self.maximum_load,
self.maximum_load - self.current_load,
ScalingRequestAlert.IDLE,
)
# Address the message to any adapter capable of supporting BACKPRESSURE request
await self.publish_backpressure_request(scaling_request)
# update / reset time-window so that the IDLE is not sent too often
self.last_data_message_time = time.time()
async def publish_backpressure_request(self, scaling_request: ScaleRequestV1):
# Publish the backpressure request to the management service
@@ -429,3 +417,14 @@ class BackpressureHandler:
if BackpressureHandler._callback_list is not None:
BackpressureHandler._callback_list["_wrap_rabbit_mq_api"] = 1
await await_future(asyncio.run_coroutine_threadsafe(_wrap_rabbit_mq_api(), self.loop))
def _do_loop(self) -> bool:
"""
Helper function for unit tests to perform several loops only. Check if the loop should continue running.
Positive value of do_loop indicates the number of iterations left.
Negative value indicates the loop should run indefinitely.
"""
_val = self.do_loop != 0
if self.do_loop > 0:
self.do_loop -= 1
return _val
+1 -1
View File
@@ -150,7 +150,7 @@ class Backpressure:
:param config: config parser to read configuration values
"""
self.threshold_threads = config.getint(
self.threshold_load = config.getint(
"CleverMicro-AMQ",
self.PREFIX + "threshold",
fallback=int(os.getenv("PARALLEL_WORKERS", 1)),
+6 -6
View File
@@ -124,10 +124,10 @@ class DataMessageHandler:
:return: DataResponse
"""
# Increment the counter for parallel executions and check resource availability
self.backpressure_handler.increase_parallel_executions()
self.backpressure_handler.increase_current_load()
await self.backpressure_handler.check_overload_condition()
logging_info(
f"PARALLEL: Current Capacity [{self.backpressure_handler.current_parallel_executions}] of [{self.backpressure_handler.parallel_workers}]"
f"PARALLEL: Current Capacity [{self.backpressure_handler.current_load}] of [{self.backpressure_handler.maximum_load}]"
)
if message.reply_to:
self.reply_to = message.reply_to
@@ -155,7 +155,7 @@ class DataMessageHandler:
except Exception as e:
logging_error(f"Request handler: {e}")
self.backpressure_handler.decrease_parallel_executions()
self.backpressure_handler.decrease_current_load()
await self.backpressure_handler.check_overload_condition()
async def reconstitute_data_message(
@@ -208,12 +208,12 @@ class DataMessageHandler:
# map_as_string(amq_message.trace_info()), context_with_span, context)
def record_duration(self, start, delivery_tag):
global last_data_message_time
last_data_message_time = time.time()
global last_backpressure_event_time
last_backpressure_event_time = time.time()
logging_info(
"######[%s] Done, single ACK: %sms.",
delivery_tag,
last_data_message_time - start,
last_backpressure_event_time - start,
)
async def send_reply(self, reply_to: str, response: DataResponse):
+12 -9
View File
@@ -83,24 +83,27 @@ class AMQService:
logging.info("***********************************************")
self.loop.run_forever()
def backpressure(self, current: int, maximum: int = -1):
def backpressure(self, current_availability: int, maximum: int = -1):
"""
Send Backpressure event to the Management Service. The Backpressure logic determines the type of the event
based on the current and maximum values.
- OVERLOAD event is triggered immediately when the current value exceeds the maximum threshold, which is
based on the current_availability and maximum values.
- OVERLOAD event is triggered immediately when the current_availability value exceeds the maximum threshold, which is
typically set to 80% of the maximum value.
- IDLE event is triggered when the current value drops below 20% of the maximum value and remains there for
- IDLE event is triggered when the current_availability value drops below 20% of the maximum value and remains there for
a IDLE_WINDOW period of time (see BackpressureHandler).
- UPDATE event is sent if neither OVERLOAD nor IDLE conditions are met, indicating that the backpressure is
within acceptable limits.
:param current: Current value of the backpressure metric.
:param maximum: Maximum value of the backpressure metric. If undefined or set to lt 0, the maximum is set
according to the configuration value 'cm.backpressure.threshold'.
:param current_availability: Currently available caopacity.
:param maximum: Maximum value of the backpressure metric. If undefined or set to lt 0 (e.g. -1),
the maximum is than hgdetermined from max value of current_availability seen over the time.
"""
if self.backpressure_handler is not None:
if maximum < 0:
maximum = self.amq_configuration.backpressure.threshold_threads
self.backpressure_handler.update_backpressure_value(current, maximum)
maximum = self.amq_configuration.backpressure.threshold_load
asyncio.run_coroutine_threadsafe(
self.backpressure_handler.update_backpressure_value(current_availability, maximum),
loop=self.backpressure_handler.loop,
)
def rpc(self, service_name: str, fq_method_name: str, **kwargs) -> Future:
"""