dex-python-sdk 1.14.0__tar.gz → 1.15.0__tar.gz

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.
Files changed (70) hide show
  1. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/PKG-INFO +42 -6
  2. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/README.md +41 -5
  3. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_pull_worker.py +74 -19
  4. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/async_worker.py +17 -0
  5. dex_python_sdk-1.15.0/dex/dexpb/dex_pb2.py +549 -0
  6. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/dexpb/dex_pb2.pyi +73 -26
  7. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/worker.py +17 -0
  8. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/worker_options.py +29 -2
  9. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/pyproject.toml +1 -1
  10. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/Cargo.lock +1 -0
  11. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/Cargo.toml +1 -0
  12. dex_python_sdk-1.14.0/dex/dexpb/dex_pb2.py +0 -545
  13. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/LEGACY_NOTICES.md +0 -0
  14. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/LICENSE +0 -0
  15. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/__init__.py +0 -0
  16. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_async_value_hydrator.py +0 -0
  17. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_async_worker_dispatcher.py +0 -0
  18. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_async_worker_service.py +0 -0
  19. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_credential_transport.py +0 -0
  20. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_grpc_errors.py +0 -0
  21. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_invocation_context.py +0 -0
  22. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_native.pyi +0 -0
  23. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_server_protocol.py +0 -0
  24. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_utils.py +0 -0
  25. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_value_hydrator.py +0 -0
  26. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_value_mapper.py +0 -0
  27. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_worker_dispatcher.py +0 -0
  28. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/_worker_service.py +0 -0
  29. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/async_client.py +0 -0
  30. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/attribute.py +0 -0
  31. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/attribute_match.py +0 -0
  32. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/blob_cache.py +0 -0
  33. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/channel.py +0 -0
  34. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/client.py +0 -0
  35. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/client_options.py +0 -0
  36. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/codec.py +0 -0
  37. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/condition.py +0 -0
  38. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/context.py +0 -0
  39. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/credential.py +0 -0
  40. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/dexpb/__init__.py +0 -0
  41. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/dexpb/dex_pb2_grpc.py +0 -0
  42. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/flow.py +0 -0
  43. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/flow_config.py +0 -0
  44. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/flow_info.py +0 -0
  45. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/flow_options.py +0 -0
  46. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/flow_result.py +0 -0
  47. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/pull_registration.py +0 -0
  48. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/py.typed +0 -0
  49. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/rpc_invoke_options.py +0 -0
  50. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/runtime_errors.py +0 -0
  51. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/step.py +0 -0
  52. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/step_execution.py +0 -0
  53. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/stream.py +0 -0
  54. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/subflow.py +0 -0
  55. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/timer.py +0 -0
  56. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/wait.py +0 -0
  57. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/dex/wait_options.py +0 -0
  58. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  59. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  60. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  61. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  62. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  63. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  64. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  65. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  66. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  67. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  68. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  69. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  70. {dex_python_sdk-1.14.0 → dex_python_sdk-1.15.0}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dex-python-sdk
3
- Version: 1.14.0
3
+ Version: 1.15.0
4
4
  Requires-Dist: grpcio>=1.83.0
5
5
  Requires-Dist: grpcio-status>=1.83.0
6
6
  Requires-Dist: protobuf>=7.35.1
@@ -94,11 +94,13 @@ The Worker polls its sticky queue and every registered Flow type queue. Flow
94
94
  type names must be unique within a Dex server. `max_concurrent_tasks` defaults
95
95
  to the CPU count, and `idle_streams_per_queue` defaults to one. At capacity,
96
96
  the Worker rejects an assignment so Dex can reassign it. Each stream runs one
97
- task at a time; Step heartbeat and stream writes use the same stream. A Worker
98
- may hold up to `(1 + number of Flow types) * (idle_streams_per_queue +
99
- max_concurrent_tasks)` streams because busy streams are replaced with idle
100
- streams and retained after tasks finish. Size these options for the server's
101
- per-Worker stream cap (256 by default).
97
+ task at a time; Step heartbeat and stream writes use the same stream. After tasks finish, the Worker
98
+ gracefully closes surplus idle streams while preserving readiness. After load drops,
99
+ the steady-state bound is `max_concurrent_tasks + (1 + number of Flow types) *
100
+ idle_streams_per_queue` streams. The transient peak during a burst remains bounded
101
+ by the matching caps. Size these options for `matching.maxConcurrentTasksCap` per
102
+ app Worker ID per matching owner (256 by default) and `matching.maxStreams` per
103
+ Matching instance (100,000 by default).
102
104
 
103
105
  The Worker rejects an unregistered Step or RPC by name. Dex retries that task
104
106
  on another pull Worker without consuming a Step attempt, including when the
@@ -155,6 +157,40 @@ applies before and after readiness. Another registered stream for that queue
155
157
  prevents shutdown. The default is False and retries indefinitely. Always call
156
158
  `stop()` or `close()` afterward; both remain safe after `start()` returns.
157
159
 
160
+ ### Reserved RPC capacity
161
+
162
+ Set `reserved_rpc_tasks` when synchronous RPC latency must stay responsive
163
+ behind long-running Steps. When unset, it reserves one slot if the resolved task
164
+ limit is at least two, otherwise zero. With the CPU-count default, Steps get one
165
+ fewer slot than before. Set it explicitly to zero to opt out. With one slot and
166
+ no explicit reserve, Worker start logs one warning: RPCs may wait behind
167
+ long-running Steps; use a limit of at least two or set the reserve explicitly.
168
+ Flows must stay responsive; Dex cannot prioritize synchronous RPCs like workflow
169
+ engines prioritize reads. The value must be nonnegative and less than
170
+ `max_concurrent_tasks` after its CPU-count default is resolved; invalid values
171
+ fail construction. RPC and Step handlers count together: WaitFor and Execute
172
+ tasks, including local attempts, are declined with `NO_CAPACITY` when the busy
173
+ count reaches the task limit minus the reserve. API-direct and transactional
174
+ InvokeWorkerRPC tasks can use the full limit.
175
+
176
+ ```python
177
+ pull = PullWorkerOptions(max_concurrent_tasks=8, reserved_rpc_tasks=2)
178
+ ```
179
+
180
+ A decline lets Dex try another Worker without consuming an attempt. If all
181
+ Workers remain at their Step limit, a Step can wait until the dispatch wait
182
+ expires, then receive `NO_WORKER_AVAILABLE`, consume an attempt, and follow its
183
+ retry policy, as when all Workers are full. Local attempts wait the local
184
+ dispatch wait, then fall back according to their policy. Each queue still keeps
185
+ its configured idle streams; a reserve decline does not close a stream or change
186
+ registration readiness.
187
+
188
+ Each reserve decline records the Flow type through the dex._pull_worker logger
189
+ at DEBUG level. Export the cumulative `pull_reserved_rpc_declines` Worker value
190
+ to distinguish reserve pressure from full-capacity rejections. The count
191
+ excludes full-capacity rejections and unknown names, never resets, and remains
192
+ available after shutdown. Listening Workers return zero.
193
+
158
194
  ## Flow state I/O
159
195
 
160
196
  Applications read and write Flow state through typed RPCs. Client and AsyncClient
@@ -78,11 +78,13 @@ The Worker polls its sticky queue and every registered Flow type queue. Flow
78
78
  type names must be unique within a Dex server. `max_concurrent_tasks` defaults
79
79
  to the CPU count, and `idle_streams_per_queue` defaults to one. At capacity,
80
80
  the Worker rejects an assignment so Dex can reassign it. Each stream runs one
81
- task at a time; Step heartbeat and stream writes use the same stream. A Worker
82
- may hold up to `(1 + number of Flow types) * (idle_streams_per_queue +
83
- max_concurrent_tasks)` streams because busy streams are replaced with idle
84
- streams and retained after tasks finish. Size these options for the server's
85
- per-Worker stream cap (256 by default).
81
+ task at a time; Step heartbeat and stream writes use the same stream. After tasks finish, the Worker
82
+ gracefully closes surplus idle streams while preserving readiness. After load drops,
83
+ the steady-state bound is `max_concurrent_tasks + (1 + number of Flow types) *
84
+ idle_streams_per_queue` streams. The transient peak during a burst remains bounded
85
+ by the matching caps. Size these options for `matching.maxConcurrentTasksCap` per
86
+ app Worker ID per matching owner (256 by default) and `matching.maxStreams` per
87
+ Matching instance (100,000 by default).
86
88
 
87
89
  The Worker rejects an unregistered Step or RPC by name. Dex retries that task
88
90
  on another pull Worker without consuming a Step attempt, including when the
@@ -139,6 +141,40 @@ applies before and after readiness. Another registered stream for that queue
139
141
  prevents shutdown. The default is False and retries indefinitely. Always call
140
142
  `stop()` or `close()` afterward; both remain safe after `start()` returns.
141
143
 
144
+ ### Reserved RPC capacity
145
+
146
+ Set `reserved_rpc_tasks` when synchronous RPC latency must stay responsive
147
+ behind long-running Steps. When unset, it reserves one slot if the resolved task
148
+ limit is at least two, otherwise zero. With the CPU-count default, Steps get one
149
+ fewer slot than before. Set it explicitly to zero to opt out. With one slot and
150
+ no explicit reserve, Worker start logs one warning: RPCs may wait behind
151
+ long-running Steps; use a limit of at least two or set the reserve explicitly.
152
+ Flows must stay responsive; Dex cannot prioritize synchronous RPCs like workflow
153
+ engines prioritize reads. The value must be nonnegative and less than
154
+ `max_concurrent_tasks` after its CPU-count default is resolved; invalid values
155
+ fail construction. RPC and Step handlers count together: WaitFor and Execute
156
+ tasks, including local attempts, are declined with `NO_CAPACITY` when the busy
157
+ count reaches the task limit minus the reserve. API-direct and transactional
158
+ InvokeWorkerRPC tasks can use the full limit.
159
+
160
+ ```python
161
+ pull = PullWorkerOptions(max_concurrent_tasks=8, reserved_rpc_tasks=2)
162
+ ```
163
+
164
+ A decline lets Dex try another Worker without consuming an attempt. If all
165
+ Workers remain at their Step limit, a Step can wait until the dispatch wait
166
+ expires, then receive `NO_WORKER_AVAILABLE`, consume an attempt, and follow its
167
+ retry policy, as when all Workers are full. Local attempts wait the local
168
+ dispatch wait, then fall back according to their policy. Each queue still keeps
169
+ its configured idle streams; a reserve decline does not close a stream or change
170
+ registration readiness.
171
+
172
+ Each reserve decline records the Flow type through the dex._pull_worker logger
173
+ at DEBUG level. Export the cumulative `pull_reserved_rpc_declines` Worker value
174
+ to distinguish reserve pressure from full-capacity rejections. The count
175
+ excludes full-capacity rejections and unknown names, never resets, and remains
176
+ available after shutdown. Listening Workers return zero.
177
+
142
178
  ## Flow state I/O
143
179
 
144
180
  Applications read and write Flow state through typed RPCs. Client and AsyncClient
@@ -94,6 +94,15 @@ class PullWorkerRuntime:
94
94
  self._permanent_error: PullRegistrationError | None = None
95
95
  self._rejection_stop_task: asyncio.Task[None] | None = None
96
96
  self.max_concurrent_tasks = options.max_concurrent_tasks or os.cpu_count() or 1
97
+ self.reserved_rpc_tasks = (
98
+ options.reserved_rpc_tasks
99
+ if options.reserved_rpc_tasks is not None
100
+ else int(self.max_concurrent_tasks >= 2)
101
+ )
102
+ self._should_warn_rpc_capacity = (
103
+ options.reserved_rpc_tasks is None and self.max_concurrent_tasks == 1
104
+ )
105
+ self.reserved_rpc_declines = 0
97
106
  self.idle_streams_per_queue = options.idle_streams_per_queue or 1
98
107
  self.stop_drain_timeout = options.stop_drain_timeout.total_seconds() or 30
99
108
  self._dispatcher = dispatcher
@@ -132,6 +141,10 @@ class PullWorkerRuntime:
132
141
  return None
133
142
 
134
143
  async def run(self) -> None:
144
+ if self._should_warn_rpc_capacity:
145
+ _LOGGER.warning(
146
+ "RPCs may wait behind long-running Steps; use max_concurrent_tasks >= 2 or set reserved_rpc_tasks explicitly"
147
+ )
135
148
  self._loop = asyncio.get_running_loop()
136
149
  self._loop_thread_id = threading.get_ident()
137
150
  self._wake = asyncio.Event()
@@ -170,6 +183,8 @@ class PullWorkerRuntime:
170
183
  def _reconcile(self, stub: Any) -> None:
171
184
  if self._stopping is None or self._stopping.is_set():
172
185
  return
186
+ for stream in tuple(self._streams):
187
+ stream.try_retire()
173
188
  for queue in self.queues:
174
189
  idle = sum(
175
190
  stream.queue == queue
@@ -191,11 +206,24 @@ class PullWorkerRuntime:
191
206
  _LOGGER.error("Worker task stream failed", exc_info=failure)
192
207
  self.signal()
193
208
 
194
- def accept_task(self) -> bool:
209
+ def accept_task(self, task: pb.PollWorkerTask | None = None) -> bool:
195
210
  if self._stopping is None or self._stopping.is_set():
196
211
  return False
197
212
  if self._busy_tasks >= self.max_concurrent_tasks:
198
213
  return False
214
+ if (
215
+ task is not None
216
+ and task.WhichOneof("request") in ("wait_for", "execute")
217
+ and self.reserved_rpc_tasks > 0
218
+ and self._busy_tasks >= self.max_concurrent_tasks - self.reserved_rpc_tasks
219
+ and self.unknown_task_reason(task) is None
220
+ ):
221
+ self.reserved_rpc_declines += 1
222
+ request = task.wait_for if task.HasField("wait_for") else task.execute
223
+ _LOGGER.debug(
224
+ "decline Step task for reserved RPC capacity: %s", request.flow_type
225
+ )
226
+ return False
199
227
  self._busy_tasks += 1
200
228
  self.signal()
201
229
  return True
@@ -279,6 +307,8 @@ class PullStream:
279
307
  self.is_busy = False
280
308
  self._is_registered = False
281
309
  self.is_draining = False
310
+ self._is_retiring = False
311
+ self._retirement_requested = asyncio.Event()
282
312
  self.is_half_closing = False
283
313
  self._active = threading.Event()
284
314
  self._call: Any = None
@@ -321,7 +351,11 @@ class PullStream:
321
351
  self.runtime._rejection_stop_task = asyncio.create_task(
322
352
  self.runtime.stop()
323
353
  )
324
- if self.runtime.is_stopping or self.runtime._permanent_error is not None:
354
+ if (
355
+ self.runtime.is_stopping
356
+ or self.runtime._permanent_error is not None
357
+ or self._is_retiring
358
+ ):
325
359
  break
326
360
  if self._is_reconnecting:
327
361
  self._is_reconnecting = False
@@ -378,11 +412,12 @@ class PullStream:
378
412
  )
379
413
  was_registered = False
380
414
  reader: asyncio.Task[Any] | None = None
415
+ retirement = asyncio.create_task(self._retirement_requested.wait())
381
416
  try:
382
417
  while not self.runtime.is_stopping or self.is_busy:
383
418
  if reader is None:
384
419
  reader = asyncio.create_task(self._call.read())
385
- waiting: set[asyncio.Task[Any]] = {reader}
420
+ waiting: set[asyncio.Task[Any]] = {reader, retirement}
386
421
  if self.handler_task is not None:
387
422
  waiting.add(self.handler_task)
388
423
  completed, _ = await asyncio.wait(
@@ -390,22 +425,23 @@ class PullStream:
390
425
  )
391
426
  if self.handler_task in completed:
392
427
  await self._finish_handler()
393
- if self.is_draining:
394
- if reader.done():
395
- frame = reader.result()
396
- reader = None
397
- if (
398
- frame is not _GRPC_AIO_EOF
399
- and frame.WhichOneof("frame") == "task"
400
- ):
401
- await self._reject_task(frame.task)
402
- self.is_half_closing = True
403
- self.runtime.signal()
404
- await self._call.done_writing()
405
- await self._await_stream_eof(reader)
428
+ self.try_retire()
429
+ if self.is_draining and not self.is_busy:
430
+ if reader.done():
431
+ frame = reader.result()
406
432
  reader = None
407
- self._is_reconnecting = True
408
- return was_registered
433
+ if (
434
+ frame is not _GRPC_AIO_EOF
435
+ and frame.WhichOneof("frame") == "task"
436
+ ):
437
+ await self._reject_task(frame.task)
438
+ self.is_half_closing = True
439
+ self.runtime.signal()
440
+ await self._call.done_writing()
441
+ await self._await_stream_eof(reader)
442
+ reader = None
443
+ self._is_reconnecting = not self._is_retiring
444
+ return was_registered
409
445
  if reader not in completed:
410
446
  continue
411
447
  frame = reader.result()
@@ -425,6 +461,7 @@ class PullStream:
425
461
  was_registered = True
426
462
  self._is_registered = True
427
463
  self.runtime.registration.record_registered_stream(self.queue)
464
+ self.runtime.signal()
428
465
  continue
429
466
  if kind == "task":
430
467
  await self._start_task(frame.task)
@@ -442,6 +479,8 @@ class PullStream:
442
479
  self._is_reconnecting = True
443
480
  return was_registered
444
481
  finally:
482
+ retirement.cancel()
483
+ await asyncio.gather(retirement, return_exceptions=True)
445
484
  self.is_half_closing = False
446
485
  self.cancel_handler()
447
486
  if self.handler_task is not None:
@@ -511,7 +550,7 @@ class PullStream:
511
550
  if unknown_reason is not None:
512
551
  await self._reject_task(task, unknown_reason)
513
552
  return
514
- if self.is_busy or self.is_draining or not self.runtime.accept_task():
553
+ if self.is_busy or self.is_draining or not self.runtime.accept_task(task):
515
554
  await self._reject_task(task)
516
555
  return
517
556
  self.is_busy = True
@@ -658,6 +697,22 @@ class PullStream:
658
697
  self._active.clear()
659
698
  self.runtime.finish_task()
660
699
 
700
+ def try_retire(self) -> None:
701
+ if self.is_busy or self.is_draining or not self._is_registered:
702
+ return
703
+ idle_replacements = sum(
704
+ candidate is not self
705
+ and candidate.queue == self.queue
706
+ and candidate._is_registered
707
+ and not candidate.is_busy
708
+ and not candidate.is_draining
709
+ for candidate in self.runtime._streams
710
+ )
711
+ if idle_replacements >= self.runtime.idle_streams_per_queue:
712
+ self.is_draining = True
713
+ self._is_retiring = True
714
+ self._retirement_requested.set()
715
+
661
716
  def cancel_handler(self) -> None:
662
717
  self._active.clear()
663
718
  if self.runtime._executor is None and self.handler_task is not None:
@@ -221,6 +221,23 @@ class AsyncWorker:
221
221
  raise RuntimeError("wait_pull_registered requires a pull Worker")
222
222
  await self._pull.registration.wait_async(timeout)
223
223
 
224
+ @property
225
+ def pull_reserved_rpc_declines(self) -> int:
226
+ """Return cumulative Step declines caused by reserved RPC capacity.
227
+
228
+ Full-capacity rejections and unknown names are excluded. The count never
229
+ resets during this Worker's lifetime and stays available after stop.
230
+ Reading it alongside handlers is safe. Listening Workers return zero.
231
+ Export this value to a monitoring counter to identify reserve pressure.
232
+
233
+ Returns:
234
+ The cumulative reserve-decline count, or zero for a listening Worker.
235
+
236
+ Examples:
237
+ >>> declines = worker.pull_reserved_rpc_declines
238
+ """
239
+ return 0 if self._pull is None else self._pull.reserved_rpc_declines
240
+
224
241
  def pull_registration_state(self) -> PullRegistrationState:
225
242
  """Return an immutable snapshot of pull queue registrations.
226
243