flo-python 0.1.0.dev4__py3-none-any.whl → 0.1.2__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.
- flo/__init__.py +60 -15
- flo/actions.py +20 -27
- flo/client.py +193 -46
- flo/exceptions.py +83 -23
- flo/kv.py +373 -165
- flo/kv_body.py +554 -0
- flo/kv_txn.py +237 -0
- flo/processing.py +3 -67
- flo/queue.py +25 -101
- flo/retry.py +124 -0
- flo/streams.py +146 -31
- flo/types.py +431 -221
- flo/wire.py +263 -208
- flo/worker.py +684 -250
- {flo_python-0.1.0.dev4.dist-info → flo_python-0.1.2.dist-info}/METADATA +165 -98
- flo_python-0.1.2.dist-info/RECORD +19 -0
- {flo_python-0.1.0.dev4.dist-info → flo_python-0.1.2.dist-info}/WHEEL +1 -1
- flo_python-0.1.2.dist-info/licenses/LICENSE +202 -0
- flo_python-0.1.0.dev4.dist-info/RECORD +0 -16
- flo_python-0.1.0.dev4.dist-info/licenses/LICENSE +0 -21
flo/worker.py
CHANGED
|
@@ -22,6 +22,7 @@ Example::
|
|
|
22
22
|
|
|
23
23
|
import asyncio
|
|
24
24
|
import contextlib
|
|
25
|
+
import dataclasses
|
|
25
26
|
import json
|
|
26
27
|
import logging
|
|
27
28
|
import secrets
|
|
@@ -31,9 +32,14 @@ from dataclasses import dataclass, field
|
|
|
31
32
|
from typing import Any
|
|
32
33
|
|
|
33
34
|
from .client import FloClient
|
|
34
|
-
from .exceptions import NonRetryableError, is_connection_error
|
|
35
|
+
from .exceptions import NonRetryableError, NotFoundError, ServerError, is_connection_error
|
|
36
|
+
from .retry import _sleep_unless_stopped, _worker_backoff, is_refused_unrun
|
|
35
37
|
from .types import (
|
|
38
|
+
_DEFAULT_WORKER_BLOCK_MS,
|
|
39
|
+
ActionRegisterOptions,
|
|
36
40
|
ActionType,
|
|
41
|
+
Reason,
|
|
42
|
+
StatusCode,
|
|
37
43
|
StreamGroupAckOptions,
|
|
38
44
|
StreamGroupNackOptions,
|
|
39
45
|
StreamGroupReadOptions,
|
|
@@ -41,8 +47,10 @@ from .types import (
|
|
|
41
47
|
StreamRecord,
|
|
42
48
|
TaskAssignment,
|
|
43
49
|
WorkerAwaitOptions,
|
|
50
|
+
WorkerRegisterOptions,
|
|
44
51
|
WorkerTouchOptions,
|
|
45
52
|
)
|
|
53
|
+
from .wire import _validate_block_ms
|
|
46
54
|
|
|
47
55
|
logger = logging.getLogger("flo.worker")
|
|
48
56
|
|
|
@@ -68,6 +76,164 @@ class ActionResult:
|
|
|
68
76
|
ActionHandler = Callable[["ActionContext"], Awaitable[bytes | dict[str, Any] | ActionResult]]
|
|
69
77
|
|
|
70
78
|
|
|
79
|
+
class _EmptyPollBackoff:
|
|
80
|
+
"""Paces a poll loop whose blocking reads come back empty early.
|
|
81
|
+
|
|
82
|
+
A blocking read can come back empty early for two reasons that look the
|
|
83
|
+
same on the wire. The server may have no room to park it (re-polling at
|
|
84
|
+
once would spin), or an append woke it so the consumer re-reads. A full
|
|
85
|
+
server answers within a round trip, so only answers under
|
|
86
|
+
min(250 ms, block_ms/2) count as early. Even then, one may be an append
|
|
87
|
+
wake, so the first in a row is re-polled at once.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
_EARLY_S = 0.25
|
|
91
|
+
_FIRST_S = 0.05
|
|
92
|
+
_MAX_S = 1.0
|
|
93
|
+
|
|
94
|
+
def __init__(self, block_ms: int, stop: asyncio.Event) -> None:
|
|
95
|
+
self._early_s = min(self._EARLY_S, block_ms / 2000.0)
|
|
96
|
+
self._stop = stop
|
|
97
|
+
self._streak = 0
|
|
98
|
+
|
|
99
|
+
def reset(self) -> None:
|
|
100
|
+
self._streak = 0
|
|
101
|
+
|
|
102
|
+
def _next_pause(self, elapsed_s: float) -> float:
|
|
103
|
+
if elapsed_s >= self._early_s:
|
|
104
|
+
self._streak = 0
|
|
105
|
+
return 0.0
|
|
106
|
+
self._streak += 1
|
|
107
|
+
if self._streak == 1:
|
|
108
|
+
return 0.0
|
|
109
|
+
# The exponent is capped: the float power overflows on a long streak,
|
|
110
|
+
# and the pause reaches _MAX_S long before the cap.
|
|
111
|
+
return min(self._FIRST_S * 2.0 ** min(self._streak - 2, 16), self._MAX_S)
|
|
112
|
+
|
|
113
|
+
async def empty(self, elapsed_s: float) -> None:
|
|
114
|
+
pause = self._next_pause(elapsed_s)
|
|
115
|
+
if pause:
|
|
116
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
117
|
+
await asyncio.wait_for(self._stop.wait(), timeout=pause)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# A result (complete, fail, ack, nack) is sent at most this many times. Past
|
|
121
|
+
# that it is dropped: the lease or ack timeout runs out on the server and the
|
|
122
|
+
# task or record is delivered again, so delivery stays at-least-once.
|
|
123
|
+
_RESULT_SENDS = 3
|
|
124
|
+
|
|
125
|
+
# A result to send: the op name, the send on a given client, and whether the
|
|
126
|
+
# handler was cancelled.
|
|
127
|
+
_Report = tuple[str, Callable[[FloClient], Awaitable[None]], bool]
|
|
128
|
+
|
|
129
|
+
# How long a stopping worker keeps reconnecting to resend a lost result, so
|
|
130
|
+
# stop() can't hang on a server that is gone.
|
|
131
|
+
_STOP_RECONNECT_S = 2.0
|
|
132
|
+
|
|
133
|
+
# auto_touch never touches more often than this, whatever the timeout.
|
|
134
|
+
_MIN_TOUCH_INTERVAL_MS = 100
|
|
135
|
+
|
|
136
|
+
# The longest lease a touch may ask for; the server refuses more.
|
|
137
|
+
_MAX_TOUCH_EXTEND_MS = 3_600_000
|
|
138
|
+
|
|
139
|
+
# How often an action worker heartbeats. The server refuses work to a worker
|
|
140
|
+
# silent past its heartbeat timeout (90 s by default) and later removes it.
|
|
141
|
+
_HEARTBEAT_INTERVAL_S = 30.0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _touch_pace(timeout_ms: int) -> tuple[float, int]:
|
|
145
|
+
"""How often (in seconds) to touch a run of an action with timeout_ms,
|
|
146
|
+
and the lease each touch asks for: the timeout, at most an hour, touched
|
|
147
|
+
every third of that (at least _MIN_TOUCH_INTERVAL_MS apart) so two can
|
|
148
|
+
fail in a row."""
|
|
149
|
+
extend_ms = min(timeout_ms, _MAX_TOUCH_EXTEND_MS)
|
|
150
|
+
return max(extend_ms / 3, _MIN_TOUCH_INTERVAL_MS) / 1000, extend_ms
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Touch refusals that mean the run moved on: touching again can't help.
|
|
154
|
+
_TOUCH_FINAL = (StatusCode.CONFLICT, StatusCode.NOT_FOUND, StatusCode.BAD_REQUEST)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _send_result(
|
|
158
|
+
what: str,
|
|
159
|
+
send: Callable[[], Awaitable[object]],
|
|
160
|
+
generation: Callable[[], int],
|
|
161
|
+
reconnect: Callable[[int], Awaitable[None]],
|
|
162
|
+
stop: asyncio.Event,
|
|
163
|
+
) -> bool:
|
|
164
|
+
"""Send a result, resending the identical request after a reconnect when
|
|
165
|
+
the connection drops or the request times out.
|
|
166
|
+
|
|
167
|
+
A refusal is not resent: the server read the request and said no. Once
|
|
168
|
+
the worker is stopping, reconnects get _STOP_RECONNECT_S in all. Nothing
|
|
169
|
+
here raises; a result that can't be sent is logged and dropped.
|
|
170
|
+
"""
|
|
171
|
+
loop = asyncio.get_running_loop()
|
|
172
|
+
stop_deadline: float | None = None
|
|
173
|
+
|
|
174
|
+
async def reconnect_bounded(seen: int) -> bool:
|
|
175
|
+
"""Reconnect; False if the stop allowance ran out first."""
|
|
176
|
+
nonlocal stop_deadline
|
|
177
|
+
task = asyncio.ensure_future(reconnect(seen))
|
|
178
|
+
try:
|
|
179
|
+
if not stop.is_set():
|
|
180
|
+
waiter = asyncio.ensure_future(stop.wait())
|
|
181
|
+
try:
|
|
182
|
+
await asyncio.wait({task, waiter}, return_when=asyncio.FIRST_COMPLETED)
|
|
183
|
+
finally:
|
|
184
|
+
waiter.cancel()
|
|
185
|
+
if not task.done():
|
|
186
|
+
if stop_deadline is None:
|
|
187
|
+
stop_deadline = loop.time() + _STOP_RECONNECT_S
|
|
188
|
+
await asyncio.wait({task}, timeout=max(stop_deadline - loop.time(), 0.0))
|
|
189
|
+
finally:
|
|
190
|
+
if not task.done():
|
|
191
|
+
task.cancel()
|
|
192
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
193
|
+
await task
|
|
194
|
+
if task.cancelled():
|
|
195
|
+
return False
|
|
196
|
+
task.result()
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
for attempt in range(1, _RESULT_SENDS + 1):
|
|
200
|
+
seen = generation()
|
|
201
|
+
try:
|
|
202
|
+
await send()
|
|
203
|
+
return True
|
|
204
|
+
except Exception as e:
|
|
205
|
+
if not (is_connection_error(e) or isinstance(e, asyncio.TimeoutError)):
|
|
206
|
+
logger.error(f"{what} was refused, not resending: {e}")
|
|
207
|
+
return False
|
|
208
|
+
if attempt == _RESULT_SENDS:
|
|
209
|
+
logger.error(f"Dropped {what} after {_RESULT_SENDS} sends: {e}")
|
|
210
|
+
return False
|
|
211
|
+
if stop.is_set() and stop_deadline is None:
|
|
212
|
+
stop_deadline = loop.time() + _STOP_RECONNECT_S
|
|
213
|
+
if stop_deadline is not None and loop.time() >= stop_deadline:
|
|
214
|
+
logger.error(f"Dropped {what}: connection lost while the worker stops: {e}")
|
|
215
|
+
return False
|
|
216
|
+
logger.warning(
|
|
217
|
+
f"Lost {what} (send {attempt}/{_RESULT_SENDS}): {e}; reconnecting to resend"
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
if not await reconnect_bounded(seen):
|
|
221
|
+
logger.error(f"Dropped {what}: no connection before the worker stopped")
|
|
222
|
+
return False
|
|
223
|
+
except Exception as rc_err:
|
|
224
|
+
# The next send fails fast on the closed connection and
|
|
225
|
+
# counts as one of the attempts.
|
|
226
|
+
logger.warning(f"Reconnect to resend {what} failed: {rc_err}")
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _worker_block_ms(block_ms: int | None) -> int:
|
|
231
|
+
if not block_ms:
|
|
232
|
+
return _DEFAULT_WORKER_BLOCK_MS
|
|
233
|
+
_validate_block_ms(block_ms)
|
|
234
|
+
return block_ms
|
|
235
|
+
|
|
236
|
+
|
|
71
237
|
@dataclass
|
|
72
238
|
class ActionWorkerOptions:
|
|
73
239
|
"""Configuration for a Flo action worker.
|
|
@@ -78,7 +244,13 @@ class ActionWorkerOptions:
|
|
|
78
244
|
worker_id: str = ""
|
|
79
245
|
concurrency: int = 10
|
|
80
246
|
action_timeout: float = 300.0 # 5 minutes
|
|
81
|
-
block_ms: int =
|
|
247
|
+
block_ms: int = _DEFAULT_WORKER_BLOCK_MS
|
|
248
|
+
# Touch each run every third of its action's timeout while the handler
|
|
249
|
+
# runs, so a long handler isn't taken back and run again elsewhere.
|
|
250
|
+
auto_touch: bool = False
|
|
251
|
+
|
|
252
|
+
def __post_init__(self) -> None:
|
|
253
|
+
self.block_ms = _worker_block_ms(self.block_ms)
|
|
82
254
|
|
|
83
255
|
|
|
84
256
|
@dataclass
|
|
@@ -190,7 +362,8 @@ class ActionWorker:
|
|
|
190
362
|
worker_id: str | None = None,
|
|
191
363
|
concurrency: int = 10,
|
|
192
364
|
action_timeout: float = 300.0,
|
|
193
|
-
block_ms: int =
|
|
365
|
+
block_ms: int = _DEFAULT_WORKER_BLOCK_MS,
|
|
366
|
+
auto_touch: bool = False,
|
|
194
367
|
):
|
|
195
368
|
"""Initialize a Flo worker from a connected client.
|
|
196
369
|
|
|
@@ -199,7 +372,11 @@ class ActionWorker:
|
|
|
199
372
|
worker_id: Unique worker identifier (auto-generated if not provided).
|
|
200
373
|
concurrency: Maximum number of concurrent actions.
|
|
201
374
|
action_timeout: Timeout for action handlers in seconds.
|
|
202
|
-
block_ms:
|
|
375
|
+
block_ms: Long-poll wait per await in milliseconds, at most 300000.
|
|
376
|
+
0 means the 30000 default.
|
|
377
|
+
auto_touch: Touch each run every third of its action's timeout
|
|
378
|
+
while its handler runs. Off by default: a handler that runs
|
|
379
|
+
past the timeout without touching is taken back and run again.
|
|
203
380
|
"""
|
|
204
381
|
self._parent_client = parent_client
|
|
205
382
|
self.config = ActionWorkerOptions(
|
|
@@ -207,16 +384,27 @@ class ActionWorker:
|
|
|
207
384
|
concurrency=concurrency,
|
|
208
385
|
action_timeout=action_timeout,
|
|
209
386
|
block_ms=block_ms,
|
|
387
|
+
auto_touch=auto_touch,
|
|
210
388
|
)
|
|
211
389
|
|
|
212
390
|
self._client: FloClient | None = None
|
|
213
391
|
self._result_client: FloClient | None = None
|
|
392
|
+
# Concurrent results that fail on the same dead connection reconnect
|
|
393
|
+
# it once: the generation tells a waiter someone already did.
|
|
394
|
+
self._result_reconnect_lock = asyncio.Lock()
|
|
395
|
+
self._result_generation = 0
|
|
214
396
|
self._handlers: dict[str, ActionHandler] = {}
|
|
397
|
+
self._action_options: dict[str, ActionRegisterOptions] = {}
|
|
215
398
|
self._running = False
|
|
216
399
|
self._stop_event = asyncio.Event()
|
|
217
400
|
self._tasks: set[asyncio.Task[None]] = set()
|
|
218
401
|
self._semaphore: asyncio.Semaphore | None = None
|
|
219
402
|
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
403
|
+
self._action_names: list[str] = []
|
|
404
|
+
# The load heartbeats report. Not the semaphore, which also counts the
|
|
405
|
+
# slot an outstanding await holds, nor _tasks, which also counts
|
|
406
|
+
# results still being sent.
|
|
407
|
+
self._running_handlers = 0
|
|
220
408
|
|
|
221
409
|
@staticmethod
|
|
222
410
|
def _generate_worker_id() -> str:
|
|
@@ -227,11 +415,14 @@ class ActionWorker:
|
|
|
227
415
|
hostname = "unknown"
|
|
228
416
|
return f"{hostname}-{secrets.token_hex(4)}"
|
|
229
417
|
|
|
230
|
-
def action(
|
|
231
|
-
|
|
418
|
+
def action(
|
|
419
|
+
self, name: str, options: ActionRegisterOptions | None = None
|
|
420
|
+
) -> Callable[[ActionHandler], ActionHandler]:
|
|
421
|
+
"""Decorator to register an action handler (see register_action).
|
|
232
422
|
|
|
233
423
|
Args:
|
|
234
424
|
name: The action name to register.
|
|
425
|
+
options: The action's timeout, attempts and owner.
|
|
235
426
|
|
|
236
427
|
Returns:
|
|
237
428
|
Decorator function.
|
|
@@ -243,17 +434,30 @@ class ActionWorker:
|
|
|
243
434
|
"""
|
|
244
435
|
|
|
245
436
|
def decorator(handler: ActionHandler) -> ActionHandler:
|
|
246
|
-
self.register_action(name, handler)
|
|
437
|
+
self.register_action(name, handler, options)
|
|
247
438
|
return handler
|
|
248
439
|
|
|
249
440
|
return decorator
|
|
250
441
|
|
|
251
|
-
def register_action(
|
|
442
|
+
def register_action(
|
|
443
|
+
self,
|
|
444
|
+
name: str,
|
|
445
|
+
handler: ActionHandler,
|
|
446
|
+
options: ActionRegisterOptions | None = None,
|
|
447
|
+
) -> None:
|
|
252
448
|
"""Register an action handler.
|
|
253
449
|
|
|
450
|
+
The worker registers the action with the server when it starts, with
|
|
451
|
+
exactly these options (the server's defaults when none: a 30 s
|
|
452
|
+
timeout, 3 attempts). Registering replaces the action's settings, so
|
|
453
|
+
workers registering one action with different options leave it with
|
|
454
|
+
whichever registered last. The options' namespace is ignored: the
|
|
455
|
+
action goes in the worker's.
|
|
456
|
+
|
|
254
457
|
Args:
|
|
255
458
|
name: The action name.
|
|
256
459
|
handler: Async function that handles the action.
|
|
460
|
+
options: The action's timeout, attempts and owner.
|
|
257
461
|
|
|
258
462
|
Raises:
|
|
259
463
|
ValueError: If action is already registered.
|
|
@@ -261,6 +465,9 @@ class ActionWorker:
|
|
|
261
465
|
if name in self._handlers:
|
|
262
466
|
raise ValueError(f"Action '{name}' is already registered")
|
|
263
467
|
self._handlers[name] = handler
|
|
468
|
+
self._action_options[name] = dataclasses.replace(
|
|
469
|
+
options or ActionRegisterOptions(), namespace=None
|
|
470
|
+
)
|
|
264
471
|
logger.info(f"Registered action: {name}")
|
|
265
472
|
|
|
266
473
|
async def start(self) -> None:
|
|
@@ -281,17 +488,13 @@ class ActionWorker:
|
|
|
281
488
|
)
|
|
282
489
|
|
|
283
490
|
# Create a dedicated connection using the parent client's endpoint and namespace.
|
|
284
|
-
|
|
285
|
-
# (ACTION_AWAIT with block_ms) don't get killed by socket-level timeout.
|
|
286
|
-
worker_timeout_ms = max(
|
|
287
|
-
self.config.block_ms + 5000,
|
|
288
|
-
int(self.config.action_timeout * 1000),
|
|
289
|
-
)
|
|
491
|
+
worker_timeout_ms = int(self._parent_client._timeout * 1000)
|
|
290
492
|
self._client = FloClient(
|
|
291
493
|
self._parent_client._endpoint,
|
|
292
494
|
namespace=self._parent_client.namespace,
|
|
293
495
|
debug=self._parent_client._debug,
|
|
294
496
|
timeout_ms=worker_timeout_ms,
|
|
497
|
+
retry=self._parent_client._retry,
|
|
295
498
|
)
|
|
296
499
|
await self._client.connect()
|
|
297
500
|
|
|
@@ -303,27 +506,21 @@ class ActionWorker:
|
|
|
303
506
|
namespace=self._parent_client.namespace,
|
|
304
507
|
debug=self._parent_client._debug,
|
|
305
508
|
timeout_ms=worker_timeout_ms,
|
|
509
|
+
retry=self._parent_client._retry,
|
|
306
510
|
)
|
|
307
511
|
await self._result_client.connect()
|
|
308
512
|
|
|
309
513
|
try:
|
|
310
514
|
# Register actions with the server
|
|
311
515
|
action_names = list(self._handlers.keys())
|
|
516
|
+
self._action_names = action_names
|
|
312
517
|
for action_name in action_names:
|
|
313
|
-
await self._client.action.register(
|
|
518
|
+
await self._client.action.register(
|
|
519
|
+
action_name, ActionType.USER, self._action_options[action_name]
|
|
520
|
+
)
|
|
314
521
|
logger.debug(f"Registered action with server: {action_name}")
|
|
315
522
|
|
|
316
|
-
|
|
317
|
-
from .types import WorkerRegisterOptions
|
|
318
|
-
|
|
319
|
-
await self._client.worker.register(
|
|
320
|
-
self.config.worker_id,
|
|
321
|
-
action_names,
|
|
322
|
-
WorkerRegisterOptions(
|
|
323
|
-
concurrency=self.config.concurrency,
|
|
324
|
-
machine_id=socket.gethostname(),
|
|
325
|
-
),
|
|
326
|
-
)
|
|
523
|
+
await self._register(self._client)
|
|
327
524
|
logger.info(f"Worker registered with {len(action_names)} actions")
|
|
328
525
|
|
|
329
526
|
# Initialize concurrency control
|
|
@@ -358,20 +555,51 @@ class ActionWorker:
|
|
|
358
555
|
self._running = False
|
|
359
556
|
logger.info("Worker stopped")
|
|
360
557
|
|
|
558
|
+
async def _register(self, client: FloClient) -> None:
|
|
559
|
+
"""Register this worker, with the same options every time."""
|
|
560
|
+
await client.worker.register(
|
|
561
|
+
self.config.worker_id,
|
|
562
|
+
self._action_names,
|
|
563
|
+
WorkerRegisterOptions(
|
|
564
|
+
concurrency=self.config.concurrency,
|
|
565
|
+
machine_id=socket.gethostname(),
|
|
566
|
+
),
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
async def _heartbeat(self) -> None:
|
|
570
|
+
"""Send one heartbeat, registering again if the server removed this
|
|
571
|
+
worker: unregistered, it would go on taking work the server can't
|
|
572
|
+
see it holding."""
|
|
573
|
+
# On the result connection: on the poll connection it would wait
|
|
574
|
+
# behind a blocking await, up to block_ms, past the server's timeout.
|
|
575
|
+
client = self._result_client or self._client
|
|
576
|
+
assert client is not None
|
|
577
|
+
load = self._running_handlers
|
|
578
|
+
seen = self._result_generation
|
|
579
|
+
try:
|
|
580
|
+
await client.worker.heartbeat(self.config.worker_id, current_load=load)
|
|
581
|
+
except NotFoundError:
|
|
582
|
+
logger.warning("Worker was removed by the server; registering again")
|
|
583
|
+
await self._register(client)
|
|
584
|
+
return
|
|
585
|
+
except Exception as e:
|
|
586
|
+
# Nothing else reconnects this connection for an idle worker, which
|
|
587
|
+
# would then miss every heartbeat and be refused work for good.
|
|
588
|
+
lost = is_connection_error(e) or isinstance(e, asyncio.TimeoutError)
|
|
589
|
+
if self._result_client is not None and lost:
|
|
590
|
+
with contextlib.suppress(Exception):
|
|
591
|
+
await self._reconnect_result_client(seen)
|
|
592
|
+
raise
|
|
593
|
+
logger.debug(f"Heartbeat sent (load={load})")
|
|
594
|
+
|
|
361
595
|
async def _heartbeat_loop(self) -> None:
|
|
362
596
|
"""Send periodic heartbeats to keep the worker registration alive."""
|
|
363
|
-
assert self._client is not None
|
|
364
597
|
while self._running and not self._stop_event.is_set():
|
|
365
598
|
try:
|
|
366
|
-
await asyncio.sleep(
|
|
599
|
+
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
|
367
600
|
if not self._running:
|
|
368
601
|
break
|
|
369
|
-
|
|
370
|
-
await self._client.worker.heartbeat(
|
|
371
|
-
self.config.worker_id,
|
|
372
|
-
current_load=current_load,
|
|
373
|
-
)
|
|
374
|
-
logger.debug(f"Heartbeat sent (load={current_load})")
|
|
602
|
+
await self._heartbeat()
|
|
375
603
|
except asyncio.CancelledError:
|
|
376
604
|
break
|
|
377
605
|
except Exception as e:
|
|
@@ -381,6 +609,13 @@ class ActionWorker:
|
|
|
381
609
|
"""Main polling loop for tasks."""
|
|
382
610
|
assert self._client is not None
|
|
383
611
|
assert self._semaphore is not None
|
|
612
|
+
# Pause before each reconnect after the first in a row: a server that
|
|
613
|
+
# accepts and then drops the connection would otherwise be hit in a
|
|
614
|
+
# tight loop. A poll that gets an answer resets it.
|
|
615
|
+
reconnect_pause = 0.0
|
|
616
|
+
loop = asyncio.get_running_loop()
|
|
617
|
+
backoff = _EmptyPollBackoff(self.config.block_ms, self._stop_event)
|
|
618
|
+
failed = _worker_backoff()
|
|
384
619
|
while self._running and not self._stop_event.is_set():
|
|
385
620
|
try:
|
|
386
621
|
# Wait for semaphore slot
|
|
@@ -392,16 +627,21 @@ class ActionWorker:
|
|
|
392
627
|
break
|
|
393
628
|
|
|
394
629
|
# Await task from server
|
|
630
|
+
started = loop.time()
|
|
395
631
|
result = await self._client.worker.await_task(
|
|
396
632
|
self.config.worker_id,
|
|
397
633
|
action_names,
|
|
398
634
|
WorkerAwaitOptions(block_ms=self.config.block_ms),
|
|
399
635
|
)
|
|
636
|
+
reconnect_pause = 0.0
|
|
637
|
+
failed.reset()
|
|
400
638
|
|
|
401
639
|
if result.task is None:
|
|
402
640
|
# No task available, release semaphore and continue
|
|
403
641
|
self._semaphore.release()
|
|
642
|
+
await backoff.empty(loop.time() - started)
|
|
404
643
|
continue
|
|
644
|
+
backoff.reset()
|
|
405
645
|
|
|
406
646
|
# Execute task in background
|
|
407
647
|
task = asyncio.create_task(self._execute_task(result.task))
|
|
@@ -412,213 +652,252 @@ class ActionWorker:
|
|
|
412
652
|
break
|
|
413
653
|
except Exception as e:
|
|
414
654
|
self._semaphore.release()
|
|
415
|
-
if
|
|
655
|
+
reason = e.reason if isinstance(e, ServerError) else None
|
|
656
|
+
if reason == Reason.WORKER_DRAINING:
|
|
657
|
+
# The server drained this worker: take no more work, and
|
|
658
|
+
# let the running handlers finish.
|
|
659
|
+
logger.info("Worker is draining; no longer taking work")
|
|
660
|
+
break
|
|
661
|
+
if reason == Reason.WORKER_UNHEALTHY:
|
|
662
|
+
# It missed its heartbeats: one now lets it take work
|
|
663
|
+
# again; the backoff keeps a heartbeat that fails from
|
|
664
|
+
# turning this into a hot loop.
|
|
665
|
+
try:
|
|
666
|
+
await self._heartbeat()
|
|
667
|
+
except Exception as hb_err:
|
|
668
|
+
logger.warning(f"Heartbeat failed: {hb_err}")
|
|
669
|
+
d = failed.next()
|
|
670
|
+
logger.warning(f"Await refused, worker unhealthy; retrying in {d:.3f}s")
|
|
671
|
+
await _sleep_unless_stopped(self._stop_event, d)
|
|
672
|
+
elif is_connection_error(e):
|
|
673
|
+
# stop() interrupts the poll connection: that is not a
|
|
674
|
+
# drop to recover from.
|
|
675
|
+
if self._stop_event.is_set():
|
|
676
|
+
break
|
|
677
|
+
if reconnect_pause:
|
|
678
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
679
|
+
await asyncio.wait_for(self._stop_event.wait(), reconnect_pause)
|
|
680
|
+
if self._stop_event.is_set():
|
|
681
|
+
break
|
|
682
|
+
reconnect_pause = min(max(reconnect_pause * 2, 1.0), 30.0)
|
|
416
683
|
logger.warning("Connection lost, reconnecting...")
|
|
417
684
|
try:
|
|
418
685
|
await self._client.reconnect()
|
|
419
686
|
# Also reconnect result client
|
|
420
687
|
if self._result_client is not None:
|
|
421
688
|
try:
|
|
422
|
-
await self.
|
|
689
|
+
await self._reconnect_result_client(only_if_down=True)
|
|
423
690
|
except Exception as rc_err:
|
|
424
691
|
logger.warning(f"Failed to reconnect result client: {rc_err}")
|
|
425
692
|
# Re-register worker after reconnect
|
|
426
693
|
try:
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
await self._client.worker.register(
|
|
430
|
-
self.config.worker_id,
|
|
431
|
-
action_names,
|
|
432
|
-
WorkerRegisterOptions(
|
|
433
|
-
concurrency=self.config.concurrency,
|
|
434
|
-
machine_id=socket.gethostname(),
|
|
435
|
-
),
|
|
436
|
-
)
|
|
694
|
+
await self._register(self._client)
|
|
437
695
|
except Exception as reg_err:
|
|
438
696
|
logger.warning(f"Failed to re-register worker: {reg_err}")
|
|
439
697
|
logger.info("Reconnected, resuming work")
|
|
440
698
|
except Exception as recon_err:
|
|
441
699
|
logger.error(f"Reconnect failed: {recon_err}, retrying...")
|
|
442
|
-
await asyncio.sleep(1)
|
|
443
700
|
else:
|
|
444
|
-
|
|
445
|
-
|
|
701
|
+
d = failed.next()
|
|
702
|
+
logger.error(f"Await error: {e}, retrying in {d:.3f}s...")
|
|
703
|
+
await _sleep_unless_stopped(self._stop_event, d)
|
|
446
704
|
|
|
447
|
-
async def
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
705
|
+
async def _reconnect_result_client(
|
|
706
|
+
self, failed_generation: int | None = None, *, only_if_down: bool = False
|
|
707
|
+
) -> None:
|
|
708
|
+
"""Reconnect the result connection, unless it was reconnected since
|
|
709
|
+
`failed_generation` was observed, or, with `only_if_down`, it is up.
|
|
710
|
+
|
|
711
|
+
The poll connection dropping says nothing certain about this one, and
|
|
712
|
+
replacing a healthy connection would cut off results in flight on it.
|
|
713
|
+
"""
|
|
714
|
+
assert self._result_client is not None
|
|
715
|
+
async with self._result_reconnect_lock:
|
|
716
|
+
if failed_generation is not None and failed_generation != self._result_generation:
|
|
453
717
|
return
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
f"Connection lost while sending {op} result "
|
|
459
|
-
f"(attempt {attempt}/{max_attempts}), reconnecting..."
|
|
460
|
-
)
|
|
461
|
-
if self._result_client is not None:
|
|
462
|
-
await self._result_client.reconnect()
|
|
463
|
-
raise RuntimeError(f"Failed to send {op} result after {max_attempts} attempts")
|
|
718
|
+
if only_if_down and self._result_client.is_connected:
|
|
719
|
+
return
|
|
720
|
+
await self._result_client.reconnect()
|
|
721
|
+
self._result_generation += 1
|
|
464
722
|
|
|
465
723
|
async def _execute_task(self, task: TaskAssignment) -> None:
|
|
466
|
-
"""
|
|
467
|
-
|
|
468
|
-
assert
|
|
469
|
-
rc = self._result_client or self._client
|
|
724
|
+
"""Run a task's handler, then report its outcome."""
|
|
725
|
+
rc = self._result_client
|
|
726
|
+
assert rc is not None
|
|
470
727
|
try:
|
|
471
|
-
|
|
472
|
-
f"Executing action: {task.task_type} (task={task.task_id}, attempt={task.attempt})"
|
|
473
|
-
)
|
|
474
|
-
|
|
475
|
-
# Get handler
|
|
476
|
-
handler = self._handlers.get(task.task_type)
|
|
477
|
-
if handler is None:
|
|
478
|
-
logger.error(f"No handler registered for action: {task.task_type}")
|
|
479
|
-
await self._send_with_retry(
|
|
480
|
-
"fail",
|
|
481
|
-
lambda: rc.worker.fail(
|
|
482
|
-
self.config.worker_id,
|
|
483
|
-
task.task_type,
|
|
484
|
-
task.task_id,
|
|
485
|
-
f"No handler for: {task.task_type}",
|
|
486
|
-
),
|
|
487
|
-
)
|
|
488
|
-
return
|
|
489
|
-
|
|
490
|
-
# Create action context
|
|
491
|
-
ctx = ActionContext(
|
|
492
|
-
task_id=task.task_id,
|
|
493
|
-
action_name=task.task_type,
|
|
494
|
-
payload=task.payload,
|
|
495
|
-
attempt=task.attempt,
|
|
496
|
-
created_at=task.created_at,
|
|
497
|
-
namespace=self._parent_client.namespace,
|
|
498
|
-
_worker=self,
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
# Execute with timeout
|
|
728
|
+
self._running_handlers += 1
|
|
502
729
|
try:
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
730
|
+
op, send, cancelled = await self._run_handler(task)
|
|
731
|
+
finally:
|
|
732
|
+
self._running_handlers -= 1
|
|
733
|
+
# Reporting stays outside the handler's error handling: a complete
|
|
734
|
+
# that can't be delivered must not turn into a fail.
|
|
735
|
+
sent = await _send_result(
|
|
736
|
+
f"{op} for task {task.task_id}",
|
|
737
|
+
lambda: send(rc),
|
|
738
|
+
lambda: self._result_generation,
|
|
739
|
+
self._reconnect_result_client,
|
|
740
|
+
self._stop_event,
|
|
741
|
+
)
|
|
742
|
+
if sent and op == "complete":
|
|
743
|
+
logger.info(f"Action completed: {task.task_type} (task={task.task_id})")
|
|
744
|
+
if cancelled:
|
|
745
|
+
raise asyncio.CancelledError()
|
|
746
|
+
except Exception as e:
|
|
747
|
+
logger.error(f"Failed to report result for task {task.task_id}: {e}")
|
|
748
|
+
finally:
|
|
749
|
+
if self._semaphore is not None:
|
|
750
|
+
self._semaphore.release()
|
|
507
751
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if isinstance(result, ActionResult):
|
|
512
|
-
# Named outcome
|
|
513
|
-
await self._send_with_retry(
|
|
514
|
-
"complete",
|
|
515
|
-
lambda: rc.worker.complete(
|
|
516
|
-
self.config.worker_id,
|
|
517
|
-
task.task_type,
|
|
518
|
-
task.task_id,
|
|
519
|
-
result.data,
|
|
520
|
-
WorkerCompleteOptions(outcome=result.outcome),
|
|
521
|
-
),
|
|
522
|
-
)
|
|
523
|
-
elif isinstance(result, dict):
|
|
524
|
-
# Plain dict → JSON serialize
|
|
525
|
-
result_bytes = json.dumps(result).encode("utf-8")
|
|
526
|
-
await self._send_with_retry(
|
|
527
|
-
"complete",
|
|
528
|
-
lambda: rc.worker.complete(
|
|
529
|
-
self.config.worker_id,
|
|
530
|
-
task.task_type,
|
|
531
|
-
task.task_id,
|
|
532
|
-
result_bytes,
|
|
533
|
-
),
|
|
534
|
-
)
|
|
535
|
-
else:
|
|
536
|
-
# bytes or other → pass through
|
|
537
|
-
await self._send_with_retry(
|
|
538
|
-
"complete",
|
|
539
|
-
lambda: rc.worker.complete(
|
|
540
|
-
self.config.worker_id,
|
|
541
|
-
task.task_type,
|
|
542
|
-
task.task_id,
|
|
543
|
-
result if isinstance(result, bytes) else b"",
|
|
544
|
-
),
|
|
545
|
-
)
|
|
546
|
-
logger.info(f"Action completed: {task.task_type}")
|
|
752
|
+
async def _run_handler(self, task: TaskAssignment) -> "_Report":
|
|
753
|
+
"""Run the task's handler. Its outcome alone decides complete or fail.
|
|
547
754
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
lambda: rc.worker.fail(
|
|
553
|
-
self.config.worker_id,
|
|
554
|
-
task.task_type,
|
|
555
|
-
task.task_id,
|
|
556
|
-
"Action timed out",
|
|
557
|
-
),
|
|
558
|
-
)
|
|
755
|
+
Returns the result to send, and whether the handler was cancelled, in
|
|
756
|
+
which case the caller re-raises the cancellation after reporting.
|
|
757
|
+
"""
|
|
758
|
+
from .types import WorkerCompleteOptions, WorkerFailOptions
|
|
559
759
|
|
|
560
|
-
|
|
561
|
-
logger.warning(f"Action cancelled: {task.task_type}")
|
|
562
|
-
await self._send_with_retry(
|
|
563
|
-
"fail",
|
|
564
|
-
lambda: rc.worker.fail(
|
|
565
|
-
self.config.worker_id,
|
|
566
|
-
task.task_type,
|
|
567
|
-
task.task_id,
|
|
568
|
-
"Action cancelled",
|
|
569
|
-
),
|
|
570
|
-
)
|
|
760
|
+
wid = self.config.worker_id
|
|
571
761
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
"fail",
|
|
580
|
-
lambda: rc.worker.fail(
|
|
581
|
-
self.config.worker_id,
|
|
582
|
-
task.task_type,
|
|
583
|
-
task.task_id,
|
|
584
|
-
err_msg,
|
|
585
|
-
WorkerFailOptions(retry=retry),
|
|
586
|
-
),
|
|
587
|
-
)
|
|
762
|
+
def fail(message: str, retry: bool = True, cancelled: bool = False) -> _Report:
|
|
763
|
+
options = WorkerFailOptions(retry=retry)
|
|
764
|
+
return (
|
|
765
|
+
"fail",
|
|
766
|
+
lambda c: c.worker.fail(wid, task.task_type, task.task_id, message, options),
|
|
767
|
+
cancelled,
|
|
768
|
+
)
|
|
588
769
|
|
|
589
|
-
|
|
590
|
-
|
|
770
|
+
def complete(data: bytes, options: WorkerCompleteOptions | None = None) -> _Report:
|
|
771
|
+
return (
|
|
772
|
+
"complete",
|
|
773
|
+
lambda c: c.worker.complete(wid, task.task_type, task.task_id, data, options),
|
|
774
|
+
False,
|
|
775
|
+
)
|
|
591
776
|
|
|
777
|
+
logger.info(
|
|
778
|
+
f"Executing action: {task.task_type} (task={task.task_id}, attempt={task.attempt})"
|
|
779
|
+
)
|
|
780
|
+
handler = self._handlers.get(task.task_type)
|
|
781
|
+
if handler is None:
|
|
782
|
+
logger.error(f"No handler registered for action: {task.task_type}")
|
|
783
|
+
return fail(f"No handler for: {task.task_type}")
|
|
784
|
+
|
|
785
|
+
ctx = ActionContext(
|
|
786
|
+
task_id=task.task_id,
|
|
787
|
+
action_name=task.task_type,
|
|
788
|
+
payload=task.payload,
|
|
789
|
+
attempt=task.attempt,
|
|
790
|
+
created_at=task.created_at,
|
|
791
|
+
namespace=self._parent_client.namespace,
|
|
792
|
+
_worker=self,
|
|
793
|
+
)
|
|
794
|
+
# Not wait_for: a TimeoutError the handler raises itself would look
|
|
795
|
+
# the same as the deadline passing. Only the deadline is final.
|
|
796
|
+
run = asyncio.ensure_future(handler(ctx))
|
|
797
|
+
timeout_ms = self._action_options[task.task_type].timeout_ms
|
|
798
|
+
stop_touching = asyncio.Event()
|
|
799
|
+
touching = (
|
|
800
|
+
asyncio.ensure_future(self._keep_touching(task, timeout_ms, stop_touching))
|
|
801
|
+
if self.config.auto_touch and timeout_ms > 0
|
|
802
|
+
else None
|
|
803
|
+
)
|
|
804
|
+
try:
|
|
805
|
+
await asyncio.wait({run}, timeout=self.config.action_timeout)
|
|
806
|
+
except asyncio.CancelledError:
|
|
807
|
+
run.cancel()
|
|
808
|
+
logger.warning(f"Action cancelled: {task.task_type}")
|
|
809
|
+
return fail("Action cancelled", cancelled=True)
|
|
592
810
|
finally:
|
|
593
|
-
if
|
|
594
|
-
|
|
811
|
+
if touching is not None:
|
|
812
|
+
# Let a touch in flight finish: cancelling it mid-request
|
|
813
|
+
# would close the result connection the outcome goes on.
|
|
814
|
+
stop_touching.set()
|
|
815
|
+
with contextlib.suppress(Exception):
|
|
816
|
+
await asyncio.shield(touching)
|
|
817
|
+
if not run.done():
|
|
818
|
+
run.cancel()
|
|
819
|
+
with contextlib.suppress(BaseException):
|
|
820
|
+
await run
|
|
821
|
+
logger.error(f"Action timed out: {task.task_type}")
|
|
822
|
+
return fail("Action timed out", retry=False)
|
|
823
|
+
try:
|
|
824
|
+
result = run.result()
|
|
825
|
+
except asyncio.CancelledError:
|
|
826
|
+
logger.warning(f"Action cancelled: {task.task_type}")
|
|
827
|
+
return fail("Action cancelled", cancelled=True)
|
|
828
|
+
except Exception as exc:
|
|
829
|
+
logger.error(f"Action failed: {task.task_type} - {exc!r}")
|
|
830
|
+
message = str(exc) or type(exc).__name__
|
|
831
|
+
return fail(message, retry=not isinstance(exc, NonRetryableError))
|
|
832
|
+
|
|
833
|
+
if isinstance(result, ActionResult):
|
|
834
|
+
return complete(result.data, WorkerCompleteOptions(outcome=result.outcome))
|
|
835
|
+
if isinstance(result, dict):
|
|
836
|
+
return complete(json.dumps(result).encode("utf-8"))
|
|
837
|
+
return complete(result if isinstance(result, bytes) else b"")
|
|
595
838
|
|
|
596
839
|
async def _touch_task(self, action_name: str, task_id: str, extend_ms: int) -> None:
|
|
597
|
-
"""Extend lease on a task (internal method).
|
|
598
|
-
|
|
840
|
+
"""Extend lease on a task (internal method).
|
|
841
|
+
|
|
842
|
+
On the result connection: the poll connection may be held by a
|
|
843
|
+
blocking await for up to block_ms, longer than a lease can wait.
|
|
844
|
+
"""
|
|
845
|
+
client = self._result_client or self._client
|
|
846
|
+
if client is None:
|
|
599
847
|
raise RuntimeError("Worker not connected")
|
|
600
|
-
await
|
|
848
|
+
await client.worker.touch(
|
|
601
849
|
self.config.worker_id,
|
|
602
850
|
action_name,
|
|
603
851
|
task_id,
|
|
604
852
|
WorkerTouchOptions(extend_ms=extend_ms),
|
|
605
853
|
)
|
|
606
854
|
|
|
855
|
+
async def _keep_touching(
|
|
856
|
+
self, task: TaskAssignment, timeout_ms: int, stop: asyncio.Event
|
|
857
|
+
) -> None:
|
|
858
|
+
"""Touch a run at _touch_pace(timeout_ms) until stop is set.
|
|
859
|
+
|
|
860
|
+
Each touch asks for this worker's timeout (at most an hour), so the
|
|
861
|
+
lease covers it even if another worker's registration shortened the
|
|
862
|
+
action's. Conflict, not_found or bad_request means the run moved on
|
|
863
|
+
(finished, cancelled, taken back) and touching stops; any other
|
|
864
|
+
refusal, an overloaded leader say, is tried again next interval, and
|
|
865
|
+
a lost connection is reconnected for it.
|
|
866
|
+
"""
|
|
867
|
+
interval_s, extend_ms = _touch_pace(timeout_ms)
|
|
868
|
+
while True:
|
|
869
|
+
try:
|
|
870
|
+
await asyncio.wait_for(stop.wait(), interval_s)
|
|
871
|
+
return
|
|
872
|
+
except asyncio.TimeoutError:
|
|
873
|
+
pass
|
|
874
|
+
seen = self._result_generation
|
|
875
|
+
try:
|
|
876
|
+
await self._touch_task(task.task_type, task.task_id, extend_ms)
|
|
877
|
+
except ServerError as e:
|
|
878
|
+
if e.status_code in _TOUCH_FINAL:
|
|
879
|
+
logger.warning(f"Touch for task {task.task_id} was refused, stopping: {e}")
|
|
880
|
+
return
|
|
881
|
+
logger.warning(f"Touch for task {task.task_id} was refused: {e}; trying again")
|
|
882
|
+
except Exception as e:
|
|
883
|
+
logger.warning(f"Touch for task {task.task_id} lost: {e!r}")
|
|
884
|
+
if is_connection_error(e) or isinstance(e, asyncio.TimeoutError):
|
|
885
|
+
with contextlib.suppress(Exception):
|
|
886
|
+
await self._reconnect_result_client(seen)
|
|
887
|
+
|
|
607
888
|
def stop(self) -> None:
|
|
608
889
|
"""Signal the worker to stop.
|
|
609
890
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
connections to unblock any blocking Await call immediately.
|
|
891
|
+
Ends the polling loop, interrupting a blocking Await. Running
|
|
892
|
+
handlers finish and their results are sent before start() returns.
|
|
613
893
|
"""
|
|
614
894
|
logger.info("Stopping worker...")
|
|
615
895
|
self._running = False
|
|
616
896
|
self._stop_event.set()
|
|
617
|
-
#
|
|
897
|
+
# Only the poll connection is interrupted: the result connection
|
|
898
|
+
# still has to carry the results of the handlers that are finishing.
|
|
618
899
|
if self._client:
|
|
619
900
|
self._client.interrupt()
|
|
620
|
-
if self._result_client:
|
|
621
|
-
self._result_client.interrupt()
|
|
622
901
|
|
|
623
902
|
async def close(self) -> None:
|
|
624
903
|
"""Stop and close the worker."""
|
|
@@ -664,8 +943,20 @@ class StreamWorkerOptions:
|
|
|
664
943
|
worker_id: str = ""
|
|
665
944
|
concurrency: int = 10
|
|
666
945
|
batch_size: int = 10
|
|
667
|
-
block_ms: int =
|
|
946
|
+
block_ms: int = _DEFAULT_WORKER_BLOCK_MS
|
|
668
947
|
message_timeout: float = 300.0 # 5 minutes
|
|
948
|
+
# Drain this consumer's pending (delivered-but-unacked) entries via
|
|
949
|
+
# group_claim after a reconnect, before resuming normal group_read.
|
|
950
|
+
# Required for at-least-once across reconnects: group_read alone only
|
|
951
|
+
# returns records past last_delivered_id. Set False for at-most-once.
|
|
952
|
+
redeliver_pending_on_reconnect: bool = True
|
|
953
|
+
# Minimum idle time (ms) an entry must have before the reconnect drain
|
|
954
|
+
# claims it. 0 = drain everything this consumer already owns; > 0 also
|
|
955
|
+
# steals entries abandoned by other (dead) consumers idle at least this long.
|
|
956
|
+
claim_min_idle_ms: int = 0
|
|
957
|
+
|
|
958
|
+
def __post_init__(self) -> None:
|
|
959
|
+
self.block_ms = _worker_block_ms(self.block_ms)
|
|
669
960
|
|
|
670
961
|
|
|
671
962
|
@dataclass
|
|
@@ -747,8 +1038,10 @@ class StreamWorker:
|
|
|
747
1038
|
worker_id: str | None = None,
|
|
748
1039
|
concurrency: int = 10,
|
|
749
1040
|
batch_size: int = 10,
|
|
750
|
-
block_ms: int =
|
|
1041
|
+
block_ms: int = _DEFAULT_WORKER_BLOCK_MS,
|
|
751
1042
|
message_timeout: float = 300.0,
|
|
1043
|
+
redeliver_pending_on_reconnect: bool = True,
|
|
1044
|
+
claim_min_idle_ms: int = 0,
|
|
752
1045
|
):
|
|
753
1046
|
self._parent_client = parent_client
|
|
754
1047
|
self._handler = handler
|
|
@@ -761,9 +1054,16 @@ class StreamWorker:
|
|
|
761
1054
|
batch_size=batch_size,
|
|
762
1055
|
block_ms=block_ms,
|
|
763
1056
|
message_timeout=message_timeout,
|
|
1057
|
+
redeliver_pending_on_reconnect=redeliver_pending_on_reconnect,
|
|
1058
|
+
claim_min_idle_ms=claim_min_idle_ms,
|
|
764
1059
|
)
|
|
765
1060
|
|
|
766
1061
|
self._client: FloClient | None = None
|
|
1062
|
+
self._ack_client: FloClient | None = None
|
|
1063
|
+
# Concurrent acks that fail on the same dead connection reconnect it
|
|
1064
|
+
# once: the generation tells a waiter someone already did.
|
|
1065
|
+
self._ack_reconnect_lock = asyncio.Lock()
|
|
1066
|
+
self._ack_generation = 0
|
|
767
1067
|
self._running = False
|
|
768
1068
|
self._stop_event = asyncio.Event()
|
|
769
1069
|
self._tasks: set[asyncio.Task[None]] = set()
|
|
@@ -790,33 +1090,46 @@ class StreamWorker:
|
|
|
790
1090
|
f"concurrency={self.config.concurrency})"
|
|
791
1091
|
)
|
|
792
1092
|
|
|
793
|
-
|
|
794
|
-
# (group_read with block_ms) don't get killed by socket-level timeout.
|
|
795
|
-
worker_timeout_ms = max(
|
|
796
|
-
self.config.block_ms + 5000,
|
|
797
|
-
int(self.config.message_timeout * 1000),
|
|
798
|
-
)
|
|
1093
|
+
worker_timeout_ms = int(self._parent_client._timeout * 1000)
|
|
799
1094
|
self._client = FloClient(
|
|
800
1095
|
self._parent_client._endpoint,
|
|
801
1096
|
namespace=self._parent_client.namespace,
|
|
802
1097
|
debug=self._parent_client._debug,
|
|
803
1098
|
timeout_ms=worker_timeout_ms,
|
|
1099
|
+
retry=self._parent_client._retry,
|
|
804
1100
|
)
|
|
805
1101
|
await self._client.connect()
|
|
806
1102
|
|
|
1103
|
+
# Acks and nacks get their own connection: the polling one is held by
|
|
1104
|
+
# a group_read for up to block_ms, and an ack queued behind it can
|
|
1105
|
+
# arrive after the group's ack timeout and see the record redelivered.
|
|
1106
|
+
self._ack_client = FloClient(
|
|
1107
|
+
self._parent_client._endpoint,
|
|
1108
|
+
namespace=self._parent_client.namespace,
|
|
1109
|
+
debug=self._parent_client._debug,
|
|
1110
|
+
timeout_ms=worker_timeout_ms,
|
|
1111
|
+
retry=self._parent_client._retry,
|
|
1112
|
+
)
|
|
807
1113
|
try:
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
self.config.consumer,
|
|
813
|
-
)
|
|
814
|
-
logger.info(f"Joined consumer group {self.config.group} on stream {self.config.stream}")
|
|
1114
|
+
await self._ack_client.connect()
|
|
1115
|
+
except BaseException:
|
|
1116
|
+
await self._client.close()
|
|
1117
|
+
raise
|
|
815
1118
|
|
|
1119
|
+
try:
|
|
1120
|
+
# Semaphore and running state must be set up before the join: a
|
|
1121
|
+
# connection error during the join triggers _handle_reconnect ->
|
|
1122
|
+
# _drain_pending, which acquires the semaphore.
|
|
816
1123
|
self._semaphore = asyncio.Semaphore(self.config.concurrency)
|
|
817
1124
|
self._running = True
|
|
818
1125
|
self._stop_event.clear()
|
|
819
1126
|
|
|
1127
|
+
# Join consumer group. A connection error here (a stalled or dropped
|
|
1128
|
+
# connection at startup) is retried via reconnect rather than
|
|
1129
|
+
# killing the worker — mirroring the poll loop and ack retry.
|
|
1130
|
+
await self._join_group()
|
|
1131
|
+
logger.info(f"Joined consumer group {self.config.group} on stream {self.config.stream}")
|
|
1132
|
+
|
|
820
1133
|
await self._poll_loop()
|
|
821
1134
|
|
|
822
1135
|
finally:
|
|
@@ -839,6 +1152,9 @@ class StreamWorker:
|
|
|
839
1152
|
|
|
840
1153
|
await self._client.close()
|
|
841
1154
|
self._client = None
|
|
1155
|
+
if self._ack_client:
|
|
1156
|
+
await self._ack_client.close()
|
|
1157
|
+
self._ack_client = None
|
|
842
1158
|
|
|
843
1159
|
self._running = False
|
|
844
1160
|
logger.info("Stream worker stopped")
|
|
@@ -847,6 +1163,9 @@ class StreamWorker:
|
|
|
847
1163
|
"""Main polling loop for stream records."""
|
|
848
1164
|
assert self._client is not None
|
|
849
1165
|
assert self._semaphore is not None
|
|
1166
|
+
loop = asyncio.get_running_loop()
|
|
1167
|
+
backoff = _EmptyPollBackoff(self.config.block_ms, self._stop_event)
|
|
1168
|
+
failed = _worker_backoff()
|
|
850
1169
|
|
|
851
1170
|
while self._running and not self._stop_event.is_set():
|
|
852
1171
|
try:
|
|
@@ -857,6 +1176,7 @@ class StreamWorker:
|
|
|
857
1176
|
self._semaphore.release()
|
|
858
1177
|
break
|
|
859
1178
|
|
|
1179
|
+
started = loop.time()
|
|
860
1180
|
result = await self._client.stream.group_read(
|
|
861
1181
|
self.config.stream,
|
|
862
1182
|
self.config.group,
|
|
@@ -866,10 +1186,13 @@ class StreamWorker:
|
|
|
866
1186
|
block_ms=self.config.block_ms,
|
|
867
1187
|
),
|
|
868
1188
|
)
|
|
1189
|
+
failed.reset()
|
|
869
1190
|
|
|
870
1191
|
if not result.records:
|
|
871
1192
|
self._semaphore.release()
|
|
1193
|
+
await backoff.empty(loop.time() - started)
|
|
872
1194
|
continue
|
|
1195
|
+
backoff.reset()
|
|
873
1196
|
|
|
874
1197
|
# Release semaphore before dispatching — each task will
|
|
875
1198
|
# acquire its own slot via _process_record.
|
|
@@ -888,6 +1211,10 @@ class StreamWorker:
|
|
|
888
1211
|
break
|
|
889
1212
|
except Exception as e:
|
|
890
1213
|
self._semaphore.release()
|
|
1214
|
+
if self._stop_event.is_set():
|
|
1215
|
+
# stop() interrupted the read. Reconnecting would also
|
|
1216
|
+
# reconnect the ack connection under the draining handlers.
|
|
1217
|
+
break
|
|
891
1218
|
if is_connection_error(e):
|
|
892
1219
|
logger.warning("Stream worker lost connection, reconnecting...")
|
|
893
1220
|
try:
|
|
@@ -897,46 +1224,146 @@ class StreamWorker:
|
|
|
897
1224
|
logger.error(f"Stream worker reconnect failed: {recon_err}, retrying...")
|
|
898
1225
|
await asyncio.sleep(1)
|
|
899
1226
|
else:
|
|
900
|
-
|
|
901
|
-
|
|
1227
|
+
d = failed.next()
|
|
1228
|
+
logger.error(f"Stream read error: {e}, retrying in {d:.3f}s...")
|
|
1229
|
+
await _sleep_unless_stopped(self._stop_event, d)
|
|
1230
|
+
|
|
1231
|
+
async def _join_group(self) -> None:
|
|
1232
|
+
"""Join the consumer group, retrying on connection errors.
|
|
1233
|
+
|
|
1234
|
+
A connection error (a stalled or dropped connection at startup) triggers
|
|
1235
|
+
a reconnect-and-retry instead of failing the worker. A join the server
|
|
1236
|
+
refused without running it (overloaded or unavailable, past the
|
|
1237
|
+
client's own retries) is tried again with the worker backoff. Other
|
|
1238
|
+
errors (e.g. an invalid request) remain fatal and are raised.
|
|
1239
|
+
"""
|
|
1240
|
+
assert self._client is not None
|
|
1241
|
+
failed = _worker_backoff()
|
|
1242
|
+
while True:
|
|
1243
|
+
if self._stop_event.is_set():
|
|
1244
|
+
return
|
|
1245
|
+
try:
|
|
1246
|
+
await self._client.stream.group_join(
|
|
1247
|
+
self.config.stream,
|
|
1248
|
+
self.config.group,
|
|
1249
|
+
self.config.consumer,
|
|
1250
|
+
)
|
|
1251
|
+
return
|
|
1252
|
+
except Exception as e:
|
|
1253
|
+
if is_refused_unrun(e):
|
|
1254
|
+
d = failed.next()
|
|
1255
|
+
logger.warning(f"Group join refused: {e}, retrying in {d:.3f}s...")
|
|
1256
|
+
await _sleep_unless_stopped(self._stop_event, d)
|
|
1257
|
+
continue
|
|
1258
|
+
if not is_connection_error(e):
|
|
1259
|
+
raise
|
|
1260
|
+
logger.warning(f"Connection lost during group join: {e}, reconnecting...")
|
|
1261
|
+
with contextlib.suppress(Exception):
|
|
1262
|
+
await self._handle_reconnect()
|
|
1263
|
+
await asyncio.sleep(1)
|
|
902
1264
|
|
|
903
1265
|
async def _handle_reconnect(self) -> None:
|
|
904
|
-
"""Reconnect
|
|
1266
|
+
"""Reconnect, re-join the consumer group, and drain pending entries.
|
|
1267
|
+
|
|
1268
|
+
After re-joining, this consumer's pending (delivered-but-unacked)
|
|
1269
|
+
entries are re-processed via a group_claim cursor loop before normal
|
|
1270
|
+
reads resume — group_read only returns records past last_delivered_id,
|
|
1271
|
+
so without this an in-flight record at crash time is never re-surfaced.
|
|
1272
|
+
See ``redeliver_pending_on_reconnect``.
|
|
1273
|
+
"""
|
|
905
1274
|
assert self._client is not None
|
|
906
1275
|
await self._client.reconnect()
|
|
1276
|
+
# The ack connection went down with the poll connection (e.g. a server
|
|
1277
|
+
# restart) but may not notice until an ack fails on it.
|
|
1278
|
+
try:
|
|
1279
|
+
await self._reconnect_ack_client()
|
|
1280
|
+
except Exception as e:
|
|
1281
|
+
logger.warning(f"Failed to reconnect ack client: {e}")
|
|
907
1282
|
await self._client.stream.group_join(
|
|
908
1283
|
self.config.stream,
|
|
909
1284
|
self.config.group,
|
|
910
1285
|
self.config.consumer,
|
|
911
1286
|
)
|
|
1287
|
+
if self.config.redeliver_pending_on_reconnect:
|
|
1288
|
+
await self._drain_pending()
|
|
912
1289
|
|
|
913
|
-
async def
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1290
|
+
async def _drain_pending(self) -> None:
|
|
1291
|
+
"""Re-process this consumer's pending entries via a group_claim loop.
|
|
1292
|
+
|
|
1293
|
+
Records flow through the same _process_record path (and concurrency
|
|
1294
|
+
gate) as live reads, so a handler that succeeds acks and clears the PEL
|
|
1295
|
+
entry. Bounded per stream by a max page count so a pathological backlog
|
|
1296
|
+
can't wedge the reconnect indefinitely; leftovers are reclaimed on the
|
|
1297
|
+
next reconnect or by group_read once acked.
|
|
1298
|
+
"""
|
|
1299
|
+
if self._semaphore is None or self._client is None:
|
|
1300
|
+
# start() has not initialized the concurrency gate yet.
|
|
1301
|
+
return
|
|
1302
|
+
|
|
1303
|
+
max_pages = 1000 # safety cap: max_pages * batch_size entries
|
|
1304
|
+
cursor = StreamID() # MIN — scan from the start of the PEL
|
|
1305
|
+
pages = 0
|
|
1306
|
+
while True:
|
|
1307
|
+
if self._stop_event.is_set():
|
|
1308
|
+
return
|
|
919
1309
|
try:
|
|
920
|
-
|
|
921
|
-
await self._client.stream.group_ack(
|
|
1310
|
+
result = await self._client.stream.group_claim(
|
|
922
1311
|
self.config.stream,
|
|
923
1312
|
self.config.group,
|
|
924
|
-
|
|
925
|
-
|
|
1313
|
+
self.config.consumer,
|
|
1314
|
+
self.config.claim_min_idle_ms,
|
|
1315
|
+
cursor,
|
|
1316
|
+
self.config.batch_size,
|
|
926
1317
|
)
|
|
927
|
-
return
|
|
928
1318
|
except Exception as e:
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
)
|
|
935
|
-
|
|
1319
|
+
# Don't fail the reconnect on a drain error; normal reads resume.
|
|
1320
|
+
logger.warning(f"Pending drain claim failed: {e}")
|
|
1321
|
+
return
|
|
1322
|
+
|
|
1323
|
+
for record in result.records:
|
|
1324
|
+
await self._semaphore.acquire() # same gate as live reads
|
|
1325
|
+
if self._stop_event.is_set():
|
|
1326
|
+
self._semaphore.release()
|
|
1327
|
+
return
|
|
1328
|
+
task = asyncio.create_task(self._process_record(record))
|
|
1329
|
+
self._tasks.add(task)
|
|
1330
|
+
task.add_done_callback(self._tasks.discard)
|
|
1331
|
+
|
|
1332
|
+
pages += 1
|
|
1333
|
+
if result.done or not result.records or pages >= max_pages:
|
|
1334
|
+
return
|
|
1335
|
+
cursor = result.next_cursor
|
|
1336
|
+
|
|
1337
|
+
async def _reconnect_ack_client(self, failed_generation: int | None = None) -> None:
|
|
1338
|
+
"""Reconnect the ack connection, unless it was reconnected since
|
|
1339
|
+
`failed_generation` was observed."""
|
|
1340
|
+
assert self._ack_client is not None
|
|
1341
|
+
async with self._ack_reconnect_lock:
|
|
1342
|
+
if failed_generation is not None and failed_generation != self._ack_generation:
|
|
1343
|
+
return
|
|
1344
|
+
await self._ack_client.reconnect()
|
|
1345
|
+
self._ack_generation += 1
|
|
1346
|
+
|
|
1347
|
+
async def _on_ack_connection(
|
|
1348
|
+
self,
|
|
1349
|
+
op: str,
|
|
1350
|
+
record_id: StreamID,
|
|
1351
|
+
send: Callable[[FloClient], Awaitable[object]],
|
|
1352
|
+
) -> bool:
|
|
1353
|
+
"""Send an ack or nack on the ack connection, resending it after a
|
|
1354
|
+
reconnect if the connection drops. Never raises; False if dropped."""
|
|
1355
|
+
ack_client = self._ack_client
|
|
1356
|
+
assert ack_client is not None
|
|
1357
|
+
return await _send_result(
|
|
1358
|
+
f"{op} for record {record_id}",
|
|
1359
|
+
lambda: send(ack_client),
|
|
1360
|
+
lambda: self._ack_generation,
|
|
1361
|
+
self._reconnect_ack_client,
|
|
1362
|
+
self._stop_event,
|
|
1363
|
+
)
|
|
936
1364
|
|
|
937
1365
|
async def _process_record(self, record: StreamRecord) -> None:
|
|
938
1366
|
"""Process a single record: call handler, then ack or nack."""
|
|
939
|
-
assert self._client is not None
|
|
940
1367
|
assert self._semaphore is not None
|
|
941
1368
|
try:
|
|
942
1369
|
ctx = StreamContext(
|
|
@@ -946,36 +1373,41 @@ class StreamWorker:
|
|
|
946
1373
|
group=self.config.group,
|
|
947
1374
|
consumer=self.config.consumer,
|
|
948
1375
|
)
|
|
949
|
-
|
|
950
1376
|
try:
|
|
951
|
-
await asyncio.wait_for(
|
|
952
|
-
|
|
953
|
-
timeout=self.config.message_timeout,
|
|
954
|
-
)
|
|
955
|
-
|
|
956
|
-
# Success — ack with retry
|
|
957
|
-
await self._ack_with_retry(
|
|
958
|
-
[record.id],
|
|
959
|
-
StreamGroupAckOptions(consumer=self.config.consumer),
|
|
960
|
-
)
|
|
961
|
-
|
|
1377
|
+
await asyncio.wait_for(self._handler(ctx), timeout=self.config.message_timeout)
|
|
1378
|
+
handled = True
|
|
962
1379
|
except Exception as e:
|
|
963
1380
|
logger.error(
|
|
964
1381
|
f"Record processing failed (stream={self.config.stream}, id={record.id}): {e}"
|
|
965
1382
|
)
|
|
966
|
-
|
|
967
|
-
|
|
1383
|
+
handled = False
|
|
1384
|
+
|
|
1385
|
+
# Outside the handler's error handling: an ack that can't be
|
|
1386
|
+
# delivered must not turn into a nack.
|
|
1387
|
+
if handled:
|
|
1388
|
+
await self._on_ack_connection(
|
|
1389
|
+
"ack",
|
|
1390
|
+
record.id,
|
|
1391
|
+
lambda c: c.stream.group_ack(
|
|
1392
|
+
self.config.stream,
|
|
1393
|
+
self.config.group,
|
|
1394
|
+
[record.id],
|
|
1395
|
+
StreamGroupAckOptions(consumer=self.config.consumer),
|
|
1396
|
+
),
|
|
1397
|
+
)
|
|
1398
|
+
else:
|
|
1399
|
+
await self._on_ack_connection(
|
|
1400
|
+
"nack",
|
|
1401
|
+
record.id,
|
|
1402
|
+
lambda c: c.stream.group_nack(
|
|
968
1403
|
self.config.stream,
|
|
969
1404
|
self.config.group,
|
|
970
1405
|
[record.id],
|
|
971
1406
|
StreamGroupNackOptions(consumer=self.config.consumer),
|
|
972
|
-
)
|
|
973
|
-
|
|
974
|
-
logger.error(f"Failed to nack record: {nack_err}")
|
|
975
|
-
|
|
1407
|
+
),
|
|
1408
|
+
)
|
|
976
1409
|
except Exception as e:
|
|
977
1410
|
logger.error(f"Failed to process record: {e}")
|
|
978
|
-
|
|
979
1411
|
finally:
|
|
980
1412
|
self._semaphore.release()
|
|
981
1413
|
|
|
@@ -992,6 +1424,8 @@ class StreamWorker:
|
|
|
992
1424
|
self.stop()
|
|
993
1425
|
if self._client:
|
|
994
1426
|
await self._client.close()
|
|
1427
|
+
if self._ack_client:
|
|
1428
|
+
await self._ack_client.close()
|
|
995
1429
|
|
|
996
1430
|
async def __aenter__(self) -> "StreamWorker":
|
|
997
1431
|
"""Async context manager entry."""
|