nextmv 0.30.0__py3-none-any.whl → 0.31.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nextmv/polling.py ADDED
@@ -0,0 +1,287 @@
1
+ """
2
+ Polling module containing logic to poll for a run result.
3
+
4
+ Polling can be used with both Cloud and local applications.
5
+
6
+ Classes
7
+ -------
8
+ PollingOptions
9
+ Options to use when polling for a run result.
10
+
11
+ Functions
12
+ ---------
13
+ poll
14
+ Function to poll a function until it succeeds or the polling strategy is
15
+ exhausted.
16
+ """
17
+
18
+ import random
19
+ import time
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass
22
+ from typing import Any, Optional
23
+
24
+ from nextmv.logger import log
25
+
26
+
27
+ @dataclass
28
+ class PollingOptions:
29
+ """
30
+ Options to use when polling for a run result.
31
+
32
+ You can import the `PollingOptions` class directly from `nextmv`:
33
+
34
+ ```python
35
+ from nextmv import PollingOptions
36
+ ```
37
+
38
+ The Cloud API will be polled for the result. The polling stops if:
39
+
40
+ * The maximum number of polls (tries) are exhausted. This is specified by
41
+ the `max_tries` parameter.
42
+ * The maximum duration of the polling strategy is reached. This is
43
+ specified by the `max_duration` parameter.
44
+
45
+ Before conducting the first poll, the `initial_delay` is used to sleep.
46
+ After each poll, a sleep duration is calculated using the following
47
+ strategy, based on exponential backoff with jitter:
48
+
49
+ ```
50
+ sleep_duration = min(`max_delay`, `delay` + `backoff` * 2 ** i + Uniform(0, `jitter`))
51
+ ```
52
+
53
+ Where:
54
+ * i is the retry (poll) number.
55
+ * Uniform is the uniform distribution.
56
+
57
+ Note that the sleep duration is capped by the `max_delay` parameter.
58
+
59
+ Parameters
60
+ ----------
61
+ backoff : float, default=0.9
62
+ Exponential backoff factor, in seconds, to use between polls.
63
+ delay : float, default=0.1
64
+ Base delay to use between polls, in seconds.
65
+ initial_delay : float, default=1.0
66
+ Initial delay to use before starting the polling strategy, in seconds.
67
+ max_delay : float, default=20.0
68
+ Maximum delay to use between polls, in seconds.
69
+ max_duration : float, default=300.0
70
+ Maximum duration of the polling strategy, in seconds.
71
+ max_tries : int, default=100
72
+ Maximum number of tries to use.
73
+ jitter : float, default=1.0
74
+ Jitter to use for the polling strategy. A uniform distribution is sampled
75
+ between 0 and this number. The resulting random number is added to the
76
+ delay for each poll, adding a random noise. Set this to 0 to avoid using
77
+ random jitter.
78
+ verbose : bool, default=False
79
+ Whether to log the polling strategy. This is useful for debugging.
80
+ stop : callable, default=None
81
+ Function to call to check if the polling should stop. This is useful for
82
+ stopping the polling based on external conditions. The function should
83
+ return True to stop the polling and False to continue. The function does
84
+ not receive any arguments. The function is called before each poll.
85
+
86
+ Examples
87
+ --------
88
+ >>> from nextmv.cloud import PollingOptions
89
+ >>> # Create polling options with custom settings
90
+ >>> polling_options = PollingOptions(
91
+ ... max_tries=50,
92
+ ... max_duration=600,
93
+ ... verbose=True
94
+ ... )
95
+ """
96
+
97
+ backoff: float = 0.9
98
+ """
99
+ Exponential backoff factor, in seconds, to use between polls.
100
+ """
101
+ delay: float = 0.1
102
+ """Base delay to use between polls, in seconds."""
103
+ initial_delay: float = 1
104
+ """
105
+ Initial delay to use before starting the polling strategy, in seconds.
106
+ """
107
+ max_delay: float = 20
108
+ """Maximum delay to use between polls, in seconds."""
109
+ max_duration: float = -1
110
+ """
111
+ Maximum duration of the polling strategy, in seconds. A negative value means no limit.
112
+ """
113
+ max_tries: int = -1
114
+ """Maximum number of tries to use. A negative value means no limit."""
115
+ jitter: float = 1
116
+ """
117
+ Jitter to use for the polling strategy. A uniform distribution is sampled
118
+ between 0 and this number. The resulting random number is added to the
119
+ delay for each poll, adding a random noise. Set this to 0 to avoid using
120
+ random jitter.
121
+ """
122
+ verbose: bool = False
123
+ """Whether to log the polling strategy. This is useful for debugging."""
124
+ stop: Optional[Callable[[], bool]] = None
125
+ """
126
+ Function to call to check if the polling should stop. This is useful for
127
+ stopping the polling based on external conditions. The function should
128
+ return True to stop the polling and False to continue. The function does
129
+ not receive any arguments. The function is called before each poll.
130
+ """
131
+
132
+
133
+ DEFAULT_POLLING_OPTIONS: PollingOptions = PollingOptions()
134
+ """
135
+ Default polling options to use when polling for a run result. This constant
136
+ provides the default values for `PollingOptions` used across the module.
137
+ Using these defaults is recommended for most use cases unless specific timing
138
+ needs are required.
139
+ """
140
+
141
+
142
+ def poll( # noqa: C901
143
+ polling_options: PollingOptions,
144
+ polling_func: Callable[[], tuple[Any, bool]],
145
+ __sleep_func: Callable[[float], None] = time.sleep,
146
+ ) -> Any:
147
+ """
148
+ Poll a function until it succeeds or the polling strategy is exhausted.
149
+
150
+ You can import the `poll` function directly from `nextmv`:
151
+
152
+ ```python
153
+ from nextmv import poll
154
+ ```
155
+
156
+ This function implements a flexible polling strategy with exponential backoff
157
+ and jitter. It calls the provided polling function repeatedly until it indicates
158
+ success, the maximum number of tries is reached, or the maximum duration is exceeded.
159
+
160
+ The `polling_func` is a callable that must return a `tuple[Any, bool]`
161
+ where the first element is the result of the polling and the second
162
+ element is a boolean indicating if the polling was successful or should be
163
+ retried.
164
+
165
+ Parameters
166
+ ----------
167
+ polling_options : PollingOptions
168
+ Options for configuring the polling behavior, including retry counts,
169
+ delays, timeouts, and verbosity settings.
170
+ polling_func : callable
171
+ Function to call to check if the polling was successful. Must return a tuple
172
+ where the first element is the result value and the second is a boolean
173
+ indicating success (True) or need to retry (False).
174
+
175
+ Returns
176
+ -------
177
+ Any
178
+ Result value from the polling function when successful.
179
+
180
+ Raises
181
+ ------
182
+ TimeoutError
183
+ If the polling exceeds the maximum duration specified in polling_options.
184
+ RuntimeError
185
+ If the maximum number of tries is exhausted without success.
186
+
187
+ Examples
188
+ --------
189
+ >>> from nextmv.cloud import PollingOptions, poll
190
+ >>> import time
191
+ >>>
192
+ >>> # Define a polling function that succeeds after 3 tries
193
+ >>> counter = 0
194
+ >>> def check_completion() -> tuple[str, bool]:
195
+ ... global counter
196
+ ... counter += 1
197
+ ... if counter >= 3:
198
+ ... return "Success", True
199
+ ... return None, False
200
+ ...
201
+ >>> # Configure polling options
202
+ >>> options = PollingOptions(
203
+ ... max_tries=5,
204
+ ... delay=0.1,
205
+ ... backoff=0.2,
206
+ ... verbose=True
207
+ ... )
208
+ >>>
209
+ >>> # Poll until the function succeeds
210
+ >>> result = poll(options, check_completion)
211
+ >>> print(result)
212
+ 'Success'
213
+ """
214
+
215
+ # Start by sleeping for the duration specified as initial delay.
216
+ if polling_options.verbose:
217
+ log(f"polling | sleeping for initial delay: {polling_options.initial_delay}")
218
+
219
+ __sleep_func(polling_options.initial_delay)
220
+
221
+ start_time = time.time()
222
+ stopped = False
223
+
224
+ # Begin the polling process.
225
+ max_reached = False
226
+ ix = 0
227
+ while True:
228
+ # Check if we reached the maximum number of tries. Break if so.
229
+ if ix >= polling_options.max_tries and polling_options.max_tries >= 0:
230
+ break
231
+ ix += 1
232
+
233
+ # Check is we should stop polling according to the stop callback.
234
+ if polling_options.stop is not None and polling_options.stop():
235
+ stopped = True
236
+
237
+ break
238
+
239
+ # We check if we can stop polling.
240
+ result, ok = polling_func()
241
+ if polling_options.verbose:
242
+ log(f"polling | try # {ix + 1}, ok: {ok}")
243
+
244
+ if ok:
245
+ return result
246
+
247
+ # An exit condition happens if we exceed the allowed duration.
248
+ passed = time.time() - start_time
249
+ if polling_options.verbose:
250
+ log(f"polling | elapsed time: {passed}")
251
+
252
+ if passed >= polling_options.max_duration and polling_options.max_duration >= 0:
253
+ raise TimeoutError(
254
+ f"polling did not succeed after {passed} seconds, exceeds max duration: {polling_options.max_duration}",
255
+ )
256
+
257
+ # Calculate the delay.
258
+ if max_reached:
259
+ # If we already reached the maximum, we don't want to further calculate the
260
+ # delay to avoid overflows.
261
+ delay = polling_options.max_delay
262
+ delay += random.uniform(0, polling_options.jitter) # Add jitter.
263
+ else:
264
+ delay = polling_options.delay # Base
265
+ delay += polling_options.backoff * (2**ix) # Add exponential backoff.
266
+ delay += random.uniform(0, polling_options.jitter) # Add jitter.
267
+
268
+ # We cannot exceed the max delay.
269
+ if delay >= polling_options.max_delay:
270
+ max_reached = True
271
+ delay = polling_options.max_delay
272
+
273
+ # Sleep for the calculated delay.
274
+ sleep_duration = delay
275
+ if polling_options.verbose:
276
+ log(f"polling | sleeping for duration: {sleep_duration}")
277
+
278
+ __sleep_func(sleep_duration)
279
+
280
+ if stopped:
281
+ log("polling | stop condition met, stopping polling")
282
+
283
+ return None
284
+
285
+ raise RuntimeError(
286
+ f"polling did not succeed after {polling_options.max_tries} tries",
287
+ )