dex-python-sdk 1.12.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.
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/PKG-INFO +27 -2
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/README.md +26 -1
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/__init__.py +8 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_pull_worker.py +38 -4
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/async_worker.py +71 -1
- dex_python_sdk-1.14.0/dex/pull_registration.py +181 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/worker.py +71 -2
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/worker_options.py +6 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/pyproject.toml +1 -1
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/LEGACY_NOTICES.md +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/LICENSE +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_async_value_hydrator.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_async_worker_dispatcher.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_async_worker_service.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_credential_transport.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_grpc_errors.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_invocation_context.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_native.pyi +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_server_protocol.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_utils.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_value_hydrator.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_value_mapper.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_worker_dispatcher.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/_worker_service.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/async_client.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/attribute.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/attribute_match.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/blob_cache.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/channel.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/client.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/client_options.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/codec.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/condition.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/context.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/credential.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/dexpb/__init__.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/dexpb/dex_pb2.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/dexpb/dex_pb2.pyi +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/dexpb/dex_pb2_grpc.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/flow.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/flow_config.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/flow_info.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/flow_options.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/flow_result.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/py.typed +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/rpc_invoke_options.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/runtime_errors.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/step.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/step_execution.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/stream.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/subflow.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/timer.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/wait.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/dex/wait_options.py +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/Cargo.lock +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/Cargo.toml +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
- {dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
- {dex_python_sdk-1.12.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.
|
|
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
|
|
@@ -122,7 +122,7 @@ an active task to finish, then waits up to `stop_drain_timeout` for the server
|
|
|
122
122
|
to close the stream after the final frame. An idle stream reconnects
|
|
123
123
|
immediately. An older server without pull support produces one explicit error
|
|
124
124
|
log and is retried after one minute. `start()` reports startup
|
|
125
|
-
synchronization errors, while later stream errors are logged and retried.
|
|
125
|
+
synchronization errors, while later stream errors are logged and retried by default.
|
|
126
126
|
`stop()` drains for at most
|
|
127
127
|
`stop_drain_timeout`, which defaults to 30 seconds. Afterward it requests
|
|
128
128
|
cancellation and waits for handlers to return. Synchronous handlers should
|
|
@@ -130,6 +130,31 @@ check `Context.is_cancellation_requested()` during long operations. Streams
|
|
|
130
130
|
that sent a final frame wait for server EOF until the remaining Stop drain
|
|
131
131
|
deadline before the channel closes.
|
|
132
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
|
+
|
|
133
158
|
## Flow state I/O
|
|
134
159
|
|
|
135
160
|
Applications read and write Flow state through typed RPCs. Client and AsyncClient
|
|
@@ -106,7 +106,7 @@ an active task to finish, then waits up to `stop_drain_timeout` for the server
|
|
|
106
106
|
to close the stream after the final frame. An idle stream reconnects
|
|
107
107
|
immediately. An older server without pull support produces one explicit error
|
|
108
108
|
log and is retried after one minute. `start()` reports startup
|
|
109
|
-
synchronization errors, while later stream errors are logged and retried.
|
|
109
|
+
synchronization errors, while later stream errors are logged and retried by default.
|
|
110
110
|
`stop()` drains for at most
|
|
111
111
|
`stop_drain_timeout`, which defaults to 30 seconds. Afterward it requests
|
|
112
112
|
cancellation and waits for handlers to return. Synchronous handlers should
|
|
@@ -114,6 +114,31 @@ check `Context.is_cancellation_requested()` during long operations. Streams
|
|
|
114
114
|
that sent a final frame wait for server EOF until the remaining Stop drain
|
|
115
115
|
deadline before the channel closes.
|
|
116
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
|
+
|
|
117
142
|
## Flow state I/O
|
|
118
143
|
|
|
119
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__)
|
|
@@ -86,6 +87,12 @@ class PullWorkerRuntime:
|
|
|
86
87
|
self.app_worker_id = app_worker_id or resolve_app_worker_id(registry, options)
|
|
87
88
|
self.flow_types = sorted(registry._registered_flows)
|
|
88
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
|
|
89
96
|
self.max_concurrent_tasks = options.max_concurrent_tasks or os.cpu_count() or 1
|
|
90
97
|
self.idle_streams_per_queue = options.idle_streams_per_queue or 1
|
|
91
98
|
self.stop_drain_timeout = options.stop_drain_timeout.total_seconds() or 30
|
|
@@ -131,9 +138,10 @@ class PullWorkerRuntime:
|
|
|
131
138
|
self._stopping = asyncio.Event()
|
|
132
139
|
self._stop_completed = asyncio.Event()
|
|
133
140
|
self._started.set()
|
|
134
|
-
channel =
|
|
135
|
-
stub = dex_pb2_grpc.WorkerTaskServiceStub(channel) # type: ignore[no-untyped-call]
|
|
141
|
+
channel: grpc.aio.Channel | None = None
|
|
136
142
|
try:
|
|
143
|
+
channel = outbound_worker_task_channel(self._server_address)
|
|
144
|
+
stub = dex_pb2_grpc.WorkerTaskServiceStub(channel) # type: ignore[no-untyped-call]
|
|
137
145
|
self._wake.set()
|
|
138
146
|
while not self._stopping.is_set():
|
|
139
147
|
await self._wake.wait()
|
|
@@ -144,9 +152,14 @@ class PullWorkerRuntime:
|
|
|
144
152
|
*(stream.run_task for stream in tuple(self._streams)),
|
|
145
153
|
return_exceptions=True,
|
|
146
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
|
|
147
159
|
finally:
|
|
148
160
|
try:
|
|
149
|
-
|
|
161
|
+
if channel is not None:
|
|
162
|
+
await channel.close(grace=None)
|
|
150
163
|
finally:
|
|
151
164
|
self._loop_thread_id = None
|
|
152
165
|
|
|
@@ -192,6 +205,7 @@ class PullWorkerRuntime:
|
|
|
192
205
|
self.signal()
|
|
193
206
|
|
|
194
207
|
async def stop(self) -> None:
|
|
208
|
+
self.registration.record_stopping()
|
|
195
209
|
if self._stopping is None:
|
|
196
210
|
return
|
|
197
211
|
if self._stopping.is_set():
|
|
@@ -263,6 +277,7 @@ class PullStream:
|
|
|
263
277
|
self.handler_task: asyncio.Task[None] | None = None
|
|
264
278
|
self.task_id = ""
|
|
265
279
|
self.is_busy = False
|
|
280
|
+
self._is_registered = False
|
|
266
281
|
self.is_draining = False
|
|
267
282
|
self.is_half_closing = False
|
|
268
283
|
self._active = threading.Event()
|
|
@@ -289,7 +304,24 @@ class PullStream:
|
|
|
289
304
|
_LOGGER.exception(
|
|
290
305
|
"Worker task stream failed", extra={"queue": self.queue}
|
|
291
306
|
)
|
|
292
|
-
|
|
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:
|
|
293
325
|
break
|
|
294
326
|
if self._is_reconnecting:
|
|
295
327
|
self._is_reconnecting = False
|
|
@@ -391,6 +423,8 @@ class PullStream:
|
|
|
391
423
|
frame.registered.supports_unknown_name_rejection
|
|
392
424
|
)
|
|
393
425
|
was_registered = True
|
|
426
|
+
self._is_registered = True
|
|
427
|
+
self.runtime.registration.record_registered_stream(self.queue)
|
|
394
428
|
continue
|
|
395
429
|
if kind == "task":
|
|
396
430
|
await self._start_task(frame.task)
|
|
@@ -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
|
-
|
|
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"
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Sustainable Use License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from concurrent.futures import Future
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PullRegistrationError(RuntimeError):
|
|
17
|
+
"""Identify a permanent rejection leaving a pull queue without a registered stream.
|
|
18
|
+
|
|
19
|
+
Raised by ``wait_pull_registered`` before initial readiness, or by ``start``
|
|
20
|
+
when ``stop_on_permanent_stream_rejection`` is enabled. Inspect ``queue``
|
|
21
|
+
and ``error`` for the rejected queue and original gRPC error. Permanent codes
|
|
22
|
+
are INVALID_ARGUMENT, RESOURCE_EXHAUSTED, UNIMPLEMENTED, PERMISSION_DENIED,
|
|
23
|
+
and UNAUTHENTICATED.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
queue: Rejected Flow type or app Worker ID queue.
|
|
27
|
+
error: Original permanent transport error, also exposed as ``__cause__``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, queue: str, error: BaseException) -> None:
|
|
31
|
+
"""Create a queue-specific rejection.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
queue: Rejected queue name.
|
|
35
|
+
error: Original permanent transport error.
|
|
36
|
+
"""
|
|
37
|
+
super().__init__(f"pull Worker queue {queue!r} registration rejected: {error}")
|
|
38
|
+
self.queue = queue
|
|
39
|
+
self.error = error
|
|
40
|
+
self.__cause__ = error
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class PullQueueRegistrationState:
|
|
45
|
+
"""Describe one queue in an immutable readiness snapshot.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
queue: Flow type or app Worker ID queue.
|
|
49
|
+
has_registered_stream: Whether a stream is currently registered.
|
|
50
|
+
last_permanent_error: Last permanent transport error, or ``None``.
|
|
51
|
+
A new Registered acknowledgement clears it. Treat errors as immutable.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
queue: str
|
|
55
|
+
has_registered_stream: bool
|
|
56
|
+
last_permanent_error: BaseException | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class PullRegistrationState:
|
|
61
|
+
"""Describe pull registration readiness independently of the Worker lifecycle.
|
|
62
|
+
|
|
63
|
+
Obtain snapshots with ``worker.pull_registration_state()``. After every queue
|
|
64
|
+
has registered once, transient failures, reconnects, and stop keep readiness.
|
|
65
|
+
A permanent error with no registered stream makes ``is_ready`` false until
|
|
66
|
+
re-registration. A listening Worker returns ``False`` and an empty tuple.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
is_ready: Whether all queues registered once and no queue is permanently
|
|
70
|
+
rejected without a registered stream.
|
|
71
|
+
queues: Immutable per-queue snapshots, ordered by app Worker ID then Flow type.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
>>> state = worker.pull_registration_state()
|
|
75
|
+
>>> is_ready = state.is_ready
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
is_ready: bool = False
|
|
79
|
+
queues: tuple[PullQueueRegistrationState, ...] = ()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _PullRegistration:
|
|
83
|
+
def __init__(self, queues: tuple[str, ...]) -> None:
|
|
84
|
+
self._lock = threading.Lock()
|
|
85
|
+
self._queues = queues
|
|
86
|
+
self._counts = dict.fromkeys(queues, 0)
|
|
87
|
+
self._registered: set[str] = set()
|
|
88
|
+
self._errors: dict[str, BaseException] = {}
|
|
89
|
+
self._initial_error: PullRegistrationError | None = None
|
|
90
|
+
self._has_registered_all = False
|
|
91
|
+
self._is_stopping = False
|
|
92
|
+
self._has_start_returned = False
|
|
93
|
+
self._start_error: BaseException | None = None
|
|
94
|
+
self._changed: Future[None] = Future()
|
|
95
|
+
|
|
96
|
+
def record_registered_stream(self, queue: str) -> None:
|
|
97
|
+
with self._lock:
|
|
98
|
+
self._counts[queue] += 1
|
|
99
|
+
self._registered.add(queue)
|
|
100
|
+
self._errors.pop(queue, None)
|
|
101
|
+
self._has_registered_all = len(self._registered) == len(self._queues)
|
|
102
|
+
self._notify()
|
|
103
|
+
|
|
104
|
+
def record_stream_result(
|
|
105
|
+
self, queue: str, was_registered: bool, error: BaseException | None
|
|
106
|
+
) -> PullRegistrationError | None:
|
|
107
|
+
with self._lock:
|
|
108
|
+
if was_registered:
|
|
109
|
+
self._counts[queue] -= 1
|
|
110
|
+
rejection = None
|
|
111
|
+
if error is not None:
|
|
112
|
+
self._errors[queue] = error
|
|
113
|
+
if self._counts[queue] == 0:
|
|
114
|
+
rejection = PullRegistrationError(queue, error)
|
|
115
|
+
if not self._has_registered_all and self._initial_error is None:
|
|
116
|
+
self._initial_error = rejection
|
|
117
|
+
self._notify()
|
|
118
|
+
return rejection
|
|
119
|
+
|
|
120
|
+
def record_stopping(self) -> None:
|
|
121
|
+
with self._lock:
|
|
122
|
+
self._is_stopping = True
|
|
123
|
+
self._notify()
|
|
124
|
+
|
|
125
|
+
def finish_start(self, error: BaseException | None) -> None:
|
|
126
|
+
with self._lock:
|
|
127
|
+
self._has_start_returned = True
|
|
128
|
+
self._start_error = error
|
|
129
|
+
self._notify()
|
|
130
|
+
|
|
131
|
+
def snapshot(self) -> PullRegistrationState:
|
|
132
|
+
with self._lock:
|
|
133
|
+
queues = tuple(
|
|
134
|
+
PullQueueRegistrationState(
|
|
135
|
+
queue, self._counts[queue] > 0, self._errors.get(queue)
|
|
136
|
+
)
|
|
137
|
+
for queue in self._queues
|
|
138
|
+
)
|
|
139
|
+
return PullRegistrationState(
|
|
140
|
+
self._has_registered_all
|
|
141
|
+
and not any(
|
|
142
|
+
not queue.has_registered_stream
|
|
143
|
+
and queue.last_permanent_error is not None
|
|
144
|
+
for queue in queues
|
|
145
|
+
),
|
|
146
|
+
queues,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def wait(self, timeout: float | None) -> None:
|
|
150
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
151
|
+
while (changed := self._next_change()) is not None:
|
|
152
|
+
changed.result(
|
|
153
|
+
None if deadline is None else max(0, deadline - time.monotonic())
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def wait_async(self, timeout: float | None) -> None:
|
|
157
|
+
if self._next_change() is not None:
|
|
158
|
+
await asyncio.wait_for(self._wait_async(), timeout)
|
|
159
|
+
|
|
160
|
+
async def _wait_async(self) -> None:
|
|
161
|
+
while (changed := self._next_change()) is not None:
|
|
162
|
+
await asyncio.shield(asyncio.wrap_future(changed))
|
|
163
|
+
|
|
164
|
+
def _next_change(self) -> Future[None] | None:
|
|
165
|
+
with self._lock:
|
|
166
|
+
if self._has_registered_all:
|
|
167
|
+
return None
|
|
168
|
+
if self._initial_error is not None:
|
|
169
|
+
raise self._initial_error
|
|
170
|
+
if self._has_start_returned:
|
|
171
|
+
if self._start_error is not None:
|
|
172
|
+
raise self._start_error
|
|
173
|
+
raise RuntimeError("pull Worker stopped before registration")
|
|
174
|
+
if self._is_stopping:
|
|
175
|
+
raise RuntimeError("pull Worker stopped before registration")
|
|
176
|
+
return self._changed
|
|
177
|
+
|
|
178
|
+
def _notify(self) -> None:
|
|
179
|
+
changed = self._changed
|
|
180
|
+
self._changed = Future()
|
|
181
|
+
changed.set_result(None)
|
|
@@ -35,6 +35,7 @@ from dex.blob_cache import BlobCache
|
|
|
35
35
|
from dex.dexpb import dex_pb2 as pb
|
|
36
36
|
from dex.dexpb import dex_pb2_grpc
|
|
37
37
|
from dex.flow import Registry
|
|
38
|
+
from dex.pull_registration import PullRegistrationState
|
|
38
39
|
from dex.worker_options import WorkerOptions, WorkerTarget
|
|
39
40
|
|
|
40
41
|
|
|
@@ -117,6 +118,7 @@ class Worker:
|
|
|
117
118
|
thread_name_prefix="dex-python-handler",
|
|
118
119
|
)
|
|
119
120
|
self._server = None
|
|
121
|
+
self._has_start_called = False
|
|
120
122
|
self._pull = None
|
|
121
123
|
if self.options.pull is not None:
|
|
122
124
|
self._pull = PullWorkerRuntime(
|
|
@@ -188,12 +190,78 @@ class Worker:
|
|
|
188
190
|
|
|
189
191
|
``start`` may be called exactly once. It first contacts FlowService using the
|
|
190
192
|
configured synchronization timeout, then starts a listener or outbound
|
|
191
|
-
pull streams and waits until ``stop`` finishes.
|
|
192
|
-
|
|
193
|
+
pull streams and waits until ``stop`` finishes. Permanent stream rejections
|
|
194
|
+
raise PullRegistrationError when stop_on_permanent_stream_rejection is
|
|
195
|
+
enabled; otherwise streams retry.
|
|
193
196
|
|
|
194
197
|
Raises:
|
|
198
|
+
PullRegistrationError: A permanent stream rejection stops this Worker.
|
|
195
199
|
RuntimeError: If lifecycle state, index synchronization, or binding fails.
|
|
196
200
|
"""
|
|
201
|
+
with self._lock:
|
|
202
|
+
if self._has_start_called or self._state != "created":
|
|
203
|
+
raise RuntimeError(f"Worker cannot start from state {self._state}")
|
|
204
|
+
self._has_start_called = True
|
|
205
|
+
start_error: BaseException | None = None
|
|
206
|
+
try:
|
|
207
|
+
self._start()
|
|
208
|
+
except BaseException as failure:
|
|
209
|
+
start_error = failure
|
|
210
|
+
raise
|
|
211
|
+
finally:
|
|
212
|
+
if self._pull is not None:
|
|
213
|
+
self._pull.registration.finish_start(start_error)
|
|
214
|
+
if self._state == "running":
|
|
215
|
+
self.stop()
|
|
216
|
+
|
|
217
|
+
def wait_pull_registered(self, timeout: float | None = None) -> None:
|
|
218
|
+
"""Wait until every pull queue has registered at least once.
|
|
219
|
+
|
|
220
|
+
Start the Worker separately before waiting, or start it concurrently.
|
|
221
|
+
Successful registration latches, including after stop. Use
|
|
222
|
+
pull_registration_state for subsequent readiness probes. A permanent
|
|
223
|
+
rejection before readiness raises PullRegistrationError only when no
|
|
224
|
+
other stream is registered for that queue. Startup failures wake waiters.
|
|
225
|
+
Multiple callers may wait independently, including before start.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
timeout: Maximum seconds to wait; None waits indefinitely.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
PullRegistrationError: A queue is permanently rejected before readiness.
|
|
232
|
+
TimeoutError: The timeout expires.
|
|
233
|
+
RuntimeError: This is a listening Worker, or it stops before readiness.
|
|
234
|
+
BaseException: The original startup failure, if start fails.
|
|
235
|
+
|
|
236
|
+
Examples:
|
|
237
|
+
>>> start_thread = threading.Thread(target=worker.start)
|
|
238
|
+
>>> start_thread.start()
|
|
239
|
+
>>> worker.wait_pull_registered(timeout=30)
|
|
240
|
+
>>> is_ready = worker.pull_registration_state().is_ready
|
|
241
|
+
"""
|
|
242
|
+
if self._pull is None:
|
|
243
|
+
raise RuntimeError("wait_pull_registered requires a pull Worker")
|
|
244
|
+
self._pull.registration.wait(timeout)
|
|
245
|
+
|
|
246
|
+
def pull_registration_state(self) -> PullRegistrationState:
|
|
247
|
+
"""Return an immutable snapshot of pull queue registrations.
|
|
248
|
+
|
|
249
|
+
Transient failures, reconnects, and stop preserve initial readiness.
|
|
250
|
+
A permanent rejection makes readiness false only while that queue has no
|
|
251
|
+
registered stream. Re-registration clears its error. Calls are safe
|
|
252
|
+
concurrently with start, stop, and other waiters. See wait_pull_registered
|
|
253
|
+
for an example.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Current per-queue state, or an empty nonready state in listening mode.
|
|
257
|
+
"""
|
|
258
|
+
return (
|
|
259
|
+
PullRegistrationState()
|
|
260
|
+
if self._pull is None
|
|
261
|
+
else self._pull.registration.snapshot()
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _start(self) -> None:
|
|
197
265
|
with self._lock:
|
|
198
266
|
if self._state != "created":
|
|
199
267
|
raise RuntimeError(f"Worker cannot start from state {self._state}")
|
|
@@ -291,6 +359,7 @@ class Worker:
|
|
|
291
359
|
was_created = self._state == "created"
|
|
292
360
|
self._state = "stopping"
|
|
293
361
|
if self._pull is not None:
|
|
362
|
+
self._pull.registration.record_stopping()
|
|
294
363
|
if not was_created:
|
|
295
364
|
self._pull.stop_threadsafe()
|
|
296
365
|
else:
|
|
@@ -77,6 +77,11 @@ class PullWorkerOptions:
|
|
|
77
77
|
the final frame. During Stop, half-closed streams use the remaining
|
|
78
78
|
drain deadline.
|
|
79
79
|
|
|
80
|
+
stop_on_permanent_stream_rejection: Whether the first permanent rejection
|
|
81
|
+
leaving a queue without a registered stream drains the Worker and
|
|
82
|
+
raises PullRegistrationError from start. Defaults to False, which
|
|
83
|
+
retries indefinitely. Applies before and after initial readiness.
|
|
84
|
+
|
|
80
85
|
Raises:
|
|
81
86
|
ValueError: If a numeric option is negative or app_worker_id is invalid.
|
|
82
87
|
|
|
@@ -91,6 +96,7 @@ class PullWorkerOptions:
|
|
|
91
96
|
idle_streams_per_queue: int = 0
|
|
92
97
|
app_worker_id: str = ""
|
|
93
98
|
stop_drain_timeout: timedelta = timedelta(0)
|
|
99
|
+
stop_on_permanent_stream_rejection: bool = False
|
|
94
100
|
|
|
95
101
|
def __post_init__(self) -> None:
|
|
96
102
|
if self.max_concurrent_tasks < 0:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dex-python-sdk"
|
|
3
3
|
# Publish uses tag sdk-python/v*; CI stamps this for release builds.
|
|
4
|
-
version = "1.
|
|
4
|
+
version = "1.14.0"
|
|
5
5
|
description = "Python SDK for the Dex workflow engine"
|
|
6
6
|
authors = [{name = "Super Durable"}]
|
|
7
7
|
readme = "README.md"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/config.rs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/format.rs
RENAMED
|
File without changes
|
|
File without changes
|
{dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml
RENAMED
|
File without changes
|
{dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE
RENAMED
|
File without changes
|
{dex_python_sdk-1.12.0 → dex_python_sdk-1.14.0}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs
RENAMED
|
File without changes
|