dex-python-sdk 1.11.0__tar.gz → 1.14.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.11.0 → dex_python_sdk-1.14.0}/PKG-INFO +35 -2
  2. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/README.md +34 -1
  3. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/__init__.py +8 -0
  4. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_pull_worker.py +78 -6
  5. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/async_worker.py +71 -1
  6. dex_python_sdk-1.14.0/dex/dexpb/dex_pb2.py +545 -0
  7. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/dexpb/dex_pb2.pyi +28 -8
  8. dex_python_sdk-1.14.0/dex/pull_registration.py +181 -0
  9. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/worker.py +71 -2
  10. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/worker_options.py +6 -0
  11. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/pyproject.toml +1 -1
  12. dex_python_sdk-1.11.0/dex/dexpb/dex_pb2.py +0 -545
  13. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/LEGACY_NOTICES.md +0 -0
  14. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/LICENSE +0 -0
  15. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_async_value_hydrator.py +0 -0
  16. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_async_worker_dispatcher.py +0 -0
  17. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_async_worker_service.py +0 -0
  18. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_credential_transport.py +0 -0
  19. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_grpc_errors.py +0 -0
  20. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_invocation_context.py +0 -0
  21. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_native.pyi +0 -0
  22. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_server_protocol.py +0 -0
  23. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_utils.py +0 -0
  24. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_value_hydrator.py +0 -0
  25. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_value_mapper.py +0 -0
  26. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_worker_dispatcher.py +0 -0
  27. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/_worker_service.py +0 -0
  28. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/async_client.py +0 -0
  29. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/attribute.py +0 -0
  30. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/attribute_match.py +0 -0
  31. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/blob_cache.py +0 -0
  32. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/channel.py +0 -0
  33. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/client.py +0 -0
  34. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/client_options.py +0 -0
  35. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/codec.py +0 -0
  36. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/condition.py +0 -0
  37. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/context.py +0 -0
  38. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/credential.py +0 -0
  39. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/dexpb/__init__.py +0 -0
  40. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/dexpb/dex_pb2_grpc.py +0 -0
  41. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/flow.py +0 -0
  42. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/flow_config.py +0 -0
  43. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/flow_info.py +0 -0
  44. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/flow_options.py +0 -0
  45. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/flow_result.py +0 -0
  46. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/py.typed +0 -0
  47. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/rpc_invoke_options.py +0 -0
  48. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/runtime_errors.py +0 -0
  49. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/step.py +0 -0
  50. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/step_execution.py +0 -0
  51. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/stream.py +0 -0
  52. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/subflow.py +0 -0
  53. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/timer.py +0 -0
  54. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/wait.py +0 -0
  55. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/dex/wait_options.py +0 -0
  56. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/Cargo.lock +0 -0
  57. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/Cargo.toml +0 -0
  58. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  59. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  60. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  61. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  62. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  63. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  64. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  65. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  66. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  67. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  68. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  69. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  70. {dex_python_sdk-1.11.0 → dex_python_sdk-1.14.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.11.0
3
+ Version: 1.14.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
@@ -100,6 +100,14 @@ max_concurrent_tasks)` streams because busy streams are replaced with idle
100
100
  streams and retained after tasks finish. Size these options for the server's
101
101
  per-Worker stream cap (256 by default).
102
102
 
103
+ The Worker rejects an unregistered Step or RPC by name. Dex retries that task
104
+ on another pull Worker without consuming a Step attempt, including when the
105
+ Flow was sticky to the rejecting Worker. If no capable Worker accepts it within
106
+ the server's dispatch wait (10 seconds by default, or the local Step attempt),
107
+ the Step follows its failure and retry policy; an RPC caller receives the named
108
+ error. This reassignment requires a Dex Server that supports it. Against older
109
+ Servers, the Worker returns the not-registered error as before.
110
+
103
111
  The generated `app_worker_id` uses the first nonempty `DEX_WORKER_INSTANCE`,
104
112
  `POD_NAME`, `HOSTNAME`, or system hostname, plus eight random base32 characters.
105
113
  An override must uniquely identify one Worker process and change after restart.
@@ -114,7 +122,7 @@ an active task to finish, then waits up to `stop_drain_timeout` for the server
114
122
  to close the stream after the final frame. An idle stream reconnects
115
123
  immediately. An older server without pull support produces one explicit error
116
124
  log and is retried after one minute. `start()` reports startup
117
- synchronization errors, while later stream errors are logged and retried.
125
+ synchronization errors, while later stream errors are logged and retried by default.
118
126
  `stop()` drains for at most
119
127
  `stop_drain_timeout`, which defaults to 30 seconds. Afterward it requests
120
128
  cancellation and waits for handlers to return. Synchronous handlers should
@@ -122,6 +130,31 @@ check `Context.is_cancellation_requested()` during long operations. Streams
122
130
  that sent a final frame wait for server EOF until the remaining Stop drain
123
131
  deadline before the channel closes.
124
132
 
133
+ Call `worker.wait_pull_registered(timeout=30)` after starting a synchronous
134
+ Worker on another thread. For AsyncWorker, start it in another task and use
135
+ `await worker.wait_pull_registered(timeout=30)`. The timeout is in seconds;
136
+ `None` waits indefinitely. Each waits for every Flow type queue and the app
137
+ Worker ID queue to register at least once. Timeout raises `TimeoutError`;
138
+ async cancellation cancels only that wait. Startup failures reach waiters.
139
+ A permanent rejection before readiness raises `PullRegistrationError`, whose
140
+ `queue` and `error` identify the rejected queue and original transport error.
141
+ Listening mode and stop before readiness raise `RuntimeError`.
142
+
143
+ Use `worker.pull_registration_state()` for subsequent readiness probes. Its
144
+ immutable `queues` expose `queue`, `has_registered_stream`, and
145
+ `last_permanent_error`. After initial registration, `is_ready` stays true across
146
+ transient failures, reconnects, and stop. It becomes false only when a queue
147
+ has a permanent error and no registered stream. Re-registration clears the
148
+ error. The startup wait keeps succeeding after initial registration, including
149
+ after stop. Multiple callers may wait independently before or after start.
150
+
151
+ `PullWorkerOptions(stop_on_permanent_stream_rejection=True)` makes the first
152
+ permanent rejection leaving a queue without a registered stream drain within
153
+ `stop_drain_timeout`, then raise `PullRegistrationError` from `start()`. This
154
+ applies before and after readiness. Another registered stream for that queue
155
+ prevents shutdown. The default is False and retries indefinitely. Always call
156
+ `stop()` or `close()` afterward; both remain safe after `start()` returns.
157
+
125
158
  ## Flow state I/O
126
159
 
127
160
  Applications read and write Flow state through typed RPCs. Client and AsyncClient
@@ -84,6 +84,14 @@ max_concurrent_tasks)` streams because busy streams are replaced with idle
84
84
  streams and retained after tasks finish. Size these options for the server's
85
85
  per-Worker stream cap (256 by default).
86
86
 
87
+ The Worker rejects an unregistered Step or RPC by name. Dex retries that task
88
+ on another pull Worker without consuming a Step attempt, including when the
89
+ Flow was sticky to the rejecting Worker. If no capable Worker accepts it within
90
+ the server's dispatch wait (10 seconds by default, or the local Step attempt),
91
+ the Step follows its failure and retry policy; an RPC caller receives the named
92
+ error. This reassignment requires a Dex Server that supports it. Against older
93
+ Servers, the Worker returns the not-registered error as before.
94
+
87
95
  The generated `app_worker_id` uses the first nonempty `DEX_WORKER_INSTANCE`,
88
96
  `POD_NAME`, `HOSTNAME`, or system hostname, plus eight random base32 characters.
89
97
  An override must uniquely identify one Worker process and change after restart.
@@ -98,7 +106,7 @@ an active task to finish, then waits up to `stop_drain_timeout` for the server
98
106
  to close the stream after the final frame. An idle stream reconnects
99
107
  immediately. An older server without pull support produces one explicit error
100
108
  log and is retried after one minute. `start()` reports startup
101
- synchronization errors, while later stream errors are logged and retried.
109
+ synchronization errors, while later stream errors are logged and retried by default.
102
110
  `stop()` drains for at most
103
111
  `stop_drain_timeout`, which defaults to 30 seconds. Afterward it requests
104
112
  cancellation and waits for handlers to return. Synchronous handlers should
@@ -106,6 +114,31 @@ check `Context.is_cancellation_requested()` during long operations. Streams
106
114
  that sent a final frame wait for server EOF until the remaining Stop drain
107
115
  deadline before the channel closes.
108
116
 
117
+ Call `worker.wait_pull_registered(timeout=30)` after starting a synchronous
118
+ Worker on another thread. For AsyncWorker, start it in another task and use
119
+ `await worker.wait_pull_registered(timeout=30)`. The timeout is in seconds;
120
+ `None` waits indefinitely. Each waits for every Flow type queue and the app
121
+ Worker ID queue to register at least once. Timeout raises `TimeoutError`;
122
+ async cancellation cancels only that wait. Startup failures reach waiters.
123
+ A permanent rejection before readiness raises `PullRegistrationError`, whose
124
+ `queue` and `error` identify the rejected queue and original transport error.
125
+ Listening mode and stop before readiness raise `RuntimeError`.
126
+
127
+ Use `worker.pull_registration_state()` for subsequent readiness probes. Its
128
+ immutable `queues` expose `queue`, `has_registered_stream`, and
129
+ `last_permanent_error`. After initial registration, `is_ready` stays true across
130
+ transient failures, reconnects, and stop. It becomes false only when a queue
131
+ has a permanent error and no registered stream. Re-registration clears the
132
+ error. The startup wait keeps succeeding after initial registration, including
133
+ after stop. Multiple callers may wait independently before or after start.
134
+
135
+ `PullWorkerOptions(stop_on_permanent_stream_rejection=True)` makes the first
136
+ permanent rejection leaving a queue without a registered stream drain within
137
+ `stop_drain_timeout`, then raise `PullRegistrationError` from `start()`. This
138
+ applies before and after readiness. Another registered stream for that queue
139
+ prevents shutdown. The default is False and retries indefinitely. Always call
140
+ `stop()` or `close()` afterward; both remain safe after `start()` returns.
141
+
109
142
  ## Flow state I/O
110
143
 
111
144
  Applications read and write Flow state through typed RPCs. Client and AsyncClient
@@ -113,6 +113,11 @@ from dex.timer import Timer
113
113
  from dex.wait import Wait
114
114
  from dex.wait_options import WaitForAttributeOptions, WaitForStepCompletionOptions
115
115
  from dex.worker import Worker
116
+ from dex.pull_registration import (
117
+ PullRegistrationError,
118
+ PullRegistrationState,
119
+ PullQueueRegistrationState,
120
+ )
116
121
  from dex.worker_options import PullWorkerOptions, WorkerOptions, WorkerTarget
117
122
 
118
123
  __all__ = [
@@ -214,6 +219,9 @@ __all__ = [
214
219
  "WorkerOptions",
215
220
  "WorkerTarget",
216
221
  "PullWorkerOptions",
222
+ "PullRegistrationError",
223
+ "PullRegistrationState",
224
+ "PullQueueRegistrationState",
217
225
  "WaitForAttributeOptions",
218
226
  "WaitForStepCompletionOptions",
219
227
  "dead_end",
@@ -28,6 +28,7 @@ from dex._server_protocol import sdk_version
28
28
  from dex.dexpb import dex_pb2 as pb
29
29
  from dex.dexpb import dex_pb2_grpc
30
30
  from dex.flow import Registry
31
+ from dex.pull_registration import PullRegistrationError, _PullRegistration
31
32
  from dex.worker_options import PullWorkerOptions
32
33
 
33
34
  _LOGGER = logging.getLogger(__name__)
@@ -82,9 +83,16 @@ class PullWorkerRuntime:
82
83
  app_worker_id: str | None = None,
83
84
  credential_runner: AsyncCredentialRunner | None = None,
84
85
  ) -> None:
86
+ self._registry = registry
85
87
  self.app_worker_id = app_worker_id or resolve_app_worker_id(registry, options)
86
88
  self.flow_types = sorted(registry._registered_flows)
87
89
  self.queues = (self.app_worker_id, *self.flow_types)
90
+ self.registration = _PullRegistration(self.queues)
91
+ self._stop_on_permanent_stream_rejection = (
92
+ options.stop_on_permanent_stream_rejection
93
+ )
94
+ self._permanent_error: PullRegistrationError | None = None
95
+ self._rejection_stop_task: asyncio.Task[None] | None = None
88
96
  self.max_concurrent_tasks = options.max_concurrent_tasks or os.cpu_count() or 1
89
97
  self.idle_streams_per_queue = options.idle_streams_per_queue or 1
90
98
  self.stop_drain_timeout = options.stop_drain_timeout.total_seconds() or 30
@@ -103,6 +111,26 @@ class PullWorkerRuntime:
103
111
  self._busy_tasks = 0
104
112
  self._unsupported_logged = False
105
113
 
114
+ def unknown_task_reason(
115
+ self, task: pb.PollWorkerTask
116
+ ) -> pb.PollWorkerTaskRejectReason | None:
117
+ kind = task.WhichOneof("request")
118
+ if kind == "rpc":
119
+ flow = self._registry._registered_flows.get(task.rpc.flow_type)
120
+ if flow is None or task.rpc.rpc_name not in flow.rpcs:
121
+ return pb.POLL_WORKER_TASK_REJECT_REASON_UNKNOWN_RPC
122
+ elif kind in ("wait_for", "execute"):
123
+ request = task.wait_for if kind == "wait_for" else task.execute
124
+ flow = self._registry._registered_flows.get(request.flow_type)
125
+ if flow is None:
126
+ return pb.POLL_WORKER_TASK_REJECT_REASON_UNKNOWN_STEP
127
+ if kind == "execute" and request.step_type == "sys:timeout_handler":
128
+ if not flow.has_timeout_handler:
129
+ return pb.POLL_WORKER_TASK_REJECT_REASON_UNKNOWN_STEP
130
+ elif request.step_type not in flow.steps:
131
+ return pb.POLL_WORKER_TASK_REJECT_REASON_UNKNOWN_STEP
132
+ return None
133
+
106
134
  async def run(self) -> None:
107
135
  self._loop = asyncio.get_running_loop()
108
136
  self._loop_thread_id = threading.get_ident()
@@ -110,9 +138,10 @@ class PullWorkerRuntime:
110
138
  self._stopping = asyncio.Event()
111
139
  self._stop_completed = asyncio.Event()
112
140
  self._started.set()
113
- channel = outbound_worker_task_channel(self._server_address)
114
- stub = dex_pb2_grpc.WorkerTaskServiceStub(channel) # type: ignore[no-untyped-call]
141
+ channel: grpc.aio.Channel | None = None
115
142
  try:
143
+ channel = outbound_worker_task_channel(self._server_address)
144
+ stub = dex_pb2_grpc.WorkerTaskServiceStub(channel) # type: ignore[no-untyped-call]
116
145
  self._wake.set()
117
146
  while not self._stopping.is_set():
118
147
  await self._wake.wait()
@@ -123,9 +152,14 @@ class PullWorkerRuntime:
123
152
  *(stream.run_task for stream in tuple(self._streams)),
124
153
  return_exceptions=True,
125
154
  )
155
+ if self._rejection_stop_task is not None:
156
+ await self._rejection_stop_task
157
+ if self._permanent_error is not None:
158
+ raise self._permanent_error
126
159
  finally:
127
160
  try:
128
- await channel.close(grace=None)
161
+ if channel is not None:
162
+ await channel.close(grace=None)
129
163
  finally:
130
164
  self._loop_thread_id = None
131
165
 
@@ -171,6 +205,7 @@ class PullWorkerRuntime:
171
205
  self.signal()
172
206
 
173
207
  async def stop(self) -> None:
208
+ self.registration.record_stopping()
174
209
  if self._stopping is None:
175
210
  return
176
211
  if self._stopping.is_set():
@@ -242,6 +277,7 @@ class PullStream:
242
277
  self.handler_task: asyncio.Task[None] | None = None
243
278
  self.task_id = ""
244
279
  self.is_busy = False
280
+ self._is_registered = False
245
281
  self.is_draining = False
246
282
  self.is_half_closing = False
247
283
  self._active = threading.Event()
@@ -249,6 +285,7 @@ class PullStream:
249
285
  self._send_lock = asyncio.Lock()
250
286
  self._is_reconnecting = False
251
287
  self._deadline_handle: asyncio.TimerHandle | None = None
288
+ self._supports_unknown_name_rejection = False
252
289
 
253
290
  def on_done(self, completed: asyncio.Task[None]) -> None:
254
291
  self.runtime._stream_done(self, completed)
@@ -267,7 +304,24 @@ class PullStream:
267
304
  _LOGGER.exception(
268
305
  "Worker task stream failed", extra={"queue": self.queue}
269
306
  )
270
- if self.runtime.is_stopping:
307
+ finally:
308
+ code = error.code() if error is not None else None
309
+ rejection = self.runtime.registration.record_stream_result(
310
+ self.queue,
311
+ self._is_registered,
312
+ error if code in _PERMANENT_CODES else None,
313
+ )
314
+ self._is_registered = False
315
+ if (
316
+ rejection is not None
317
+ and self.runtime._stop_on_permanent_stream_rejection
318
+ and self.runtime._permanent_error is None
319
+ ):
320
+ self.runtime._permanent_error = rejection
321
+ self.runtime._rejection_stop_task = asyncio.create_task(
322
+ self.runtime.stop()
323
+ )
324
+ if self.runtime.is_stopping or self.runtime._permanent_error is not None:
271
325
  break
272
326
  if self._is_reconnecting:
273
327
  self._is_reconnecting = False
@@ -301,6 +355,7 @@ class PullStream:
301
355
  async def _poll(self) -> bool:
302
356
  if self.runtime.is_stopping:
303
357
  return False
358
+ self._supports_unknown_name_rejection = False
304
359
  if self.runtime._credential_runner is None:
305
360
  self._call = self.stub.PollWorkerTask()
306
361
  else:
@@ -364,7 +419,12 @@ class PullStream:
364
419
  return False
365
420
  if kind != "registered":
366
421
  raise RuntimeError("Worker task stream did not register")
422
+ self._supports_unknown_name_rejection = (
423
+ frame.registered.supports_unknown_name_rejection
424
+ )
367
425
  was_registered = True
426
+ self._is_registered = True
427
+ self.runtime.registration.record_registered_stream(self.queue)
368
428
  continue
369
429
  if kind == "task":
370
430
  await self._start_task(frame.task)
@@ -443,6 +503,14 @@ class PullStream:
443
503
  pass
444
504
 
445
505
  async def _start_task(self, task: pb.PollWorkerTask) -> None:
506
+ unknown_reason = (
507
+ self.runtime.unknown_task_reason(task)
508
+ if self._supports_unknown_name_rejection
509
+ else None
510
+ )
511
+ if unknown_reason is not None:
512
+ await self._reject_task(task, unknown_reason)
513
+ return
446
514
  if self.is_busy or self.is_draining or not self.runtime.accept_task():
447
515
  await self._reject_task(task)
448
516
  return
@@ -458,12 +526,16 @@ class PullStream:
458
526
  )
459
527
  self.runtime.signal()
460
528
 
461
- async def _reject_task(self, task: pb.PollWorkerTask) -> None:
529
+ async def _reject_task(
530
+ self,
531
+ task: pb.PollWorkerTask,
532
+ reason: pb.PollWorkerTaskRejectReason = pb.POLL_WORKER_TASK_REJECT_REASON_NO_CAPACITY,
533
+ ) -> None:
462
534
  await self._send(
463
535
  pb.PollWorkerTaskRequest(
464
536
  reject=pb.PollWorkerTaskReject(
465
537
  task_id=task.task_id,
466
- reason=pb.POLL_WORKER_TASK_REJECT_REASON_NO_CAPACITY,
538
+ reason=reason,
467
539
  )
468
540
  )
469
541
  )
@@ -30,6 +30,7 @@ from dex.blob_cache import BlobCache
30
30
  from dex.dexpb import dex_pb2 as pb
31
31
  from dex.dexpb import dex_pb2_grpc
32
32
  from dex.flow import Registry
33
+ from dex.pull_registration import PullRegistrationState
33
34
  from dex.worker_options import WorkerOptions, WorkerTarget
34
35
 
35
36
 
@@ -100,6 +101,7 @@ class AsyncWorker:
100
101
  self._server_protocol,
101
102
  )
102
103
  self._server = None
104
+ self._has_start_called = False
103
105
  self._pull = None
104
106
  if self.options.pull is not None:
105
107
  self._pull = PullWorkerRuntime(
@@ -167,11 +169,77 @@ class AsyncWorker:
167
169
 
168
170
  ``start`` may be awaited exactly once. It contacts FlowService before binding
169
171
  and completes only after ``stop`` terminates the listener or pull streams.
170
- Later pull stream failures are logged and retried.
172
+ Permanent pull stream rejections raise PullRegistrationError when
173
+ stop_on_permanent_stream_rejection is enabled; otherwise streams retry.
171
174
 
172
175
  Raises:
176
+ PullRegistrationError: A permanent stream rejection stops this Worker.
173
177
  RuntimeError: If lifecycle state, index synchronization, or binding fails.
174
178
  """
179
+ if self._has_start_called:
180
+ raise RuntimeError("Worker can only start once")
181
+ self._has_start_called = True
182
+ start_error: BaseException | None = None
183
+ try:
184
+ await self._start()
185
+ except BaseException as failure:
186
+ start_error = failure
187
+ raise
188
+ finally:
189
+ if self._pull is not None:
190
+ self._pull.registration.finish_start(start_error)
191
+ if self._state == "running":
192
+ await self.stop()
193
+
194
+ async def wait_pull_registered(self, timeout: float | None = None) -> None:
195
+ """Wait until every pull queue has registered at least once.
196
+
197
+ Start the Worker separately before waiting, or start it concurrently.
198
+ Successful registration latches, including after stop. Use
199
+ pull_registration_state for subsequent readiness probes. A permanent
200
+ rejection before readiness raises PullRegistrationError only when no
201
+ other stream is registered for that queue. Startup failures wake waiters.
202
+ Multiple callers may wait independently, including before start.
203
+
204
+ Args:
205
+ timeout: Maximum seconds to wait; None waits indefinitely.
206
+
207
+ Raises:
208
+ PullRegistrationError: A queue is permanently rejected before readiness.
209
+ TimeoutError: The timeout expires.
210
+ asyncio.CancelledError: The calling task is canceled; the Worker keeps running.
211
+ RuntimeError: This is a listening Worker, or it stops before readiness.
212
+ BaseException: The original startup failure, if start fails.
213
+
214
+ Examples:
215
+ >>> start_task = asyncio.create_task(worker.start())
216
+
217
+ >>> await worker.wait_pull_registered(timeout=30)
218
+ >>> is_ready = worker.pull_registration_state().is_ready
219
+ """
220
+ if self._pull is None:
221
+ raise RuntimeError("wait_pull_registered requires a pull Worker")
222
+ await self._pull.registration.wait_async(timeout)
223
+
224
+ def pull_registration_state(self) -> PullRegistrationState:
225
+ """Return an immutable snapshot of pull queue registrations.
226
+
227
+ Transient failures, reconnects, and stop preserve initial readiness.
228
+ A permanent rejection makes readiness false only while that queue has no
229
+ registered stream. Re-registration clears its error. Calls are safe
230
+ concurrently with start, stop, and other waiters. See wait_pull_registered
231
+ for an example.
232
+
233
+ Returns:
234
+ Current per-queue state, or an empty nonready state in listening mode.
235
+ """
236
+ return (
237
+ PullRegistrationState()
238
+ if self._pull is None
239
+ else self._pull.registration.snapshot()
240
+ )
241
+
242
+ async def _start(self) -> None:
175
243
  if self._state != "created":
176
244
  raise RuntimeError(f"AsyncWorker cannot start from state {self._state}")
177
245
  try:
@@ -247,6 +315,8 @@ class AsyncWorker:
247
315
  Half-closed pull streams wait for server EOF until the remaining drain
248
316
  deadline before the channel closes.
249
317
  """
318
+ if self._pull is not None:
319
+ self._pull.registration.record_stopping()
250
320
  if self._state in ("stopped", "closed"):
251
321
  return
252
322
  self._state = "stopping"