dex-python-sdk 1.20.0__tar.gz → 1.25.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 (72) hide show
  1. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/PKG-INFO +47 -37
  2. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/README.md +46 -36
  3. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/__init__.py +1 -2
  4. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_grpc_errors.py +1 -18
  5. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_pull_worker.py +126 -7
  6. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_worker_dispatcher.py +4 -8
  7. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/async_client.py +12 -22
  8. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/async_worker.py +35 -106
  9. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/client.py +10 -23
  10. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/client_options.py +1 -5
  11. dex_python_sdk-1.25.0/dex/dexpb/dex_pb2.py +551 -0
  12. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/dexpb/dex_pb2.pyi +12 -6
  13. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/flow_config.py +2 -5
  14. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/pull_registration.py +1 -1
  15. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/runtime_errors.py +1 -1
  16. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/worker.py +37 -124
  17. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/worker_options.py +20 -73
  18. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/pyproject.toml +1 -1
  19. dex_python_sdk-1.20.0/dex/_async_worker_service.py +0 -122
  20. dex_python_sdk-1.20.0/dex/_worker_service.py +0 -94
  21. dex_python_sdk-1.20.0/dex/dexpb/dex_pb2.py +0 -551
  22. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/LEGACY_NOTICES.md +0 -0
  23. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/LICENSE +0 -0
  24. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_async_value_hydrator.py +0 -0
  25. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_async_worker_dispatcher.py +0 -0
  26. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_credential_transport.py +0 -0
  27. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_invocation_context.py +0 -0
  28. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_native.pyi +0 -0
  29. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_server_protocol.py +0 -0
  30. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_utils.py +0 -0
  31. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_value_hydrator.py +0 -0
  32. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/_value_mapper.py +0 -0
  33. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/attribute.py +0 -0
  34. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/attribute_match.py +0 -0
  35. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/blob_cache.py +0 -0
  36. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/channel.py +0 -0
  37. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/codec.py +0 -0
  38. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/condition.py +0 -0
  39. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/context.py +0 -0
  40. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/credential.py +0 -0
  41. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/dexpb/__init__.py +0 -0
  42. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/dexpb/dex_pb2_grpc.py +0 -0
  43. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/flow.py +0 -0
  44. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/flow_info.py +0 -0
  45. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/flow_options.py +0 -0
  46. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/flow_result.py +0 -0
  47. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/py.typed +0 -0
  48. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/rpc_invoke_options.py +0 -0
  49. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/step.py +0 -0
  50. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/step_execution.py +0 -0
  51. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/stream.py +0 -0
  52. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/subflow.py +0 -0
  53. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/timer.py +0 -0
  54. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/wait.py +0 -0
  55. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/dex/wait_options.py +0 -0
  56. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/Cargo.lock +0 -0
  57. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/Cargo.toml +0 -0
  58. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  59. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  60. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/README.md +0 -0
  61. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  62. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  63. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  64. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  65. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  66. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  67. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  68. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  69. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  70. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  71. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.0}/sdk-rust/crates/dex-blob-cache-python/README.md +0 -0
  72. {dex_python_sdk-1.20.0 → dex_python_sdk-1.25.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.20.0
3
+ Version: 1.25.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
@@ -95,14 +95,17 @@ retain up to four idle credential threads. Sync token lookup must return
95
95
  promptly. By default, no authorization metadata is sent. Dex uses plaintext
96
96
  gRPC, so protect the network path that carries the token.
97
97
 
98
- ## Pull mode
98
+ ## Workers
99
99
 
100
- Set `WorkerOptions.pull` to run a `Worker` or `AsyncWorker` with outbound task
101
- streams. The Worker connects to the Dex server through `server_address` and
102
- does not open a listener. Advertise `worker.worker_target` through
103
- `ClientOptions.worker_target`. A single Flow can override the Client default
104
- with `StartFlowOptions(config_override=FlowConfig(worker_target=...))`; a
105
- SubFlow can use `SubFlowOptions` with the same `FlowConfig` override.
100
+ Workers receive tasks through outbound pull streams. `Worker` and `AsyncWorker`
101
+ need only an outbound connection to the Dex Server at `server_address`. Configure
102
+ capacity and shutdown with `WorkerOptions.pull`; its default is
103
+ `PullWorkerOptions()`. Clients and Flows use pull routing automatically.
104
+
105
+ StartFlow and SubFlow always select pull Workers. Updating Flow configuration
106
+ preserves its existing Worker target; an empty `FlowConfig()` changes nothing.
107
+ Before upgrading an application with active push Flows, use an SDK version that
108
+ supports Worker targets to migrate those Flows to pull routing.
106
109
 
107
110
  ```python
108
111
  import threading
@@ -118,10 +121,11 @@ worker = Worker(
118
121
  )
119
122
  worker_thread = threading.Thread(target=worker.start)
120
123
  worker_thread.start()
124
+ worker.wait_pull_registered(timeout=30)
121
125
  client = Client(
122
126
  registry,
123
127
  cache,
124
- ClientOptions(server_address="dex.example:8801", worker_target=worker.worker_target),
128
+ ClientOptions(server_address="dex.example:8801"),
125
129
  )
126
130
  try:
127
131
  client.start_flow(flow, flow_id, input_value)
@@ -135,13 +139,18 @@ The Worker polls its sticky queue and every registered Flow type queue. Flow
135
139
  type names must be unique within a Dex server. `max_concurrent_tasks` defaults
136
140
  to the CPU count, and `idle_streams_per_queue` defaults to one. At capacity,
137
141
  the Worker rejects an assignment so Dex can reassign it. Each stream runs one
138
- task at a time; Step heartbeat and stream writes use the same stream. After tasks finish, the Worker
139
- gracefully closes surplus idle streams while preserving readiness. After load drops,
142
+ task at a time; Step heartbeat and stream writes use the same stream. After
143
+ tasks finish, the Worker gracefully closes surplus idle streams while preserving
144
+ readiness. The SDK sends an upstream Pong every 20 seconds while a task runs and
145
+ stops before its final frame. This interval is internal and needs no Worker
146
+ option. After load drops,
140
147
  the steady-state bound is `max_concurrent_tasks + (1 + number of Flow types) *
141
- idle_streams_per_queue` streams. The transient peak during a burst remains bounded
142
- by the matching caps. Size these options for `matching.maxConcurrentTasksCap` per
143
- app Worker ID per matching owner (256 by default) and `matching.maxStreams` per
144
- Matching instance (100,000 by default).
148
+ idle_streams_per_queue` streams.
149
+ Allow up to one more stream per queue for 30 seconds after reserve declines,
150
+ or up to `1 + number of Flow types` additional streams. The transient peak
151
+ during a burst remains bounded by the matching caps. Size these options for
152
+ `matching.maxConcurrentTasksCap` per app Worker ID per matching owner (256 by
153
+ default) and `matching.maxStreams` per Matching instance (100,000 by default).
145
154
 
146
155
  The Worker rejects an unregistered Step or RPC by name. Dex retries that task
147
156
  on another pull Worker without consuming a Step attempt, including when the
@@ -155,10 +164,7 @@ The generated `app_worker_id` uses the first nonempty `DEX_WORKER_INSTANCE`,
155
164
  `POD_NAME`, `HOSTNAME`, or system hostname, plus eight random base32 characters.
156
165
  An override must uniquely identify one Worker process and change after restart.
157
166
  It must contain 1-63 ASCII letters, digits, dots, underscores, or hyphens and
158
- must not equal a registered Flow type. Pull mode cannot configure a bind address
159
- or an advertised address or headless target. Use `WorkerTarget(pull=True)` for
160
- Client defaults or Flow overrides when the Worker target is not directly in
161
- scope.
167
+ must not equal a registered Flow type.
162
168
 
163
169
  Pull streams reconnect after failures. A server-directed reconnect waits for
164
170
  an active task to finish, then waits up to `stop_drain_timeout` for the server
@@ -181,7 +187,7 @@ Worker ID queue to register at least once. Timeout raises `TimeoutError`;
181
187
  async cancellation cancels only that wait. Startup failures reach waiters.
182
188
  A permanent rejection before readiness raises `PullRegistrationError`, whose
183
189
  `queue` and `error` identify the rejected queue and original transport error.
184
- Listening mode and stop before readiness raise `RuntimeError`.
190
+ Stopping before readiness raises `RuntimeError`.
185
191
 
186
192
  Use `worker.pull_registration_state()` for subsequent readiness probes. Its
187
193
  immutable `queues` expose `queue`, `has_registered_stream`, and
@@ -206,11 +212,11 @@ limit is at least two, otherwise zero. With the CPU-count default, Steps get one
206
212
  fewer slot than before. Set it explicitly to zero to opt out. With one slot and
207
213
  no explicit reserve, Worker start logs one warning: RPCs may wait behind
208
214
  long-running Steps; use a limit of at least two or set the reserve explicitly.
209
- Flows must stay responsive; Dex cannot prioritize synchronous RPCs like workflow
210
- engines prioritize reads. The value must be nonnegative and less than
211
- `max_concurrent_tasks` after its CPU-count default is resolved; invalid values
212
- fail construction. RPC and Step handlers count together: WaitFor and Execute
213
- tasks, including local attempts, are declined with `NO_CAPACITY` when the busy
215
+ Flows must stay responsive because RPCs share Worker task capacity with Steps.
216
+ The value must be nonnegative and less than `max_concurrent_tasks` after its
217
+ CPU-count default is resolved; invalid values fail construction. RPC and Step
218
+ handlers count together. WaitFor and Execute tasks, including local attempts,
219
+ are declined with `NO_CAPACITY` when the busy
214
220
  count reaches the task limit minus the reserve. API-direct and transactional
215
221
  InvokeWorkerRPC tasks can use the full limit.
216
222
 
@@ -222,15 +228,19 @@ A decline lets Dex try another Worker without consuming an attempt. If all
222
228
  Workers remain at their Step limit, a Step can wait until the dispatch wait
223
229
  expires, then receive `NO_WORKER_AVAILABLE`, consume an attempt, and follow its
224
230
  retry policy, as when all Workers are full. Local attempts wait the local
225
- dispatch wait, then fall back according to their policy. Each queue still keeps
226
- its configured idle streams; a reserve decline does not close a stream or change
227
- registration readiness.
231
+ dispatch wait, then fall back according to their policy. A queue that declines
232
+ a Step for reserved RPC capacity keeps one additional idle stream until 30
233
+ seconds after its last decline. Other queues retain their configured count.
234
+ The Worker does not open the extra stream while all task slots are busy. Set
235
+ `idle_streams_per_queue` to at least two for heavy Step backlogs. A reserve decline
236
+ does not close a stream or change registration readiness.
228
237
 
229
238
  Each reserve decline records the Flow type through the dex._pull_worker logger
230
239
  at DEBUG level. Export the cumulative `pull_reserved_rpc_declines` Worker value
231
- to distinguish reserve pressure from full-capacity rejections. The count
232
- excludes full-capacity rejections and unknown names, never resets, and remains
233
- available after shutdown. Listening Workers return zero.
240
+ to distinguish reserve pressure from full-capacity rejections. The count excludes
241
+ full-capacity rejections and unknown names. It counts decline events, including
242
+ repeated declines of the same Step dispatch while Dex retries it. Read its rate
243
+ to assess reserve pressure. It never resets and remains available after shutdown.
234
244
 
235
245
  ## Flow state I/O
236
246
 
@@ -388,7 +398,7 @@ explicit codec only for a custom encoding or a type Registry cannot derive.
388
398
  partitions them by definition type.
389
399
 
390
400
  `Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
391
- Dex Server before opening their listener. Existing indexes return immediately;
401
+ Dex Server before opening outbound task streams. Existing indexes return immediately;
392
402
  failure or the default two-minute deadline aborts startup. An indexed
393
403
  `AttributeMap` must provide one fixed `index_key`.
394
404
 
@@ -784,8 +794,8 @@ environment:
784
794
  ./run-integration-tests.sh
785
795
  ```
786
796
 
787
- Set `DEX_INTEG_WORKER_MODE=pull` to run the suite with pull Workers instead of
788
- listening Workers. The default is `push`.
797
+ The suite always uses pull Workers. Set `DEX_INTEG_ENGINE=zd` to test against
798
+ server-zd, or leave it unset to use the Temporal-backed server.
789
799
 
790
800
  ### Measure integration coverage
791
801
 
@@ -858,9 +868,9 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#releases-monorepo-tags) for monorepo ta
858
868
  ## Server protocol compatibility
859
869
 
860
870
  Both `Worker` and `AsyncWorker` call `GetServerInfo`, negotiate the highest
861
- common protocol, synchronize Attribute indexes, and only then bind
862
- WorkerService. The initial Python SDK interval is `[1,1]`. Missing information,
863
- invalid or disjoint intervals, and RPC failures stop startup before binding.
871
+ common protocol, synchronize Attribute indexes, and only then open outbound
872
+ task streams. The initial Python SDK interval is `[1,1]`. Missing information,
873
+ invalid or disjoint intervals, and RPC failures stop startup before polling.
864
874
  The current Python SDK interval is `[1,3]`. Protocol 3 adds hard-delete
865
875
  Attributes. Workers record the negotiated version at startup and reject each
866
876
  handler result that contains a hard-delete Attribute write when it is below 3.
@@ -79,14 +79,17 @@ retain up to four idle credential threads. Sync token lookup must return
79
79
  promptly. By default, no authorization metadata is sent. Dex uses plaintext
80
80
  gRPC, so protect the network path that carries the token.
81
81
 
82
- ## Pull mode
82
+ ## Workers
83
83
 
84
- Set `WorkerOptions.pull` to run a `Worker` or `AsyncWorker` with outbound task
85
- streams. The Worker connects to the Dex server through `server_address` and
86
- does not open a listener. Advertise `worker.worker_target` through
87
- `ClientOptions.worker_target`. A single Flow can override the Client default
88
- with `StartFlowOptions(config_override=FlowConfig(worker_target=...))`; a
89
- SubFlow can use `SubFlowOptions` with the same `FlowConfig` override.
84
+ Workers receive tasks through outbound pull streams. `Worker` and `AsyncWorker`
85
+ need only an outbound connection to the Dex Server at `server_address`. Configure
86
+ capacity and shutdown with `WorkerOptions.pull`; its default is
87
+ `PullWorkerOptions()`. Clients and Flows use pull routing automatically.
88
+
89
+ StartFlow and SubFlow always select pull Workers. Updating Flow configuration
90
+ preserves its existing Worker target; an empty `FlowConfig()` changes nothing.
91
+ Before upgrading an application with active push Flows, use an SDK version that
92
+ supports Worker targets to migrate those Flows to pull routing.
90
93
 
91
94
  ```python
92
95
  import threading
@@ -102,10 +105,11 @@ worker = Worker(
102
105
  )
103
106
  worker_thread = threading.Thread(target=worker.start)
104
107
  worker_thread.start()
108
+ worker.wait_pull_registered(timeout=30)
105
109
  client = Client(
106
110
  registry,
107
111
  cache,
108
- ClientOptions(server_address="dex.example:8801", worker_target=worker.worker_target),
112
+ ClientOptions(server_address="dex.example:8801"),
109
113
  )
110
114
  try:
111
115
  client.start_flow(flow, flow_id, input_value)
@@ -119,13 +123,18 @@ The Worker polls its sticky queue and every registered Flow type queue. Flow
119
123
  type names must be unique within a Dex server. `max_concurrent_tasks` defaults
120
124
  to the CPU count, and `idle_streams_per_queue` defaults to one. At capacity,
121
125
  the Worker rejects an assignment so Dex can reassign it. Each stream runs one
122
- task at a time; Step heartbeat and stream writes use the same stream. After tasks finish, the Worker
123
- gracefully closes surplus idle streams while preserving readiness. After load drops,
126
+ task at a time; Step heartbeat and stream writes use the same stream. After
127
+ tasks finish, the Worker gracefully closes surplus idle streams while preserving
128
+ readiness. The SDK sends an upstream Pong every 20 seconds while a task runs and
129
+ stops before its final frame. This interval is internal and needs no Worker
130
+ option. After load drops,
124
131
  the steady-state bound is `max_concurrent_tasks + (1 + number of Flow types) *
125
- idle_streams_per_queue` streams. The transient peak during a burst remains bounded
126
- by the matching caps. Size these options for `matching.maxConcurrentTasksCap` per
127
- app Worker ID per matching owner (256 by default) and `matching.maxStreams` per
128
- Matching instance (100,000 by default).
132
+ idle_streams_per_queue` streams.
133
+ Allow up to one more stream per queue for 30 seconds after reserve declines,
134
+ or up to `1 + number of Flow types` additional streams. The transient peak
135
+ during a burst remains bounded by the matching caps. Size these options for
136
+ `matching.maxConcurrentTasksCap` per app Worker ID per matching owner (256 by
137
+ default) and `matching.maxStreams` per Matching instance (100,000 by default).
129
138
 
130
139
  The Worker rejects an unregistered Step or RPC by name. Dex retries that task
131
140
  on another pull Worker without consuming a Step attempt, including when the
@@ -139,10 +148,7 @@ The generated `app_worker_id` uses the first nonempty `DEX_WORKER_INSTANCE`,
139
148
  `POD_NAME`, `HOSTNAME`, or system hostname, plus eight random base32 characters.
140
149
  An override must uniquely identify one Worker process and change after restart.
141
150
  It must contain 1-63 ASCII letters, digits, dots, underscores, or hyphens and
142
- must not equal a registered Flow type. Pull mode cannot configure a bind address
143
- or an advertised address or headless target. Use `WorkerTarget(pull=True)` for
144
- Client defaults or Flow overrides when the Worker target is not directly in
145
- scope.
151
+ must not equal a registered Flow type.
146
152
 
147
153
  Pull streams reconnect after failures. A server-directed reconnect waits for
148
154
  an active task to finish, then waits up to `stop_drain_timeout` for the server
@@ -165,7 +171,7 @@ Worker ID queue to register at least once. Timeout raises `TimeoutError`;
165
171
  async cancellation cancels only that wait. Startup failures reach waiters.
166
172
  A permanent rejection before readiness raises `PullRegistrationError`, whose
167
173
  `queue` and `error` identify the rejected queue and original transport error.
168
- Listening mode and stop before readiness raise `RuntimeError`.
174
+ Stopping before readiness raises `RuntimeError`.
169
175
 
170
176
  Use `worker.pull_registration_state()` for subsequent readiness probes. Its
171
177
  immutable `queues` expose `queue`, `has_registered_stream`, and
@@ -190,11 +196,11 @@ limit is at least two, otherwise zero. With the CPU-count default, Steps get one
190
196
  fewer slot than before. Set it explicitly to zero to opt out. With one slot and
191
197
  no explicit reserve, Worker start logs one warning: RPCs may wait behind
192
198
  long-running Steps; use a limit of at least two or set the reserve explicitly.
193
- Flows must stay responsive; Dex cannot prioritize synchronous RPCs like workflow
194
- engines prioritize reads. The value must be nonnegative and less than
195
- `max_concurrent_tasks` after its CPU-count default is resolved; invalid values
196
- fail construction. RPC and Step handlers count together: WaitFor and Execute
197
- tasks, including local attempts, are declined with `NO_CAPACITY` when the busy
199
+ Flows must stay responsive because RPCs share Worker task capacity with Steps.
200
+ The value must be nonnegative and less than `max_concurrent_tasks` after its
201
+ CPU-count default is resolved; invalid values fail construction. RPC and Step
202
+ handlers count together. WaitFor and Execute tasks, including local attempts,
203
+ are declined with `NO_CAPACITY` when the busy
198
204
  count reaches the task limit minus the reserve. API-direct and transactional
199
205
  InvokeWorkerRPC tasks can use the full limit.
200
206
 
@@ -206,15 +212,19 @@ A decline lets Dex try another Worker without consuming an attempt. If all
206
212
  Workers remain at their Step limit, a Step can wait until the dispatch wait
207
213
  expires, then receive `NO_WORKER_AVAILABLE`, consume an attempt, and follow its
208
214
  retry policy, as when all Workers are full. Local attempts wait the local
209
- dispatch wait, then fall back according to their policy. Each queue still keeps
210
- its configured idle streams; a reserve decline does not close a stream or change
211
- registration readiness.
215
+ dispatch wait, then fall back according to their policy. A queue that declines
216
+ a Step for reserved RPC capacity keeps one additional idle stream until 30
217
+ seconds after its last decline. Other queues retain their configured count.
218
+ The Worker does not open the extra stream while all task slots are busy. Set
219
+ `idle_streams_per_queue` to at least two for heavy Step backlogs. A reserve decline
220
+ does not close a stream or change registration readiness.
212
221
 
213
222
  Each reserve decline records the Flow type through the dex._pull_worker logger
214
223
  at DEBUG level. Export the cumulative `pull_reserved_rpc_declines` Worker value
215
- to distinguish reserve pressure from full-capacity rejections. The count
216
- excludes full-capacity rejections and unknown names, never resets, and remains
217
- available after shutdown. Listening Workers return zero.
224
+ to distinguish reserve pressure from full-capacity rejections. The count excludes
225
+ full-capacity rejections and unknown names. It counts decline events, including
226
+ repeated declines of the same Step dispatch while Dex retries it. Read its rate
227
+ to assess reserve pressure. It never resets and remains available after shutdown.
218
228
 
219
229
  ## Flow state I/O
220
230
 
@@ -372,7 +382,7 @@ explicit codec only for a custom encoding or a type Registry cannot derive.
372
382
  partitions them by definition type.
373
383
 
374
384
  `Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
375
- Dex Server before opening their listener. Existing indexes return immediately;
385
+ Dex Server before opening outbound task streams. Existing indexes return immediately;
376
386
  failure or the default two-minute deadline aborts startup. An indexed
377
387
  `AttributeMap` must provide one fixed `index_key`.
378
388
 
@@ -768,8 +778,8 @@ environment:
768
778
  ./run-integration-tests.sh
769
779
  ```
770
780
 
771
- Set `DEX_INTEG_WORKER_MODE=pull` to run the suite with pull Workers instead of
772
- listening Workers. The default is `push`.
781
+ The suite always uses pull Workers. Set `DEX_INTEG_ENGINE=zd` to test against
782
+ server-zd, or leave it unset to use the Temporal-backed server.
773
783
 
774
784
  ### Measure integration coverage
775
785
 
@@ -842,9 +852,9 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#releases-monorepo-tags) for monorepo ta
842
852
  ## Server protocol compatibility
843
853
 
844
854
  Both `Worker` and `AsyncWorker` call `GetServerInfo`, negotiate the highest
845
- common protocol, synchronize Attribute indexes, and only then bind
846
- WorkerService. The initial Python SDK interval is `[1,1]`. Missing information,
847
- invalid or disjoint intervals, and RPC failures stop startup before binding.
855
+ common protocol, synchronize Attribute indexes, and only then open outbound
856
+ task streams. The initial Python SDK interval is `[1,1]`. Missing information,
857
+ invalid or disjoint intervals, and RPC failures stop startup before polling.
848
858
  The current Python SDK interval is `[1,3]`. Protocol 3 adds hard-delete
849
859
  Attributes. Workers record the negotiated version at startup and reject each
850
860
  handler result that contains a hard-delete Attribute write when it is below 3.
@@ -124,7 +124,7 @@ from dex.timer import Timer
124
124
  from dex.wait import Wait
125
125
  from dex.wait_options import WaitForAttributeOptions, WaitForStepCompletionOptions
126
126
  from dex.worker import Worker
127
- from dex.worker_options import PullWorkerOptions, WorkerOptions, WorkerTarget
127
+ from dex.worker_options import PullWorkerOptions, WorkerOptions
128
128
 
129
129
  __all__ = [
130
130
  "BOOL",
@@ -226,7 +226,6 @@ __all__ = [
226
226
  "Worker",
227
227
  "WorkerInvocationError",
228
228
  "WorkerOptions",
229
- "WorkerTarget",
230
229
  "PullWorkerOptions",
231
230
  "PullRegistrationError",
232
231
  "PullRegistrationState",
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import traceback
12
12
  from dataclasses import dataclass
13
- from typing import Any, Literal, cast
13
+ from typing import Literal, cast
14
14
 
15
15
  import grpc
16
16
  from google.protobuf import any_pb2
@@ -165,23 +165,6 @@ def _worker_code(details: pb.ServiceErrorResponse | None) -> grpc.StatusCode | N
165
165
  )
166
166
 
167
167
 
168
- def abort_worker_error(
169
- context: grpc.ServicerContext,
170
- error: BaseException,
171
- ) -> None:
172
- context.abort_with_status(rpc_status.to_status(_worker_error_status(error)))
173
-
174
-
175
- async def async_abort_worker_error(
176
- context: grpc.aio.ServicerContext[Any, Any],
177
- error: BaseException,
178
- ) -> None:
179
- # types-grpcio on 3.11 omits aio abort_with_status; runtime provides it.
180
- await cast(Any, context).abort_with_status(
181
- rpc_status.to_status(_worker_error_status(error))
182
- )
183
-
184
-
185
168
  def _worker_error_status(error: BaseException) -> status_pb2.Status:
186
169
  retry_after_error: RetryAfterError | None = None
187
170
  reported = error
@@ -16,6 +16,7 @@ import random
16
16
  import re
17
17
  import socket
18
18
  import threading
19
+ import time
19
20
  from concurrent.futures import ThreadPoolExecutor
20
21
  from datetime import datetime, timezone
21
22
  from typing import Any
@@ -37,6 +38,7 @@ from dex.worker_options import PullWorkerOptions
37
38
 
38
39
  _LOGGER = logging.getLogger(__name__)
39
40
  _GRPC_AIO_EOF = getattr(grpc.aio, "EOF")
41
+ _BUSY_PULL_STREAM_KEEPALIVE_SECONDS = 20
40
42
  _PERMANENT_CODES = {
41
43
  grpc.StatusCode.UNAUTHENTICATED,
42
44
  grpc.StatusCode.PERMISSION_DENIED,
@@ -86,6 +88,7 @@ class PullWorkerRuntime:
86
88
  executor: ThreadPoolExecutor | None,
87
89
  app_worker_id: str | None = None,
88
90
  credential_runner: AsyncCredentialRunner | None = None,
91
+ reserve_pressure_linger: float = 30.0,
89
92
  ) -> None:
90
93
  self._registry = registry
91
94
  self.app_worker_id = app_worker_id or resolve_app_worker_id(registry, options)
@@ -107,6 +110,9 @@ class PullWorkerRuntime:
107
110
  options.reserved_rpc_tasks is None and self.max_concurrent_tasks == 1
108
111
  )
109
112
  self.reserved_rpc_declines = 0
113
+ self._last_reserve_decline: dict[str, float] = {}
114
+ self._reserve_pressure_timers: dict[str, asyncio.TimerHandle] = {}
115
+ self._reserve_pressure_linger = reserve_pressure_linger
110
116
  self.idle_streams_per_queue = options.idle_streams_per_queue or 1
111
117
  self.stop_drain_timeout = options.stop_drain_timeout.total_seconds() or 30
112
118
  self._dispatcher = dispatcher
@@ -196,12 +202,26 @@ class PullWorkerRuntime:
196
202
  and not stream.is_half_closing
197
203
  for stream in self._streams
198
204
  )
199
- for _ in range(self.idle_streams_per_queue - idle):
205
+ desired_idle = self.desired_idle_streams_per_queue(queue)
206
+ creation_target = (
207
+ self.idle_streams_per_queue
208
+ if self._busy_tasks == self.max_concurrent_tasks
209
+ else desired_idle
210
+ )
211
+ for _ in range(creation_target - idle):
200
212
  stream = PullStream(self, stub, queue)
201
213
  self._streams.add(stream)
202
214
  stream.run_task = asyncio.create_task(stream.run())
203
215
  stream.run_task.add_done_callback(stream.on_done)
204
216
 
217
+ def desired_idle_streams_per_queue(self, queue: str) -> int:
218
+ last_decline = self._last_reserve_decline.get(queue)
219
+ return self.idle_streams_per_queue + int(
220
+ self.reserved_rpc_tasks > 0
221
+ and last_decline is not None
222
+ and time.monotonic() - last_decline < self._reserve_pressure_linger
223
+ )
224
+
205
225
  def _stream_done(self, stream: PullStream, completed: asyncio.Task[None]) -> None:
206
226
  self._streams.discard(stream)
207
227
  if not completed.cancelled():
@@ -210,7 +230,9 @@ class PullWorkerRuntime:
210
230
  _LOGGER.error("Worker task stream failed", exc_info=failure)
211
231
  self.signal()
212
232
 
213
- def accept_task(self, task: pb.PollWorkerTask | None = None) -> bool:
233
+ def accept_task(
234
+ self, task: pb.PollWorkerTask | None = None, queue: str | None = None
235
+ ) -> bool:
214
236
  if self._stopping is None or self._stopping.is_set():
215
237
  return False
216
238
  if self._busy_tasks >= self.max_concurrent_tasks:
@@ -223,6 +245,18 @@ class PullWorkerRuntime:
223
245
  and self.unknown_task_reason(task) is None
224
246
  ):
225
247
  self.reserved_rpc_declines += 1
248
+ queue_name = queue or self.app_worker_id
249
+ self._last_reserve_decline[queue_name] = time.monotonic()
250
+ existing_timer = self._reserve_pressure_timers.pop(queue_name, None)
251
+ if existing_timer is not None:
252
+ existing_timer.cancel()
253
+ if self._loop is not None:
254
+ self._reserve_pressure_timers[queue_name] = self._loop.call_later(
255
+ self._reserve_pressure_linger,
256
+ self._expire_reserve_pressure,
257
+ queue_name,
258
+ )
259
+ self.signal()
226
260
  request = task.wait_for if task.HasField("wait_for") else task.execute
227
261
  _LOGGER.debug(
228
262
  "decline Step task for reserved RPC capacity: %s", request.flow_type
@@ -232,6 +266,21 @@ class PullWorkerRuntime:
232
266
  self.signal()
233
267
  return True
234
268
 
269
+ def _expire_reserve_pressure(self, queue: str) -> None:
270
+ last_decline = self._last_reserve_decline.get(queue)
271
+ if last_decline is None:
272
+ return
273
+ remaining = self._reserve_pressure_linger - (time.monotonic() - last_decline)
274
+ if remaining > 0:
275
+ assert self._loop is not None
276
+ self._reserve_pressure_timers[queue] = self._loop.call_later(
277
+ remaining, self._expire_reserve_pressure, queue
278
+ )
279
+ return
280
+ self._last_reserve_decline.pop(queue, None)
281
+ self._reserve_pressure_timers.pop(queue, None)
282
+ self.signal()
283
+
235
284
  def finish_task(self) -> None:
236
285
  self._busy_tasks -= 1
237
286
  self.signal()
@@ -250,6 +299,9 @@ class PullWorkerRuntime:
250
299
  asyncio.get_running_loop().time() + self.stop_drain_timeout
251
300
  )
252
301
  self._stopping.set()
302
+ for timer in self._reserve_pressure_timers.values():
303
+ timer.cancel()
304
+ self._reserve_pressure_timers.clear()
253
305
  try:
254
306
  for stream in tuple(self._streams):
255
307
  stream.drain()
@@ -317,6 +369,8 @@ class PullStream:
317
369
  self._active = threading.Event()
318
370
  self._call: Any = None
319
371
  self._send_lock = asyncio.Lock()
372
+ self._keepalive_task: asyncio.Task[None] | None = None
373
+ self._is_final_frame_sent = False
320
374
  self._is_reconnecting = False
321
375
  self._stream_metadata: Any = None
322
376
  self._deadline_handle: asyncio.TimerHandle | None = None
@@ -424,6 +478,7 @@ class PullStream:
424
478
  if self.runtime.is_stopping:
425
479
  return False
426
480
  self._supports_unknown_name_rejection = False
481
+ self._is_final_frame_sent = False
427
482
  self._stream_metadata = None
428
483
  if self.runtime._credential_runner is None:
429
484
  self._call = self.stub.PollWorkerTask()
@@ -471,9 +526,11 @@ class PullStream:
471
526
  and frame.WhichOneof("frame") == "task"
472
527
  ):
473
528
  await self._reject_task(frame.task)
474
- self.is_half_closing = True
475
- self.runtime.signal()
476
- await self._call.done_writing()
529
+ async with self._send_lock:
530
+ self._stop_keepalive()
531
+ self.is_half_closing = True
532
+ self.runtime.signal()
533
+ await self._call.done_writing()
477
534
  await self._await_stream_eof(reader)
478
535
  reader = None
479
536
  self._is_reconnecting = not self._is_retiring
@@ -515,6 +572,8 @@ class PullStream:
515
572
  self._is_reconnecting = True
516
573
  return was_registered
517
574
  finally:
575
+ async with self._send_lock:
576
+ self._stop_keepalive()
518
577
  retirement.cancel()
519
578
  await asyncio.gather(retirement, return_exceptions=True)
520
579
  self.is_half_closing = False
@@ -586,12 +645,18 @@ class PullStream:
586
645
  if unknown_reason is not None:
587
646
  await self._reject_task(task, unknown_reason)
588
647
  return
589
- if self.is_busy or self.is_draining or not self.runtime.accept_task(task):
648
+ if (
649
+ self.is_busy
650
+ or self.is_draining
651
+ or not self.runtime.accept_task(task, self.queue)
652
+ ):
590
653
  await self._reject_task(task)
591
654
  return
592
655
  self.is_busy = True
593
656
  self.task_id = task.task_id
594
657
  self._active.set()
658
+ self._is_final_frame_sent = False
659
+ self._keepalive_task = asyncio.create_task(self._send_busy_keepalives())
595
660
  self.handler_task = asyncio.create_task(self._execute(task))
596
661
  if task.HasField("deadline"):
597
662
  deadline = task.deadline.ToDatetime(tzinfo=timezone.utc)
@@ -716,14 +781,68 @@ class PullStream:
716
781
  async def _send_frame(self, frame: pb.PollWorkerTaskFrame) -> None:
717
782
  await self._send(pb.PollWorkerTaskRequest(task_frame=frame))
718
783
 
784
+ async def _send_busy_keepalives(self) -> None:
785
+ try:
786
+ while True:
787
+ await asyncio.sleep(_BUSY_PULL_STREAM_KEEPALIVE_SECONDS)
788
+ async with self._send_lock:
789
+ if self._is_final_frame_sent or self.is_half_closing:
790
+ return
791
+ await self._call.write(
792
+ pb.PollWorkerTaskRequest(pong=pb.PollWorkerTaskPong())
793
+ )
794
+ except asyncio.CancelledError:
795
+ return
796
+ except Exception:
797
+ _LOGGER.exception("send Worker keepalive Pong failed")
798
+
799
+ def _stop_keepalive(self) -> None:
800
+ if self._keepalive_task is not None:
801
+ self._keepalive_task.cancel()
802
+ self._keepalive_task = None
803
+
719
804
  async def _send(self, request: pb.PollWorkerTaskRequest) -> None:
720
805
  async with self._send_lock:
806
+ if request.HasField("task_frame"):
807
+ frame = request.task_frame
808
+ is_progress_frame = (
809
+ frame.HasField("wait_for")
810
+ and frame.wait_for.WhichOneof("output")
811
+ in ("heartbeat", "stream_write")
812
+ or frame.HasField("execute")
813
+ and frame.execute.WhichOneof("output")
814
+ in ("heartbeat", "stream_write")
815
+ )
816
+ if (
817
+ frame.task_id != self.task_id
818
+ or self._is_final_frame_sent
819
+ or not self._active.is_set()
820
+ and is_progress_frame
821
+ ):
822
+ return
823
+ if (
824
+ frame.HasField("rpc")
825
+ or frame.HasField("worker_error")
826
+ or frame.HasField("wait_for")
827
+ and frame.wait_for.WhichOneof("output") == "result"
828
+ or frame.HasField("execute")
829
+ and frame.execute.WhichOneof("output") == "result"
830
+ ):
831
+ self._stop_keepalive()
832
+ self._is_final_frame_sent = True
833
+ if request.HasField("pong") and (
834
+ self._is_final_frame_sent or self.is_half_closing
835
+ ):
836
+ return
721
837
  await self._call.write(request)
722
838
 
723
839
  async def _finish_handler(self) -> None:
724
840
  if self.handler_task is None:
725
841
  return
726
842
  await asyncio.gather(self.handler_task, return_exceptions=True)
843
+ async with self._send_lock:
844
+ self._stop_keepalive()
845
+ self._is_final_frame_sent = False
727
846
  if self._deadline_handle is not None:
728
847
  self._deadline_handle.cancel()
729
848
  self._deadline_handle = None
@@ -744,7 +863,7 @@ class PullStream:
744
863
  and not candidate.is_draining
745
864
  for candidate in self.runtime._streams
746
865
  )
747
- if idle_replacements >= self.runtime.idle_streams_per_queue:
866
+ if idle_replacements >= self.runtime.desired_idle_streams_per_queue(self.queue):
748
867
  self.is_draining = True
749
868
  self._is_retiring = True
750
869
  self._retirement_requested.set()