ringo-task-queue 0.1.0.dev2__py3-none-win_amd64.whl → 0.1.0.dev4__py3-none-win_amd64.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.
- ringo_task_queue/__init__.py +4 -1
- ringo_task_queue/_proto/ringo/v1/queue_pb2.py +136 -137
- ringo_task_queue/_proto/ringo/v1/queue_pb2_grpc.py +618 -619
- ringo_task_queue/_version.py +5 -3
- ringo_task_queue/bin/manifest.json +6 -6
- ringo_task_queue/bin/ringo-task-queue-windows-amd64.exe +0 -0
- ringo_task_queue/client.py +16 -3
- ringo_task_queue/concurrency.py +480 -0
- ringo_task_queue/errors.py +8 -1
- ringo_task_queue/worker.py +351 -48
- {ringo_task_queue-0.1.0.dev2.dist-info → ringo_task_queue-0.1.0.dev4.dist-info}/METADATA +54 -33
- ringo_task_queue-0.1.0.dev4.dist-info/RECORD +19 -0
- {ringo_task_queue-0.1.0.dev2.dist-info → ringo_task_queue-0.1.0.dev4.dist-info}/WHEEL +1 -1
- ringo_task_queue-0.1.0.dev2.dist-info/RECORD +0 -18
- {ringo_task_queue-0.1.0.dev2.dist-info → ringo_task_queue-0.1.0.dev4.dist-info}/licenses/LICENSE +0 -0
ringo_task_queue/worker.py
CHANGED
|
@@ -17,6 +17,7 @@ import grpc
|
|
|
17
17
|
|
|
18
18
|
from . import _pb
|
|
19
19
|
from ._proto.ringo.v1 import queue_pb2
|
|
20
|
+
from .concurrency import AdaptiveConcurrency, ResourceConcurrency, _Member
|
|
20
21
|
from .errors import (
|
|
21
22
|
IncompatibleVersionError,
|
|
22
23
|
LeaseLostError,
|
|
@@ -145,6 +146,10 @@ class _Assignment:
|
|
|
145
146
|
renew_task: asyncio.Task[Any] | None = None
|
|
146
147
|
alive: bool = True
|
|
147
148
|
completion_started: bool = False
|
|
149
|
+
completion_success: bool = False
|
|
150
|
+
completion_congestion: bool = False
|
|
151
|
+
controller_released: bool = False
|
|
152
|
+
fixed_released: bool = False
|
|
148
153
|
|
|
149
154
|
@property
|
|
150
155
|
def ref(self) -> queue_pb2.LeaseRef:
|
|
@@ -170,18 +175,33 @@ class Worker:
|
|
|
170
175
|
handler: Callable[[Task, TaskContext], Awaitable[Any]],
|
|
171
176
|
*,
|
|
172
177
|
task_types: Iterable[str],
|
|
173
|
-
concurrency: int,
|
|
178
|
+
concurrency: int | ResourceConcurrency | AdaptiveConcurrency,
|
|
174
179
|
lease_duration: timedelta,
|
|
175
180
|
grace_period: timedelta,
|
|
176
181
|
on_idle: Callable[[], Any] | None = None,
|
|
182
|
+
weight: int = 1,
|
|
177
183
|
) -> None:
|
|
178
|
-
if concurrency
|
|
179
|
-
|
|
184
|
+
if isinstance(concurrency, bool) or not isinstance(
|
|
185
|
+
concurrency, (int, ResourceConcurrency, AdaptiveConcurrency)
|
|
186
|
+
):
|
|
187
|
+
raise ValueError(
|
|
188
|
+
"concurrency must be an integer, ResourceConcurrency, or AdaptiveConcurrency"
|
|
189
|
+
)
|
|
190
|
+
if isinstance(concurrency, int) and concurrency < 1:
|
|
191
|
+
raise ValueError("initial fixed concurrency must be >= 1")
|
|
192
|
+
if isinstance(weight, bool) or not isinstance(weight, int) or weight < 1:
|
|
193
|
+
raise ValueError("weight must be a positive integer")
|
|
180
194
|
self._queue = queue
|
|
181
195
|
self._client = queue._client
|
|
182
196
|
self._handler = handler
|
|
183
197
|
self._task_types = list(task_types)
|
|
184
|
-
self.
|
|
198
|
+
self._controller: ResourceConcurrency | AdaptiveConcurrency | None = (
|
|
199
|
+
concurrency if not isinstance(concurrency, int) else None
|
|
200
|
+
)
|
|
201
|
+
self._member: _Member | None = None
|
|
202
|
+
self._concurrency = concurrency if isinstance(concurrency, int) else max(1, concurrency.capacity)
|
|
203
|
+
self._configured_concurrency = self._concurrency
|
|
204
|
+
self._weight = weight
|
|
185
205
|
self._lease_duration = lease_duration
|
|
186
206
|
self._grace_period = max(0.0, grace_period.total_seconds())
|
|
187
207
|
self._worker_id = self._new_worker_id()
|
|
@@ -222,6 +242,24 @@ class Worker:
|
|
|
222
242
|
self._next_request_id = 1
|
|
223
243
|
self._pending: dict[int, asyncio.Future[queue_pb2.WorkResult]] = {}
|
|
224
244
|
self._inflight: dict[str, _Assignment] = {}
|
|
245
|
+
# Handler lifetime, not just lease-map membership, owns local capacity.
|
|
246
|
+
# Old-stream handlers that swallow cancellation remain here until their
|
|
247
|
+
# coroutine actually settles.
|
|
248
|
+
self._handler_tasks: set[asyncio.Task[Any]] = set()
|
|
249
|
+
self._fixed_running = 0
|
|
250
|
+
# Every capacity update shares one publisher mutex and a monotonically
|
|
251
|
+
# increasing session epoch. A late task from an old stream can never
|
|
252
|
+
# publish credit onto a replacement stream or after close.
|
|
253
|
+
self._publisher_lock = asyncio.Lock()
|
|
254
|
+
self._session_epoch = 0
|
|
255
|
+
self._publisher_closed = False
|
|
256
|
+
self._probe: tuple[_Member, int] | None = None
|
|
257
|
+
self._detached = False
|
|
258
|
+
self._controller_registered = False
|
|
259
|
+
self._fixed_dynamic_target = False
|
|
260
|
+
if self._controller is not None:
|
|
261
|
+
self._member = self._controller._attach(self, weight)
|
|
262
|
+
self._controller_registered = True
|
|
225
263
|
|
|
226
264
|
@staticmethod
|
|
227
265
|
def _new_worker_id() -> str:
|
|
@@ -236,8 +274,35 @@ class Worker:
|
|
|
236
274
|
|
|
237
275
|
@property
|
|
238
276
|
def concurrency(self) -> int:
|
|
277
|
+
"""Current fixed target, or the controller's current capacity."""
|
|
278
|
+
if self._controller is not None:
|
|
279
|
+
return self._controller.capacity
|
|
239
280
|
return self._concurrency
|
|
240
281
|
|
|
282
|
+
@property
|
|
283
|
+
def weight(self) -> int:
|
|
284
|
+
return self._weight
|
|
285
|
+
|
|
286
|
+
async def set_concurrency(self, concurrency: int) -> None:
|
|
287
|
+
"""Resize a fixed worker without cancelling running handlers."""
|
|
288
|
+
if self._controller is not None:
|
|
289
|
+
raise ValueError("a Worker attached to a shared controller cannot resize itself")
|
|
290
|
+
if isinstance(concurrency, bool) or not isinstance(concurrency, int):
|
|
291
|
+
raise ValueError("concurrency must be an integer")
|
|
292
|
+
if concurrency < 0 or concurrency > 2**31 - 1:
|
|
293
|
+
raise ValueError("concurrency must be between 0 and 2**31-1")
|
|
294
|
+
if self._run_started:
|
|
295
|
+
conn = self._client._require_conn()
|
|
296
|
+
if conn._session_minor < 2:
|
|
297
|
+
raise IncompatibleVersionError(
|
|
298
|
+
"runtime fixed concurrency requires protocol minor >= 2; "
|
|
299
|
+
f"negotiated minor is {conn._session_minor}"
|
|
300
|
+
)
|
|
301
|
+
self._concurrency = concurrency
|
|
302
|
+
self._fixed_dynamic_target = True
|
|
303
|
+
if self._session_done is not None and not self._session_done.is_set():
|
|
304
|
+
await self._publish_fixed_capacity()
|
|
305
|
+
|
|
241
306
|
async def run(self) -> None:
|
|
242
307
|
"""Run until :meth:`close` is called, reconnecting boundedly."""
|
|
243
308
|
if self._run_started:
|
|
@@ -245,6 +310,15 @@ class Worker:
|
|
|
245
310
|
self._run_started = True
|
|
246
311
|
failures = 0
|
|
247
312
|
try:
|
|
313
|
+
conn = self._client._require_conn()
|
|
314
|
+
if (
|
|
315
|
+
(self._controller is not None or self._fixed_dynamic_target)
|
|
316
|
+
and conn._session_minor < 2
|
|
317
|
+
):
|
|
318
|
+
raise IncompatibleVersionError(
|
|
319
|
+
"dynamic concurrency requires protocol minor >= 2; "
|
|
320
|
+
f"negotiated minor is {conn._session_minor}"
|
|
321
|
+
)
|
|
248
322
|
if self._on_idle is not None:
|
|
249
323
|
# GetQueueActivity arrived in protocol minor 1; fail fast
|
|
250
324
|
# instead of silently running without idle observation.
|
|
@@ -304,6 +378,9 @@ class Worker:
|
|
|
304
378
|
async with self._close_lock:
|
|
305
379
|
if self._close_task is None:
|
|
306
380
|
self._close_started.set()
|
|
381
|
+
# The controller must linearize detach and withdraw its probe
|
|
382
|
+
# before this gate closes publication; the close path uses the
|
|
383
|
+
# still-live stream to await that correlated WorkResult.
|
|
307
384
|
self._idle_candidate = False
|
|
308
385
|
self._idle_zero_since = None
|
|
309
386
|
self._idle_session_token = None
|
|
@@ -316,6 +393,11 @@ class Worker:
|
|
|
316
393
|
|
|
317
394
|
async def _close_impl(self) -> None:
|
|
318
395
|
try:
|
|
396
|
+
if self._controller is not None and self._member is not None:
|
|
397
|
+
await self._controller._detach(self._member)
|
|
398
|
+
self._detached = True
|
|
399
|
+
self._publisher_closed = True
|
|
400
|
+
self._session_epoch += 1
|
|
319
401
|
session = self._session_done
|
|
320
402
|
# Drain is best effort. A disconnected stream cannot safely report
|
|
321
403
|
# any terminal result, and leases are deliberately left to expire.
|
|
@@ -357,7 +439,7 @@ class Worker:
|
|
|
357
439
|
self._close_finished.set()
|
|
358
440
|
|
|
359
441
|
async def _wait_handlers(self) -> None:
|
|
360
|
-
tasks =
|
|
442
|
+
tasks = list(self._handler_tasks)
|
|
361
443
|
if tasks:
|
|
362
444
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
363
445
|
|
|
@@ -368,21 +450,23 @@ class Worker:
|
|
|
368
450
|
self._idle_session_token = None
|
|
369
451
|
assignments = list(self._inflight.values())
|
|
370
452
|
self._inflight.clear()
|
|
453
|
+
renewals: list[asyncio.Task[Any]] = []
|
|
371
454
|
for assignment in assignments:
|
|
372
455
|
self._invalidate_assignment(assignment)
|
|
373
|
-
|
|
456
|
+
handler = assignment.handler_task
|
|
457
|
+
if handler is not None and not handler.done():
|
|
458
|
+
# Cancellation is advisory. Never let a handler that catches
|
|
459
|
+
# CancelledError block reconnect or forced close; its local
|
|
460
|
+
# capacity remains occupied until its coroutine really ends.
|
|
461
|
+
handler.cancel()
|
|
462
|
+
renewal = assignment.renew_task
|
|
463
|
+
if renewal is not None and not renewal.done():
|
|
464
|
+
renewal.cancel()
|
|
465
|
+
renewals.append(renewal)
|
|
466
|
+
if renewals:
|
|
467
|
+
await asyncio.gather(*renewals, return_exceptions=True)
|
|
374
468
|
for assignment in assignments:
|
|
375
|
-
|
|
376
|
-
assignment.handler_task is not None
|
|
377
|
-
and not assignment.handler_task.done()
|
|
378
|
-
):
|
|
379
|
-
assignment.handler_task.cancel()
|
|
380
|
-
tasks.append(assignment.handler_task)
|
|
381
|
-
if assignment.renew_task is not None and not assignment.renew_task.done():
|
|
382
|
-
assignment.renew_task.cancel()
|
|
383
|
-
tasks.append(assignment.renew_task)
|
|
384
|
-
if tasks:
|
|
385
|
-
await asyncio.gather(*tasks, return_exceptions=True)
|
|
469
|
+
await self._release_capacity_if_settled(assignment)
|
|
386
470
|
|
|
387
471
|
def _invalidate_assignment(self, assignment: _Assignment) -> None:
|
|
388
472
|
if not assignment.alive:
|
|
@@ -391,6 +475,49 @@ class Worker:
|
|
|
391
475
|
if assignment.context is not None:
|
|
392
476
|
assignment.context._cancel()
|
|
393
477
|
|
|
478
|
+
async def _release_controller_permit(
|
|
479
|
+
self,
|
|
480
|
+
assignment: _Assignment,
|
|
481
|
+
*,
|
|
482
|
+
success: bool = False,
|
|
483
|
+
congestion: bool = False,
|
|
484
|
+
) -> None:
|
|
485
|
+
if (
|
|
486
|
+
self._controller is None
|
|
487
|
+
or self._member is None
|
|
488
|
+
or assignment.controller_released
|
|
489
|
+
):
|
|
490
|
+
return
|
|
491
|
+
assignment.controller_released = True
|
|
492
|
+
await self._controller._release_running(
|
|
493
|
+
self._member, success=success, congestion=congestion
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
async def _release_capacity_if_settled(
|
|
497
|
+
self, assignment: _Assignment, *, handler_settling: bool = False
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Release capacity only as the application handler settles."""
|
|
500
|
+
if assignment.alive:
|
|
501
|
+
return
|
|
502
|
+
handler = assignment.handler_task
|
|
503
|
+
current = asyncio.current_task()
|
|
504
|
+
if handler is not None and not handler.done():
|
|
505
|
+
if handler is not current or not handler_settling:
|
|
506
|
+
return
|
|
507
|
+
if self._controller is not None:
|
|
508
|
+
await self._release_controller_permit(
|
|
509
|
+
assignment,
|
|
510
|
+
success=assignment.completion_success,
|
|
511
|
+
congestion=assignment.completion_congestion,
|
|
512
|
+
)
|
|
513
|
+
return
|
|
514
|
+
if assignment.fixed_released:
|
|
515
|
+
return
|
|
516
|
+
assignment.fixed_released = True
|
|
517
|
+
self._fixed_running = max(0, self._fixed_running - 1)
|
|
518
|
+
if not self._close_started.is_set():
|
|
519
|
+
await self._publish_fixed_capacity()
|
|
520
|
+
|
|
394
521
|
async def _run_session_monitored(self) -> None:
|
|
395
522
|
"""Run one Work session, observing queue activity when on_idle is set.
|
|
396
523
|
|
|
@@ -437,7 +564,7 @@ class Worker:
|
|
|
437
564
|
):
|
|
438
565
|
return False
|
|
439
566
|
# Local assignments make the queue non-idle for this worker.
|
|
440
|
-
if self._inflight:
|
|
567
|
+
if self._inflight or self._handler_tasks:
|
|
441
568
|
self._idle_zero_since = None
|
|
442
569
|
return False
|
|
443
570
|
session = self._session_done
|
|
@@ -491,6 +618,7 @@ class Worker:
|
|
|
491
618
|
# query in-flight window cannot produce a false trigger.
|
|
492
619
|
if (
|
|
493
620
|
self._inflight
|
|
621
|
+
or self._handler_tasks
|
|
494
622
|
or self._close_started.is_set()
|
|
495
623
|
or self._force_cancel.is_set()
|
|
496
624
|
):
|
|
@@ -525,6 +653,7 @@ class Worker:
|
|
|
525
653
|
|
|
526
654
|
async def _run_session(self) -> None:
|
|
527
655
|
conn = self._client._require_conn()
|
|
656
|
+
self._session_epoch += 1
|
|
528
657
|
# A new session invalidates any in-flight idle observation.
|
|
529
658
|
self._idle_candidate = False
|
|
530
659
|
self._idle_zero_since = None
|
|
@@ -545,7 +674,7 @@ class Worker:
|
|
|
545
674
|
queue=self._queue.name,
|
|
546
675
|
worker_id=self._worker_id,
|
|
547
676
|
task_types=self._task_types,
|
|
548
|
-
max_concurrency=self._concurrency,
|
|
677
|
+
max_concurrency=max(1, self._concurrency),
|
|
549
678
|
)
|
|
550
679
|
if self._lease_duration > timedelta(0):
|
|
551
680
|
reg.lease_duration.CopyFrom(_pb.td_to_dur(self._lease_duration))
|
|
@@ -571,7 +700,11 @@ class Worker:
|
|
|
571
700
|
if self._force_cancel.is_set():
|
|
572
701
|
return
|
|
573
702
|
# Initial credit is sent only after WorkerRegistered was observed.
|
|
574
|
-
|
|
703
|
+
if self._controller is None:
|
|
704
|
+
await self._send_credit(self._concurrency)
|
|
705
|
+
else:
|
|
706
|
+
self._controller._check_loop()
|
|
707
|
+
await self._controller._start_member(self._member)
|
|
575
708
|
if self._on_idle is not None and not self._close_started.is_set():
|
|
576
709
|
# Eligible only from the first/current WorkerRegistered plus
|
|
577
710
|
# a successful initial credit of this session. The candidate
|
|
@@ -628,6 +761,8 @@ class Worker:
|
|
|
628
761
|
if not future.done():
|
|
629
762
|
future.set_exception(UnavailableError("Work stream closed"))
|
|
630
763
|
self._pending.clear()
|
|
764
|
+
if self._controller is not None and self._member is not None:
|
|
765
|
+
await self._controller._stream_lost(self._member)
|
|
631
766
|
|
|
632
767
|
async def _enqueue_frame(self, frame: queue_pb2.WorkRequest) -> None:
|
|
633
768
|
outbox = self._outbox
|
|
@@ -666,7 +801,11 @@ class Worker:
|
|
|
666
801
|
_pb.dur_to_td(registered.lease_duration)
|
|
667
802
|
or self._lease_duration
|
|
668
803
|
)
|
|
669
|
-
if
|
|
804
|
+
if (
|
|
805
|
+
self._controller is None
|
|
806
|
+
and not self._fixed_dynamic_target
|
|
807
|
+
and registered.max_concurrency
|
|
808
|
+
):
|
|
670
809
|
self._concurrency = registered.max_concurrency
|
|
671
810
|
if self._registered is not None:
|
|
672
811
|
self._registered.set()
|
|
@@ -722,13 +861,24 @@ class Worker:
|
|
|
722
861
|
# existing assignment if a malformed peer sends a duplicate.
|
|
723
862
|
if task.id in self._inflight:
|
|
724
863
|
return
|
|
864
|
+
if self._controller is not None and self._member is not None:
|
|
865
|
+
# Consuming the reserved probe is the linearization point at which
|
|
866
|
+
# an assignment becomes a running controller permit.
|
|
867
|
+
if not await self._controller._probe_consumed(self._member):
|
|
868
|
+
# A withdrawn probe must not become a new handler. The lease
|
|
869
|
+
# remains server-owned and will naturally expire if this was a
|
|
870
|
+
# transport race.
|
|
871
|
+
return
|
|
725
872
|
self._inflight[task.id] = assignment
|
|
873
|
+
if self._controller is None:
|
|
874
|
+
self._fixed_running += 1
|
|
726
875
|
assignment.renew_task = asyncio.create_task(
|
|
727
876
|
self._renew_loop(assignment), name=f"ringo-renew-{task.id}"
|
|
728
877
|
)
|
|
729
878
|
assignment.handler_task = asyncio.create_task(
|
|
730
879
|
self._handle_assignment(assignment), name=f"ringo-handler-{task.id}"
|
|
731
880
|
)
|
|
881
|
+
self._handler_tasks.add(assignment.handler_task)
|
|
732
882
|
assignment.handler_task.add_done_callback(_consume_task_exception)
|
|
733
883
|
|
|
734
884
|
async def _handle_assignment(self, assignment: _Assignment) -> None:
|
|
@@ -753,6 +903,13 @@ class Worker:
|
|
|
753
903
|
except Exception as exc:
|
|
754
904
|
if assignment.alive and not self._force_cancel.is_set():
|
|
755
905
|
await self._finish(assignment, "nack", exc)
|
|
906
|
+
finally:
|
|
907
|
+
current = asyncio.current_task()
|
|
908
|
+
if current is not None:
|
|
909
|
+
self._handler_tasks.discard(current)
|
|
910
|
+
await self._release_capacity_if_settled(
|
|
911
|
+
assignment, handler_settling=True
|
|
912
|
+
)
|
|
756
913
|
|
|
757
914
|
async def _finish(
|
|
758
915
|
self,
|
|
@@ -811,15 +968,26 @@ class Worker:
|
|
|
811
968
|
# protocol; leave the assignment fenced until the stream/server does.
|
|
812
969
|
assignment.completion_started = False
|
|
813
970
|
return
|
|
814
|
-
|
|
815
|
-
await self.
|
|
971
|
+
congestion = isinstance(error, RetryTaskError) and error.congestion
|
|
972
|
+
await self._release_assignment(
|
|
973
|
+
assignment,
|
|
974
|
+
success=operation == "ack",
|
|
975
|
+
congestion=congestion,
|
|
976
|
+
)
|
|
816
977
|
|
|
817
978
|
async def _release_assignment(
|
|
818
|
-
self,
|
|
979
|
+
self,
|
|
980
|
+
assignment: _Assignment,
|
|
981
|
+
*,
|
|
982
|
+
cancel_context: bool = False,
|
|
983
|
+
success: bool = False,
|
|
984
|
+
congestion: bool = False,
|
|
819
985
|
) -> None:
|
|
820
986
|
if not assignment.alive:
|
|
821
987
|
return
|
|
822
988
|
assignment.alive = False
|
|
989
|
+
assignment.completion_success = success
|
|
990
|
+
assignment.completion_congestion = congestion
|
|
823
991
|
self._inflight.pop(assignment.task.id, None)
|
|
824
992
|
if cancel_context and assignment.context is not None:
|
|
825
993
|
assignment.context._cancel()
|
|
@@ -829,21 +997,22 @@ class Worker:
|
|
|
829
997
|
):
|
|
830
998
|
assignment.renew_task.cancel()
|
|
831
999
|
await asyncio.gather(assignment.renew_task, return_exceptions=True)
|
|
1000
|
+
await self._release_capacity_if_settled(assignment)
|
|
832
1001
|
|
|
833
1002
|
async def _lease_lost(self, assignment: _Assignment) -> None:
|
|
834
1003
|
if not assignment.alive:
|
|
835
1004
|
return
|
|
836
1005
|
await self._release_assignment(assignment, cancel_context=True)
|
|
837
|
-
|
|
1006
|
+
handler = assignment.handler_task
|
|
838
1007
|
if (
|
|
839
|
-
|
|
840
|
-
and
|
|
841
|
-
and not
|
|
1008
|
+
handler is not None
|
|
1009
|
+
and handler is not asyncio.current_task()
|
|
1010
|
+
and not handler.done()
|
|
842
1011
|
):
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1012
|
+
# Do not await an application coroutine that may intentionally
|
|
1013
|
+
# swallow cancellation. Its capacity is released by its finally.
|
|
1014
|
+
handler.cancel()
|
|
1015
|
+
await self._release_capacity_if_settled(assignment)
|
|
847
1016
|
|
|
848
1017
|
async def _renew_loop(self, assignment: _Assignment) -> None:
|
|
849
1018
|
while assignment.alive and not self._force_cancel.is_set():
|
|
@@ -905,35 +1074,169 @@ class Worker:
|
|
|
905
1074
|
raise error_from_detail(detail)
|
|
906
1075
|
|
|
907
1076
|
async def _send_credit(self, available: int) -> None:
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1077
|
+
# ``available`` is intentionally not trusted across retries. A task
|
|
1078
|
+
# may arrive between a rejected snapshot and its retry, so every
|
|
1079
|
+
# credit-capacity retry samples local in-flight state again.
|
|
1080
|
+
await self._publish_fixed_capacity()
|
|
1081
|
+
|
|
1082
|
+
async def _publish_fixed_capacity(self) -> None:
|
|
1083
|
+
async with self._publisher_lock:
|
|
1084
|
+
epoch = self._session_epoch
|
|
1085
|
+
for attempt in range(20):
|
|
1086
|
+
if (
|
|
1087
|
+
self._publisher_closed
|
|
1088
|
+
or self._close_started.is_set()
|
|
1089
|
+
or epoch != self._session_epoch
|
|
1090
|
+
or self._session_done is None
|
|
1091
|
+
or self._session_done.is_set()
|
|
1092
|
+
):
|
|
1093
|
+
return
|
|
1094
|
+
available = max(0, self._concurrency - self._fixed_running)
|
|
1095
|
+
update = queue_pb2.CreditUpdate(available=available)
|
|
1096
|
+
if self._fixed_dynamic_target:
|
|
1097
|
+
update.target_concurrency = self._concurrency
|
|
1098
|
+
result = await self._send_command(
|
|
1099
|
+
queue_pb2.WorkRequest(credit=update), wait=True
|
|
1100
|
+
)
|
|
1101
|
+
if (
|
|
1102
|
+
result is None
|
|
1103
|
+
or not result.HasField("error")
|
|
1104
|
+
or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
|
|
1105
|
+
):
|
|
1106
|
+
return
|
|
1107
|
+
if (
|
|
1108
|
+
result.error.metadata.get("reason") != "credit_capacity"
|
|
1109
|
+
or attempt == 19
|
|
1110
|
+
):
|
|
1111
|
+
raise error_from_detail(result.error)
|
|
1112
|
+
await asyncio.sleep(0.01)
|
|
1113
|
+
|
|
1114
|
+
async def _publish_controller_initial(self, capacity: int) -> None:
|
|
1115
|
+
async with self._publisher_lock:
|
|
1116
|
+
if (
|
|
1117
|
+
self._publisher_closed
|
|
1118
|
+
or self._close_started.is_set()
|
|
1119
|
+
or self._session_done is None
|
|
1120
|
+
or self._session_done.is_set()
|
|
1121
|
+
):
|
|
1122
|
+
raise UnavailableError("Work stream is not connected")
|
|
1123
|
+
ok = await self._publish_dynamic_locked(capacity, 0)
|
|
1124
|
+
if not ok:
|
|
1125
|
+
raise UnavailableError("initial dynamic credit was not accepted")
|
|
1126
|
+
|
|
1127
|
+
async def _publish_controller_probe(
|
|
1128
|
+
self, member: _Member, capacity: int, generation: int
|
|
1129
|
+
) -> None:
|
|
1130
|
+
async with self._publisher_lock:
|
|
1131
|
+
if (
|
|
1132
|
+
self._publisher_closed
|
|
1133
|
+
or self._close_started.is_set()
|
|
1134
|
+
or self._member is not member
|
|
1135
|
+
or not member.probe
|
|
1136
|
+
or member.probe_generation != generation
|
|
1137
|
+
or self._session_done is None
|
|
1138
|
+
or self._session_done.is_set()
|
|
1139
|
+
):
|
|
1140
|
+
return
|
|
1141
|
+
await self._publish_dynamic_locked(
|
|
1142
|
+
capacity,
|
|
1143
|
+
1,
|
|
1144
|
+
probe_member=member,
|
|
1145
|
+
probe_generation=generation,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
async def _publish_controller_target(self, capacity: int) -> bool:
|
|
1149
|
+
"""Publish target/zero credit for a live shared-controller stream."""
|
|
1150
|
+
async with self._publisher_lock:
|
|
1151
|
+
if self._publisher_closed:
|
|
1152
|
+
return True
|
|
1153
|
+
if self._session_done is None or self._session_done.is_set():
|
|
1154
|
+
return True
|
|
1155
|
+
return await self._publish_dynamic_locked(capacity, 0)
|
|
1156
|
+
|
|
1157
|
+
async def _withdraw_controller_probe(
|
|
1158
|
+
self,
|
|
1159
|
+
member: _Member,
|
|
1160
|
+
capacity: int,
|
|
1161
|
+
*,
|
|
1162
|
+
force_target: bool = False,
|
|
1163
|
+
) -> bool:
|
|
1164
|
+
async with self._publisher_lock:
|
|
1165
|
+
if self._publisher_closed:
|
|
1166
|
+
return True
|
|
1167
|
+
if self._session_done is None or self._session_done.is_set():
|
|
1168
|
+
# The old stream is definitively gone; its credit no longer
|
|
1169
|
+
# exists server-side and the controller may reclaim the token.
|
|
1170
|
+
return True
|
|
1171
|
+
return await self._publish_dynamic_locked(
|
|
1172
|
+
capacity,
|
|
1173
|
+
0,
|
|
1174
|
+
allow_closing=True,
|
|
1175
|
+
explicit_target=force_target,
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
async def _publish_dynamic_locked(
|
|
1179
|
+
self,
|
|
1180
|
+
target: int,
|
|
1181
|
+
available: int,
|
|
1182
|
+
*,
|
|
1183
|
+
allow_closing: bool = False,
|
|
1184
|
+
explicit_target: bool = False,
|
|
1185
|
+
probe_member: _Member | None = None,
|
|
1186
|
+
probe_generation: int = 0,
|
|
1187
|
+
) -> bool:
|
|
1188
|
+
epoch = self._session_epoch
|
|
920
1189
|
for attempt in range(20):
|
|
921
|
-
|
|
922
|
-
|
|
1190
|
+
if (
|
|
1191
|
+
self._publisher_closed
|
|
1192
|
+
or (self._close_started.is_set() and not allow_closing)
|
|
1193
|
+
or epoch != self._session_epoch
|
|
1194
|
+
or self._session_done is None
|
|
1195
|
+
or self._session_done.is_set()
|
|
1196
|
+
):
|
|
1197
|
+
return self._session_done is None or self._session_done.is_set()
|
|
1198
|
+
if probe_member is not None and (
|
|
1199
|
+
not probe_member.probe
|
|
1200
|
+
or probe_member.probe_generation != probe_generation
|
|
1201
|
+
):
|
|
1202
|
+
# The assignment consumed this probe while a previous
|
|
1203
|
+
# capacity update was in flight; never replay available=1.
|
|
1204
|
+
return True
|
|
1205
|
+
# target and available are one atomic protocol snapshot. A
|
|
1206
|
+
# credit_capacity rejection must be retried with a fresh target /
|
|
1207
|
+
# in-flight sample, never by replaying the old snapshot.
|
|
1208
|
+
current_target = (
|
|
1209
|
+
self._controller.capacity
|
|
1210
|
+
if self._controller is not None and not explicit_target
|
|
1211
|
+
else target
|
|
1212
|
+
)
|
|
1213
|
+
current_available = available
|
|
1214
|
+
if self._controller is None:
|
|
1215
|
+
# An assignment may have been sent by the server before the
|
|
1216
|
+
# rejected credit result reached this reader. Re-sample the
|
|
1217
|
+
# now-materialized local assignments instead of replaying the
|
|
1218
|
+
# stale fixed-capacity snapshot.
|
|
1219
|
+
current_available = max(0, current_target - self._fixed_running)
|
|
1220
|
+
update = queue_pb2.CreditUpdate(
|
|
1221
|
+
available=max(0, current_available),
|
|
1222
|
+
target_concurrency=current_target,
|
|
1223
|
+
)
|
|
1224
|
+
result = await self._send_command(
|
|
1225
|
+
queue_pb2.WorkRequest(credit=update), wait=True
|
|
923
1226
|
)
|
|
924
|
-
result = await self._send_command(request, wait=True)
|
|
925
1227
|
if (
|
|
926
1228
|
result is None
|
|
927
1229
|
or not result.HasField("error")
|
|
928
1230
|
or result.error.code == queue_pb2.ERROR_CODE_UNSPECIFIED
|
|
929
1231
|
):
|
|
930
|
-
return
|
|
1232
|
+
return True
|
|
931
1233
|
if (
|
|
932
1234
|
result.error.metadata.get("reason") != "credit_capacity"
|
|
933
1235
|
or attempt == 19
|
|
934
1236
|
):
|
|
935
1237
|
raise error_from_detail(result.error)
|
|
936
1238
|
await asyncio.sleep(0.01)
|
|
1239
|
+
return False
|
|
937
1240
|
|
|
938
1241
|
def _allocate_request_id(self) -> int:
|
|
939
1242
|
rid = self._next_request_id
|