Skip to content

Commit 35969b3

Browse files
Introduce more abstract handling of throttling signaling (#35984)
* Introduce more abstract handling of throttling signaling * linting * gemini suggestions * review suggestions * Update sdks/python/apache_beam/io/components/adaptive_throttler.py Co-authored-by: Danny McCormick <dannymccormick@google.com> * linting --------- Co-authored-by: Danny McCormick <dannymccormick@google.com>
1 parent 7d6de9a commit 35969b3

3 files changed

Lines changed: 112 additions & 24 deletions

File tree

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
Beam now supports data enrichment capabilities using SQL databases, with built-in support for:
8383
- Managed PostgreSQL, MySQL, and Microsoft SQL Server instances on CloudSQL
8484
- Unmanaged SQL database instances not hosted on CloudSQL (e.g., self-hosted or on-premises databases)
85+
* [Python] Added the `ReactiveThrottler` and `ThrottlingSignaler` classes to streamline throttling behavior in DoFns, expose throttling mechanisms for users ([#35984](https://github.com/apache/beam/pull/35984))
8586
* Added a pipeline option to specify the processing timeout for a single element by any PTransform (Java/Python/Go) ([#35174](https://github.com/apache/beam/issues/35174)).
8687
- When specified, the SDK harness automatically restarts if an element takes too long to process. Beam runner may then retry processing of the same work item.
8788
- Use the `--element_processing_timeout_minutes` option to reduce the chance of having stalled pipelines due to unexpected cases of slow processing, where slowness might not happen again if processing of the same element is retried.

sdks/python/apache_beam/io/components/adaptive_throttler.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,32 @@
2121

2222
# pytype: skip-file
2323

24+
import logging
2425
import random
26+
import time
2527

2628
from apache_beam.io.components import util
29+
from apache_beam.metrics.metric import Metrics
30+
31+
_SECONDS_TO_MILLISECONDS = 1_000
32+
33+
34+
class ThrottlingSignaler(object):
35+
"""A class that handles signaling throttling of remote requests to the
36+
SDK harness.
37+
"""
38+
def __init__(self, namespace: str = ""):
39+
self.throttling_metric = Metrics.counter(
40+
namespace, "cumulativeThrottlingSeconds")
41+
42+
def signal_throttled(self, seconds: int):
43+
"""Signals to the runner that requests have been throttled for some amount
44+
of time.
45+
46+
Args:
47+
seconds: int, duration of throttling in seconds.
48+
"""
49+
self.throttling_metric.inc(seconds)
2750

2851

2952
class AdaptiveThrottler(object):
@@ -94,3 +117,72 @@ def successful_request(self, now):
94117
now: int, time in ms since the epoch
95118
"""
96119
self._successful_requests.add(now, 1)
120+
121+
122+
class ReactiveThrottler(AdaptiveThrottler):
123+
""" A wrapper around the AdaptiveThrottler that also handles logging and
124+
signaling throttling to the SDK harness using the provided namespace.
125+
126+
For usage, instantiate one instance of a ReactiveThrottler class for a
127+
PTransform. When making remote calls to a service, preface that call with
128+
the throttle() method to potentially pre-emptively throttle the request.
129+
This will throttle future calls based on the failure rate of preceding calls,
130+
with higher failure rates leading to longer periods of throttling to allow
131+
system recovery. capture the timestamp of the attempted request, then execute
132+
the request code. On a success, call successful_request(timestamp) to report
133+
the success to the throttler. This flow looks like the following:
134+
135+
def remote_call():
136+
throttler.throttle()
137+
138+
try:
139+
timestamp = time.time()
140+
result = make_request()
141+
throttler.successful_request(timestamp)
142+
return result
143+
except Exception as e:
144+
# do any error handling you want to do
145+
raise
146+
"""
147+
def __init__(
148+
self,
149+
window_ms: int,
150+
bucket_ms: int,
151+
overload_ratio: float,
152+
namespace: str = '',
153+
throttle_delay_secs: int = 5):
154+
"""Initializes the ReactiveThrottler.
155+
156+
Args:
157+
window_ms: int, length of history to consider, in ms, to set
158+
throttling.
159+
bucket_ms: int, granularity of time buckets that we store data in, in
160+
ms.
161+
overload_ratio: float, the target ratio between requests sent and
162+
successful requests. This is "K" in the formula in
163+
https://landing.google.com/sre/book/chapters/handling-overload.html.
164+
namespace: str, the namespace to use for logging and signaling
165+
throttling is occurring
166+
throttle_delay_secs: int, the amount of time in seconds to wait
167+
after preemptively throttled requests
168+
"""
169+
self.throttling_signaler = ThrottlingSignaler(namespace=namespace)
170+
self.logger = logging.getLogger(namespace)
171+
self.throttle_delay_secs = throttle_delay_secs
172+
super().__init__(
173+
window_ms=window_ms, bucket_ms=bucket_ms, overload_ratio=overload_ratio)
174+
175+
def throttle(self):
176+
""" Stops request code from advancing while the underlying
177+
AdaptiveThrottler is signaling to preemptively throttle the request.
178+
Automatically handles logging the throttling and signaling to the SDK
179+
harness that the request is being throttled. This should be called in any
180+
context where a call to a remote service is being contacted prior to the
181+
call being performed.
182+
"""
183+
while self.throttle_request(time.time() * _SECONDS_TO_MILLISECONDS):
184+
self.logger.info(
185+
"Delaying request for %d seconds due to previous failures",
186+
self.throttle_delay_secs)
187+
time.sleep(self.throttle_delay_secs)
188+
self.throttling_signaler.signal_throttled(self.throttle_delay_secs)

sdks/python/apache_beam/ml/inference/base.py

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,7 @@
5555
from typing import Union
5656

5757
import apache_beam as beam
58-
from apache_beam.io.components.adaptive_throttler import AdaptiveThrottler
59-
from apache_beam.metrics.metric import Metrics
58+
from apache_beam.io.components.adaptive_throttler import ReactiveThrottler
6059
from apache_beam.utils import multi_process_shared
6160
from apache_beam.utils import retry
6261
from apache_beam.utils import shared
@@ -354,14 +353,16 @@ def __init__(
354353
window_ms: int = 1 * _MILLISECOND_TO_SECOND,
355354
bucket_ms: int = 1 * _MILLISECOND_TO_SECOND,
356355
overload_ratio: float = 2):
357-
"""Initializes metrics tracking + an AdaptiveThrottler class for enabling
358-
client-side throttling for remote calls to an inference service.
356+
"""Initializes a ReactiveThrottler class for enabling
357+
client-side throttling for remote calls to an inference service. Also wraps
358+
provided calls to the service with retry logic.
359+
359360
See https://s.apache.org/beam-client-side-throttling for more details
360361
on the configuration of the throttling and retry
361362
mechanics.
362363
363364
Args:
364-
namespace: the metrics and logging namespace
365+
namespace: the metrics and logging namespace
365366
num_retries: the maximum number of times to retry a request on retriable
366367
errors before failing
367368
throttle_delay_secs: the amount of time to throttle when the client-side
@@ -372,19 +373,18 @@ def __init__(
372373
window_ms: length of history to consider, in ms, to set throttling.
373374
bucket_ms: granularity of time buckets that we store data in, in ms.
374375
overload_ratio: the target ratio between requests sent and successful
375-
requests. This is "K" in the formula in
376+
requests. This is "K" in the formula in
376377
https://landing.google.com/sre/book/chapters/handling-overload.html.
377378
"""
378-
# Configure AdaptiveThrottler and throttling metrics for client-side
379-
# throttling behavior.
380-
self.throttled_secs = Metrics.counter(
381-
namespace, "cumulativeThrottlingSeconds")
382-
self.throttler = AdaptiveThrottler(
383-
window_ms=window_ms, bucket_ms=bucket_ms, overload_ratio=overload_ratio)
379+
# Configure ReactiveThrottler for client-side throttling behavior.
380+
self.throttler = ReactiveThrottler(
381+
window_ms=window_ms,
382+
bucket_ms=bucket_ms,
383+
overload_ratio=overload_ratio,
384+
namespace=namespace,
385+
throttle_delay_secs=throttle_delay_secs)
384386
self.logger = logging.getLogger(namespace)
385-
386387
self.num_retries = num_retries
387-
self.throttle_delay_secs = throttle_delay_secs
388388
self.retry_filter = retry_filter
389389

390390
def __init_subclass__(cls):
@@ -434,12 +434,7 @@ def run_inference(
434434
Returns:
435435
An Iterable of Predictions.
436436
"""
437-
while self.throttler.throttle_request(time.time() * _MILLISECOND_TO_SECOND):
438-
self.logger.info(
439-
"Delaying request for %d seconds due to previous failures",
440-
self.throttle_delay_secs)
441-
time.sleep(self.throttle_delay_secs)
442-
self.throttled_secs.inc(self.throttle_delay_secs)
437+
self.throttler.throttle()
443438

444439
try:
445440
req_time = time.time()
@@ -1642,7 +1637,7 @@ def next_model_index(self, num_models):
16421637

16431638
class _ModelStatus():
16441639
"""A class holding any metadata about a model required by RunInference.
1645-
1640+
16461641
Currently, this only includes whether or not the model is valid. Uses the
16471642
model tag to map models to metadata.
16481643
"""
@@ -1656,7 +1651,7 @@ def __init__(self, share_model_across_processes: bool):
16561651

16571652
def try_mark_current_model_invalid(self, min_model_life_seconds):
16581653
"""Mark the current model invalid.
1659-
1654+
16601655
Since we don't have sufficient information to say which model is being
16611656
marked invalid, but there may be multiple active models, we will mark all
16621657
models currently in use as inactive so that they all get reloaded. To
@@ -1678,7 +1673,7 @@ def try_mark_current_model_invalid(self, min_model_life_seconds):
16781673

16791674
def get_valid_tag(self, tag: str) -> str:
16801675
"""Takes in a proposed valid tag and returns a valid one.
1681-
1676+
16821677
Will always return a valid tag. If the passed in tag is valid, this
16831678
function will simply return it, otherwise it will deterministically
16841679
generate a new tag to use instead. The new tag will be the original tag
@@ -1747,7 +1742,7 @@ def load_model_status(
17471742

17481743
class _SharedModelWrapper():
17491744
"""A router class to map incoming calls to the correct model.
1750-
1745+
17511746
This allows us to round robin calls to models sitting in different
17521747
processes so that we can more efficiently use resources (e.g. GPUs).
17531748
"""

0 commit comments

Comments
 (0)