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/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 = 30000
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 = 30000,
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: Timeout for blocking dequeue in milliseconds.
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(self, name: str) -> Callable[[ActionHandler], ActionHandler]:
231
- """Decorator to register an action handler.
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(self, name: str, handler: ActionHandler) -> None:
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
- # Timeout must accommodate block_ms + action_timeout so blocking reads
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(action_name, ActionType.USER)
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
- # Register worker
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(30)
599
+ await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
367
600
  if not self._running:
368
601
  break
369
- current_load = len(self._tasks)
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 is_connection_error(e):
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._result_client.reconnect()
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
- from .types import WorkerRegisterOptions
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
- logger.error(f"Await error: {e}, retrying...")
445
- await asyncio.sleep(1)
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 _send_with_retry(self, op: str, fn: Callable[[], Awaitable[None]]) -> None:
448
- """Attempt to send a result (Complete/Fail), reconnecting on connection error."""
449
- max_attempts = 3
450
- for attempt in range(1, max_attempts + 1):
451
- try:
452
- await fn()
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
- except Exception as e:
455
- if not is_connection_error(e) or not self._running:
456
- raise
457
- logger.warning(
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
- """Execute a task with error handling."""
467
- assert self._client is not None
468
- assert self._semaphore is not None
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
- logger.info(
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
- result = await asyncio.wait_for(
504
- handler(ctx),
505
- timeout=self.config.action_timeout,
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
- # 3-way dispatch based on result type
509
- from .types import WorkerCompleteOptions
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
- except asyncio.TimeoutError:
549
- logger.error(f"Action timed out: {task.task_type}")
550
- await self._send_with_retry(
551
- "fail",
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
- except asyncio.CancelledError:
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
- except Exception as exc:
573
- logger.error(f"Action failed: {task.task_type} - {exc}")
574
- from .types import WorkerFailOptions
575
-
576
- retry = not isinstance(exc, NonRetryableError)
577
- err_msg = str(exc)
578
- await self._send_with_retry(
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
- except Exception as e:
590
- logger.error(f"Failed to report task result: {e}")
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 self._semaphore is not None:
594
- self._semaphore.release()
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
- if self._client is None:
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 self._client.worker.touch(
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
- This sets a flag that will cause the polling loop to exit
611
- after the current iteration completes. Also interrupts in-flight
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
- # Interrupt connections to unblock any blocking Await
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 = 30000
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 = 30000,
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
- # Timeout must accommodate block_ms + message_timeout so blocking reads
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
- # Join consumer group
809
- await self._client.stream.group_join(
810
- self.config.stream,
811
- self.config.group,
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
- logger.error(f"Stream read error: {e}, retrying...")
901
- await asyncio.sleep(1)
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 and re-join the consumer group."""
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 _ack_with_retry(
914
- self, record_ids: list[StreamID], options: StreamGroupAckOptions
915
- ) -> None:
916
- """Ack with retry on connection error."""
917
- max_attempts = 3
918
- for attempt in range(1, max_attempts + 1):
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
- assert self._client is not None
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
- record_ids,
925
- options,
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
- if not is_connection_error(e) or not self._running:
930
- raise
931
- logger.warning(
932
- "Connection lost while acking "
933
- f"(attempt {attempt}/{max_attempts}), reconnecting..."
934
- )
935
- await self._handle_reconnect()
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
- self._handler(ctx),
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
- try:
967
- await self._client.stream.group_nack(
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
- except Exception as nack_err:
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."""