dex-python-sdk 1.14.1__tar.gz → 1.16.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.14.1 → dex_python_sdk-1.16.0}/PKG-INFO +76 -1
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/README.md +75 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/__init__.py +14 -7
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_credential_transport.py +103 -4
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_pull_worker.py +66 -4
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/async_worker.py +17 -0
- dex_python_sdk-1.16.0/dex/credential.py +547 -0
- dex_python_sdk-1.16.0/dex/dexpb/dex_pb2.py +551 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/dexpb/dex_pb2.pyi +120 -42
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/dexpb/dex_pb2_grpc.py +14 -1
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/flow_options.py +23 -1
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/worker.py +17 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/worker_options.py +29 -2
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/pyproject.toml +1 -1
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/Cargo.lock +710 -12
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/Cargo.toml +20 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +4 -0
- dex_python_sdk-1.16.0/sdk-rust/crates/dex-blob-cache/README.md +9 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +15 -3
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +152 -31
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +61 -34
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +83 -6
- dex_python_sdk-1.16.0/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +37 -0
- dex_python_sdk-1.16.0/sdk-rust/crates/dex-blob-cache-python/README.md +5 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +2 -0
- dex_python_sdk-1.14.1/dex/credential.py +0 -146
- dex_python_sdk-1.14.1/dex/dexpb/dex_pb2.py +0 -545
- dex_python_sdk-1.14.1/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -17
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/LEGACY_NOTICES.md +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/LICENSE +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_async_value_hydrator.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_async_worker_dispatcher.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_async_worker_service.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_grpc_errors.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_invocation_context.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_native.pyi +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_server_protocol.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_utils.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_value_hydrator.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_value_mapper.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_worker_dispatcher.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_worker_service.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/async_client.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/attribute.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/attribute_match.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/blob_cache.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/channel.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/client.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/client_options.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/codec.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/condition.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/context.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/dexpb/__init__.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/flow.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/flow_config.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/flow_info.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/flow_result.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/pull_registration.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/py.typed +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/rpc_invoke_options.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/runtime_errors.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/step.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/step_execution.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/stream.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/subflow.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/timer.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/wait.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/wait_options.py +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
- {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dex-python-sdk
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.16.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
|
|
@@ -25,6 +25,47 @@ checks its mtime and size at most once per second, and reloads it after rotation
|
|
|
25
25
|
Whitespace around the token is removed. An unreadable or empty file fails calls
|
|
26
26
|
until it is restored; the SDK does not send an old token.
|
|
27
27
|
|
|
28
|
+
Use `ApiKeyCredential` when the application holds a long-lived API key and a
|
|
29
|
+
token endpoint issues short-lived access tokens scoped to a server ID. Supply
|
|
30
|
+
the token URL, server ID, and API key explicitly; the server ID is never derived
|
|
31
|
+
from the endpoint host. The endpoint must use HTTPS, except loopback HTTP on
|
|
32
|
+
`localhost`, `127.0.0.0/8`, or `::1` for local development.
|
|
33
|
+
Loopback HTTP bypasses environment proxies so the API key stays on the host.
|
|
34
|
+
HTTPS uses configured proxies with TLS through CONNECT. Token endpoint redirects
|
|
35
|
+
are refused, including redirects that preserve the POST method.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from dex import ApiKeyCredential, Client, ClientOptions
|
|
39
|
+
|
|
40
|
+
credential = ApiKeyCredential(
|
|
41
|
+
"https://auth.example.com/token", "dex-server", "application-api-key"
|
|
42
|
+
)
|
|
43
|
+
client = Client(registry, cache, ClientOptions(credential=credential))
|
|
44
|
+
try:
|
|
45
|
+
client.health_check()
|
|
46
|
+
finally:
|
|
47
|
+
client.close()
|
|
48
|
+
credential.close()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The first call exchanges the key for a token. After 80% of its lifetime, a
|
|
52
|
+
single background refresh begins while calls continue using the valid token.
|
|
53
|
+
Callers waiting for an expired token share the exchange. Failed refreshes retry
|
|
54
|
+
no more often than every five seconds while the previous token remains valid.
|
|
55
|
+
Each exchange has a 10-second deadline by default, configurable through
|
|
56
|
+
`exchange_timeout`. It covers connecting, sending, reading, and parsing. A Dex
|
|
57
|
+
`UNAUTHENTICATED` response discards the rejected token
|
|
58
|
+
and retries a unary call once with a fresh token. Pull Worker streams reconnect
|
|
59
|
+
once immediately with a fresh token; a second rejection uses the normal backoff.
|
|
60
|
+
The credential is thread-safe.
|
|
61
|
+
It redacts the API key, issued access tokens, and JWT-shaped strings from
|
|
62
|
+
exchange errors, including endpoint details and transport causes. Client
|
|
63
|
+
calls raise `ApiKeyExchangeError` unchanged; pull Workers log it and retry.
|
|
64
|
+
Closing the credential is optional. After all Clients and Workers sharing it
|
|
65
|
+
are closed, `close()` prevents further token calls and waits at most until the
|
|
66
|
+
current exchange deadline. An uninterruptible network operation may continue
|
|
67
|
+
in its daemon thread after that deadline.
|
|
68
|
+
|
|
28
69
|
```python
|
|
29
70
|
from dex import Client, ClientOptions, FileCredential, Worker, WorkerOptions
|
|
30
71
|
|
|
@@ -157,6 +198,40 @@ applies before and after readiness. Another registered stream for that queue
|
|
|
157
198
|
prevents shutdown. The default is False and retries indefinitely. Always call
|
|
158
199
|
`stop()` or `close()` afterward; both remain safe after `start()` returns.
|
|
159
200
|
|
|
201
|
+
### Reserved RPC capacity
|
|
202
|
+
|
|
203
|
+
Set `reserved_rpc_tasks` when synchronous RPC latency must stay responsive
|
|
204
|
+
behind long-running Steps. When unset, it reserves one slot if the resolved task
|
|
205
|
+
limit is at least two, otherwise zero. With the CPU-count default, Steps get one
|
|
206
|
+
fewer slot than before. Set it explicitly to zero to opt out. With one slot and
|
|
207
|
+
no explicit reserve, Worker start logs one warning: RPCs may wait behind
|
|
208
|
+
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
|
|
214
|
+
count reaches the task limit minus the reserve. API-direct and transactional
|
|
215
|
+
InvokeWorkerRPC tasks can use the full limit.
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
pull = PullWorkerOptions(max_concurrent_tasks=8, reserved_rpc_tasks=2)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A decline lets Dex try another Worker without consuming an attempt. If all
|
|
222
|
+
Workers remain at their Step limit, a Step can wait until the dispatch wait
|
|
223
|
+
expires, then receive `NO_WORKER_AVAILABLE`, consume an attempt, and follow its
|
|
224
|
+
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.
|
|
228
|
+
|
|
229
|
+
Each reserve decline records the Flow type through the dex._pull_worker logger
|
|
230
|
+
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.
|
|
234
|
+
|
|
160
235
|
## Flow state I/O
|
|
161
236
|
|
|
162
237
|
Applications read and write Flow state through typed RPCs. Client and AsyncClient
|
|
@@ -9,6 +9,47 @@ checks its mtime and size at most once per second, and reloads it after rotation
|
|
|
9
9
|
Whitespace around the token is removed. An unreadable or empty file fails calls
|
|
10
10
|
until it is restored; the SDK does not send an old token.
|
|
11
11
|
|
|
12
|
+
Use `ApiKeyCredential` when the application holds a long-lived API key and a
|
|
13
|
+
token endpoint issues short-lived access tokens scoped to a server ID. Supply
|
|
14
|
+
the token URL, server ID, and API key explicitly; the server ID is never derived
|
|
15
|
+
from the endpoint host. The endpoint must use HTTPS, except loopback HTTP on
|
|
16
|
+
`localhost`, `127.0.0.0/8`, or `::1` for local development.
|
|
17
|
+
Loopback HTTP bypasses environment proxies so the API key stays on the host.
|
|
18
|
+
HTTPS uses configured proxies with TLS through CONNECT. Token endpoint redirects
|
|
19
|
+
are refused, including redirects that preserve the POST method.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from dex import ApiKeyCredential, Client, ClientOptions
|
|
23
|
+
|
|
24
|
+
credential = ApiKeyCredential(
|
|
25
|
+
"https://auth.example.com/token", "dex-server", "application-api-key"
|
|
26
|
+
)
|
|
27
|
+
client = Client(registry, cache, ClientOptions(credential=credential))
|
|
28
|
+
try:
|
|
29
|
+
client.health_check()
|
|
30
|
+
finally:
|
|
31
|
+
client.close()
|
|
32
|
+
credential.close()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The first call exchanges the key for a token. After 80% of its lifetime, a
|
|
36
|
+
single background refresh begins while calls continue using the valid token.
|
|
37
|
+
Callers waiting for an expired token share the exchange. Failed refreshes retry
|
|
38
|
+
no more often than every five seconds while the previous token remains valid.
|
|
39
|
+
Each exchange has a 10-second deadline by default, configurable through
|
|
40
|
+
`exchange_timeout`. It covers connecting, sending, reading, and parsing. A Dex
|
|
41
|
+
`UNAUTHENTICATED` response discards the rejected token
|
|
42
|
+
and retries a unary call once with a fresh token. Pull Worker streams reconnect
|
|
43
|
+
once immediately with a fresh token; a second rejection uses the normal backoff.
|
|
44
|
+
The credential is thread-safe.
|
|
45
|
+
It redacts the API key, issued access tokens, and JWT-shaped strings from
|
|
46
|
+
exchange errors, including endpoint details and transport causes. Client
|
|
47
|
+
calls raise `ApiKeyExchangeError` unchanged; pull Workers log it and retry.
|
|
48
|
+
Closing the credential is optional. After all Clients and Workers sharing it
|
|
49
|
+
are closed, `close()` prevents further token calls and waits at most until the
|
|
50
|
+
current exchange deadline. An uninterruptible network operation may continue
|
|
51
|
+
in its daemon thread after that deadline.
|
|
52
|
+
|
|
12
53
|
```python
|
|
13
54
|
from dex import Client, ClientOptions, FileCredential, Worker, WorkerOptions
|
|
14
55
|
|
|
@@ -141,6 +182,40 @@ applies before and after readiness. Another registered stream for that queue
|
|
|
141
182
|
prevents shutdown. The default is False and retries indefinitely. Always call
|
|
142
183
|
`stop()` or `close()` afterward; both remain safe after `start()` returns.
|
|
143
184
|
|
|
185
|
+
### Reserved RPC capacity
|
|
186
|
+
|
|
187
|
+
Set `reserved_rpc_tasks` when synchronous RPC latency must stay responsive
|
|
188
|
+
behind long-running Steps. When unset, it reserves one slot if the resolved task
|
|
189
|
+
limit is at least two, otherwise zero. With the CPU-count default, Steps get one
|
|
190
|
+
fewer slot than before. Set it explicitly to zero to opt out. With one slot and
|
|
191
|
+
no explicit reserve, Worker start logs one warning: RPCs may wait behind
|
|
192
|
+
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
|
|
198
|
+
count reaches the task limit minus the reserve. API-direct and transactional
|
|
199
|
+
InvokeWorkerRPC tasks can use the full limit.
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
pull = PullWorkerOptions(max_concurrent_tasks=8, reserved_rpc_tasks=2)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A decline lets Dex try another Worker without consuming an attempt. If all
|
|
206
|
+
Workers remain at their Step limit, a Step can wait until the dispatch wait
|
|
207
|
+
expires, then receive `NO_WORKER_AVAILABLE`, consume an attempt, and follow its
|
|
208
|
+
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.
|
|
212
|
+
|
|
213
|
+
Each reserve decline records the Flow type through the dex._pull_worker logger
|
|
214
|
+
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.
|
|
218
|
+
|
|
144
219
|
## Flow state I/O
|
|
145
220
|
|
|
146
221
|
Applications read and write Flow state through typed RPCs. Client and AsyncClient
|
|
@@ -24,7 +24,6 @@ from dex.blob_cache import BlobCache, BlobCacheConfig, open_blob_cache
|
|
|
24
24
|
from dex.channel import Channel, ChannelMap, ChannelMapLoad, ChannelMessage
|
|
25
25
|
from dex.client import Client
|
|
26
26
|
from dex.client_options import ClientOptions
|
|
27
|
-
from dex.credential import Credential, FileCredential
|
|
28
27
|
from dex.codec import (
|
|
29
28
|
BOOL,
|
|
30
29
|
BYTES,
|
|
@@ -39,6 +38,12 @@ from dex.codec import (
|
|
|
39
38
|
)
|
|
40
39
|
from dex.condition import ConditionCombination
|
|
41
40
|
from dex.context import AsyncContext, Context, RecoveryErrorInfo
|
|
41
|
+
from dex.credential import (
|
|
42
|
+
ApiKeyCredential,
|
|
43
|
+
ApiKeyExchangeError,
|
|
44
|
+
Credential,
|
|
45
|
+
FileCredential,
|
|
46
|
+
)
|
|
42
47
|
from dex.flow import Flow, PersistenceSchema, Registry, RPCResult, rpc
|
|
43
48
|
from dex.flow_config import ActiveStepSearchMode, FlowConfig
|
|
44
49
|
from dex.flow_info import (
|
|
@@ -62,6 +67,12 @@ from dex.flow_options import (
|
|
|
62
67
|
TimeTravelType,
|
|
63
68
|
)
|
|
64
69
|
from dex.flow_result import FlowResult, StepCompletion
|
|
70
|
+
from dex.pull_registration import (
|
|
71
|
+
PullQueueRegistrationState,
|
|
72
|
+
PullRegistrationError,
|
|
73
|
+
PullRegistrationState,
|
|
74
|
+
)
|
|
75
|
+
from dex.rpc_invoke_options import RPCInvokeOptions
|
|
65
76
|
from dex.runtime_errors import (
|
|
66
77
|
AttributeMapNotLoadedError,
|
|
67
78
|
ChannelMessageNotFoundError,
|
|
@@ -80,7 +91,6 @@ from dex.runtime_errors import (
|
|
|
80
91
|
ValueMappingError,
|
|
81
92
|
WorkerInvocationError,
|
|
82
93
|
)
|
|
83
|
-
from dex.rpc_invoke_options import RPCInvokeOptions
|
|
84
94
|
from dex.step import (
|
|
85
95
|
RetryPolicy,
|
|
86
96
|
Step,
|
|
@@ -113,11 +123,6 @@ from dex.timer import Timer
|
|
|
113
123
|
from dex.wait import Wait
|
|
114
124
|
from dex.wait_options import WaitForAttributeOptions, WaitForStepCompletionOptions
|
|
115
125
|
from dex.worker import Worker
|
|
116
|
-
from dex.pull_registration import (
|
|
117
|
-
PullRegistrationError,
|
|
118
|
-
PullRegistrationState,
|
|
119
|
-
PullQueueRegistrationState,
|
|
120
|
-
)
|
|
121
126
|
from dex.worker_options import PullWorkerOptions, WorkerOptions, WorkerTarget
|
|
122
127
|
|
|
123
128
|
__all__ = [
|
|
@@ -138,6 +143,8 @@ __all__ = [
|
|
|
138
143
|
"AsyncClient",
|
|
139
144
|
"AsyncBufferedTextStream",
|
|
140
145
|
"AsyncWorker",
|
|
146
|
+
"ApiKeyCredential",
|
|
147
|
+
"ApiKeyExchangeError",
|
|
141
148
|
"BlobCache",
|
|
142
149
|
"BlobCacheConfig",
|
|
143
150
|
"BufferedTextStream",
|
|
@@ -16,6 +16,7 @@ import asyncio
|
|
|
16
16
|
import inspect
|
|
17
17
|
import queue
|
|
18
18
|
import threading
|
|
19
|
+
import time
|
|
19
20
|
from collections.abc import Callable
|
|
20
21
|
from concurrent.futures import Future
|
|
21
22
|
from typing import Any, NamedTuple, cast
|
|
@@ -168,6 +169,38 @@ def credential_deadline_error() -> grpc.aio.AioRpcError:
|
|
|
168
169
|
)
|
|
169
170
|
|
|
170
171
|
|
|
172
|
+
class _SyncCredentialDeadlineError(grpc.RpcError):
|
|
173
|
+
def code(self) -> grpc.StatusCode:
|
|
174
|
+
return grpc.StatusCode.DEADLINE_EXCEEDED
|
|
175
|
+
|
|
176
|
+
def details(self) -> str:
|
|
177
|
+
return "credential lookup exceeded RPC deadline"
|
|
178
|
+
|
|
179
|
+
def trailing_metadata(self) -> tuple[Any, ...]:
|
|
180
|
+
return ()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _bounded_credential_metadata(
|
|
184
|
+
credential: Credential, metadata: Any, timeout: float | None
|
|
185
|
+
) -> Any:
|
|
186
|
+
if timeout is not None and timeout <= 0:
|
|
187
|
+
raise _SyncCredentialDeadlineError()
|
|
188
|
+
bounded_token = getattr(credential, "_token_with_timeout", None)
|
|
189
|
+
if not callable(bounded_token) or timeout is None:
|
|
190
|
+
return credential_metadata(credential, metadata)
|
|
191
|
+
try:
|
|
192
|
+
token = bounded_token(timeout)
|
|
193
|
+
except TimeoutError:
|
|
194
|
+
raise _SyncCredentialDeadlineError() from None
|
|
195
|
+
return tuple(metadata or ()) + (("authorization", "Bearer " + token),)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _reject_credential_token(credential: Credential, metadata: Any) -> None:
|
|
199
|
+
reject_token = getattr(credential, "_reject_token", None)
|
|
200
|
+
if callable(reject_token):
|
|
201
|
+
reject_token(metadata[-1][1][7:])
|
|
202
|
+
|
|
203
|
+
|
|
171
204
|
class _SyncCredentialInterceptor(grpc.UnaryUnaryClientInterceptor):
|
|
172
205
|
def __init__(self, credential: Credential) -> None:
|
|
173
206
|
self._credential = credential
|
|
@@ -178,15 +211,49 @@ class _SyncCredentialInterceptor(grpc.UnaryUnaryClientInterceptor):
|
|
|
178
211
|
client_call_details: grpc.ClientCallDetails,
|
|
179
212
|
request: Any,
|
|
180
213
|
) -> Any:
|
|
214
|
+
started_at = time.monotonic()
|
|
215
|
+
timeout = client_call_details.timeout
|
|
216
|
+
metadata = _bounded_credential_metadata(
|
|
217
|
+
self._credential, client_call_details.metadata, timeout
|
|
218
|
+
)
|
|
219
|
+
if timeout is not None:
|
|
220
|
+
timeout -= time.monotonic() - started_at
|
|
221
|
+
if timeout <= 0:
|
|
222
|
+
raise _SyncCredentialDeadlineError()
|
|
181
223
|
details = _SyncCallDetails(
|
|
182
224
|
client_call_details.method,
|
|
183
|
-
|
|
184
|
-
|
|
225
|
+
timeout,
|
|
226
|
+
metadata,
|
|
185
227
|
client_call_details.credentials,
|
|
186
228
|
client_call_details.wait_for_ready,
|
|
187
229
|
client_call_details.compression,
|
|
188
230
|
)
|
|
189
|
-
|
|
231
|
+
try:
|
|
232
|
+
response = continuation(details, request)
|
|
233
|
+
except grpc.RpcError as error:
|
|
234
|
+
if error.code() != grpc.StatusCode.UNAUTHENTICATED:
|
|
235
|
+
raise
|
|
236
|
+
if not callable(getattr(self._credential, "_reject_token", None)):
|
|
237
|
+
raise
|
|
238
|
+
else:
|
|
239
|
+
if response.code() != grpc.StatusCode.UNAUTHENTICATED:
|
|
240
|
+
return response
|
|
241
|
+
if not callable(getattr(self._credential, "_reject_token", None)):
|
|
242
|
+
return response
|
|
243
|
+
_reject_credential_token(self._credential, details.metadata)
|
|
244
|
+
if client_call_details.timeout is not None:
|
|
245
|
+
timeout = client_call_details.timeout - (time.monotonic() - started_at)
|
|
246
|
+
retry_details = details._replace(
|
|
247
|
+
metadata=_bounded_credential_metadata(
|
|
248
|
+
self._credential, client_call_details.metadata, timeout
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
if client_call_details.timeout is not None:
|
|
252
|
+
timeout = client_call_details.timeout - (time.monotonic() - started_at)
|
|
253
|
+
if timeout <= 0:
|
|
254
|
+
raise _SyncCredentialDeadlineError()
|
|
255
|
+
retry_details = retry_details._replace(timeout=timeout)
|
|
256
|
+
return continuation(retry_details, request)
|
|
190
257
|
|
|
191
258
|
|
|
192
259
|
class _AsyncCredentialInterceptor(grpc.aio.UnaryUnaryClientInterceptor):
|
|
@@ -216,7 +283,39 @@ class _AsyncCredentialInterceptor(grpc.aio.UnaryUnaryClientInterceptor):
|
|
|
216
283
|
client_call_details.credentials,
|
|
217
284
|
client_call_details.wait_for_ready,
|
|
218
285
|
)
|
|
219
|
-
|
|
286
|
+
call = await continuation(details, request)
|
|
287
|
+
try:
|
|
288
|
+
await call
|
|
289
|
+
except grpc.aio.AioRpcError as error:
|
|
290
|
+
if error.code() != grpc.StatusCode.UNAUTHENTICATED:
|
|
291
|
+
raise
|
|
292
|
+
if not callable(getattr(self._runner._credential, "_reject_token", None)):
|
|
293
|
+
raise
|
|
294
|
+
_reject_credential_token(self._runner._credential, metadata)
|
|
295
|
+
if client_call_details.timeout is not None:
|
|
296
|
+
remaining_timeout = client_call_details.timeout - (
|
|
297
|
+
loop.time() - started_at
|
|
298
|
+
)
|
|
299
|
+
if remaining_timeout <= 0:
|
|
300
|
+
raise credential_deadline_error() from None
|
|
301
|
+
retry_metadata = await async_credential_metadata(
|
|
302
|
+
self._runner, client_call_details.metadata, remaining_timeout
|
|
303
|
+
)
|
|
304
|
+
if client_call_details.timeout is not None:
|
|
305
|
+
remaining_timeout = client_call_details.timeout - (
|
|
306
|
+
loop.time() - started_at
|
|
307
|
+
)
|
|
308
|
+
if remaining_timeout <= 0:
|
|
309
|
+
raise credential_deadline_error() from None
|
|
310
|
+
retry_details = grpc.aio.ClientCallDetails(
|
|
311
|
+
client_call_details.method,
|
|
312
|
+
remaining_timeout,
|
|
313
|
+
retry_metadata,
|
|
314
|
+
client_call_details.credentials,
|
|
315
|
+
client_call_details.wait_for_ready,
|
|
316
|
+
)
|
|
317
|
+
return await continuation(retry_details, request)
|
|
318
|
+
return call
|
|
220
319
|
|
|
221
320
|
|
|
222
321
|
def sync_channel(server_address: str, credential: Credential | None) -> grpc.Channel:
|
|
@@ -22,7 +22,11 @@ from typing import Any
|
|
|
22
22
|
|
|
23
23
|
import grpc
|
|
24
24
|
|
|
25
|
-
from dex._credential_transport import
|
|
25
|
+
from dex._credential_transport import (
|
|
26
|
+
AsyncCredentialRunner,
|
|
27
|
+
_reject_credential_token,
|
|
28
|
+
async_credential_metadata,
|
|
29
|
+
)
|
|
26
30
|
from dex._grpc_errors import _worker_error_status
|
|
27
31
|
from dex._server_protocol import sdk_version
|
|
28
32
|
from dex.dexpb import dex_pb2 as pb
|
|
@@ -94,6 +98,15 @@ class PullWorkerRuntime:
|
|
|
94
98
|
self._permanent_error: PullRegistrationError | None = None
|
|
95
99
|
self._rejection_stop_task: asyncio.Task[None] | None = None
|
|
96
100
|
self.max_concurrent_tasks = options.max_concurrent_tasks or os.cpu_count() or 1
|
|
101
|
+
self.reserved_rpc_tasks = (
|
|
102
|
+
options.reserved_rpc_tasks
|
|
103
|
+
if options.reserved_rpc_tasks is not None
|
|
104
|
+
else int(self.max_concurrent_tasks >= 2)
|
|
105
|
+
)
|
|
106
|
+
self._should_warn_rpc_capacity = (
|
|
107
|
+
options.reserved_rpc_tasks is None and self.max_concurrent_tasks == 1
|
|
108
|
+
)
|
|
109
|
+
self.reserved_rpc_declines = 0
|
|
97
110
|
self.idle_streams_per_queue = options.idle_streams_per_queue or 1
|
|
98
111
|
self.stop_drain_timeout = options.stop_drain_timeout.total_seconds() or 30
|
|
99
112
|
self._dispatcher = dispatcher
|
|
@@ -132,6 +145,10 @@ class PullWorkerRuntime:
|
|
|
132
145
|
return None
|
|
133
146
|
|
|
134
147
|
async def run(self) -> None:
|
|
148
|
+
if self._should_warn_rpc_capacity:
|
|
149
|
+
_LOGGER.warning(
|
|
150
|
+
"RPCs may wait behind long-running Steps; use max_concurrent_tasks >= 2 or set reserved_rpc_tasks explicitly"
|
|
151
|
+
)
|
|
135
152
|
self._loop = asyncio.get_running_loop()
|
|
136
153
|
self._loop_thread_id = threading.get_ident()
|
|
137
154
|
self._wake = asyncio.Event()
|
|
@@ -193,11 +210,24 @@ class PullWorkerRuntime:
|
|
|
193
210
|
_LOGGER.error("Worker task stream failed", exc_info=failure)
|
|
194
211
|
self.signal()
|
|
195
212
|
|
|
196
|
-
def accept_task(self) -> bool:
|
|
213
|
+
def accept_task(self, task: pb.PollWorkerTask | None = None) -> bool:
|
|
197
214
|
if self._stopping is None or self._stopping.is_set():
|
|
198
215
|
return False
|
|
199
216
|
if self._busy_tasks >= self.max_concurrent_tasks:
|
|
200
217
|
return False
|
|
218
|
+
if (
|
|
219
|
+
task is not None
|
|
220
|
+
and task.WhichOneof("request") in ("wait_for", "execute")
|
|
221
|
+
and self.reserved_rpc_tasks > 0
|
|
222
|
+
and self._busy_tasks >= self.max_concurrent_tasks - self.reserved_rpc_tasks
|
|
223
|
+
and self.unknown_task_reason(task) is None
|
|
224
|
+
):
|
|
225
|
+
self.reserved_rpc_declines += 1
|
|
226
|
+
request = task.wait_for if task.HasField("wait_for") else task.execute
|
|
227
|
+
_LOGGER.debug(
|
|
228
|
+
"decline Step task for reserved RPC capacity: %s", request.flow_type
|
|
229
|
+
)
|
|
230
|
+
return False
|
|
201
231
|
self._busy_tasks += 1
|
|
202
232
|
self.signal()
|
|
203
233
|
return True
|
|
@@ -288,6 +318,7 @@ class PullStream:
|
|
|
288
318
|
self._call: Any = None
|
|
289
319
|
self._send_lock = asyncio.Lock()
|
|
290
320
|
self._is_reconnecting = False
|
|
321
|
+
self._stream_metadata: Any = None
|
|
291
322
|
self._deadline_handle: asyncio.TimerHandle | None = None
|
|
292
323
|
self._supports_unknown_name_rejection = False
|
|
293
324
|
|
|
@@ -296,6 +327,7 @@ class PullStream:
|
|
|
296
327
|
|
|
297
328
|
async def run(self) -> None:
|
|
298
329
|
backoff = 0.1
|
|
330
|
+
has_retried_unauthenticated = False
|
|
299
331
|
while not self.runtime.is_stopping:
|
|
300
332
|
self.is_draining = False
|
|
301
333
|
was_registered = False
|
|
@@ -310,12 +342,35 @@ class PullStream:
|
|
|
310
342
|
)
|
|
311
343
|
finally:
|
|
312
344
|
code = error.code() if error is not None else None
|
|
345
|
+
if self._is_registered:
|
|
346
|
+
has_retried_unauthenticated = False
|
|
347
|
+
credential = (
|
|
348
|
+
self.runtime._credential_runner._credential
|
|
349
|
+
if self.runtime._credential_runner is not None
|
|
350
|
+
else None
|
|
351
|
+
)
|
|
352
|
+
can_reject_token = (
|
|
353
|
+
code is grpc.StatusCode.UNAUTHENTICATED
|
|
354
|
+
and self._stream_metadata is not None
|
|
355
|
+
and credential is not None
|
|
356
|
+
and callable(getattr(credential, "_reject_token", None))
|
|
357
|
+
)
|
|
358
|
+
should_retry_unauthenticated = (
|
|
359
|
+
can_reject_token and not has_retried_unauthenticated
|
|
360
|
+
)
|
|
361
|
+
if can_reject_token and credential is not None:
|
|
362
|
+
_reject_credential_token(credential, self._stream_metadata)
|
|
313
363
|
rejection = self.runtime.registration.record_stream_result(
|
|
314
364
|
self.queue,
|
|
315
365
|
self._is_registered,
|
|
316
|
-
|
|
366
|
+
(
|
|
367
|
+
error
|
|
368
|
+
if code in _PERMANENT_CODES and not should_retry_unauthenticated
|
|
369
|
+
else None
|
|
370
|
+
),
|
|
317
371
|
)
|
|
318
372
|
self._is_registered = False
|
|
373
|
+
self._stream_metadata = None
|
|
319
374
|
if (
|
|
320
375
|
rejection is not None
|
|
321
376
|
and self.runtime._stop_on_permanent_stream_rejection
|
|
@@ -331,6 +386,11 @@ class PullStream:
|
|
|
331
386
|
or self._is_retiring
|
|
332
387
|
):
|
|
333
388
|
break
|
|
389
|
+
if should_retry_unauthenticated:
|
|
390
|
+
has_retried_unauthenticated = True
|
|
391
|
+
continue
|
|
392
|
+
if code is grpc.StatusCode.UNAUTHENTICATED:
|
|
393
|
+
has_retried_unauthenticated = False
|
|
334
394
|
if self._is_reconnecting:
|
|
335
395
|
self._is_reconnecting = False
|
|
336
396
|
backoff = 0.1
|
|
@@ -364,6 +424,7 @@ class PullStream:
|
|
|
364
424
|
if self.runtime.is_stopping:
|
|
365
425
|
return False
|
|
366
426
|
self._supports_unknown_name_rejection = False
|
|
427
|
+
self._stream_metadata = None
|
|
367
428
|
if self.runtime._credential_runner is None:
|
|
368
429
|
self._call = self.stub.PollWorkerTask()
|
|
369
430
|
else:
|
|
@@ -372,6 +433,7 @@ class PullStream:
|
|
|
372
433
|
)
|
|
373
434
|
if self.runtime.is_stopping:
|
|
374
435
|
return False
|
|
436
|
+
self._stream_metadata = metadata
|
|
375
437
|
self._call = self.stub.PollWorkerTask(metadata=metadata)
|
|
376
438
|
await self._send(
|
|
377
439
|
pb.PollWorkerTaskRequest(
|
|
@@ -524,7 +586,7 @@ class PullStream:
|
|
|
524
586
|
if unknown_reason is not None:
|
|
525
587
|
await self._reject_task(task, unknown_reason)
|
|
526
588
|
return
|
|
527
|
-
if self.is_busy or self.is_draining or not self.runtime.accept_task():
|
|
589
|
+
if self.is_busy or self.is_draining or not self.runtime.accept_task(task):
|
|
528
590
|
await self._reject_task(task)
|
|
529
591
|
return
|
|
530
592
|
self.is_busy = True
|
|
@@ -221,6 +221,23 @@ class AsyncWorker:
|
|
|
221
221
|
raise RuntimeError("wait_pull_registered requires a pull Worker")
|
|
222
222
|
await self._pull.registration.wait_async(timeout)
|
|
223
223
|
|
|
224
|
+
@property
|
|
225
|
+
def pull_reserved_rpc_declines(self) -> int:
|
|
226
|
+
"""Return cumulative Step declines caused by reserved RPC capacity.
|
|
227
|
+
|
|
228
|
+
Full-capacity rejections and unknown names are excluded. The count never
|
|
229
|
+
resets during this Worker's lifetime and stays available after stop.
|
|
230
|
+
Reading it alongside handlers is safe. Listening Workers return zero.
|
|
231
|
+
Export this value to a monitoring counter to identify reserve pressure.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
The cumulative reserve-decline count, or zero for a listening Worker.
|
|
235
|
+
|
|
236
|
+
Examples:
|
|
237
|
+
>>> declines = worker.pull_reserved_rpc_declines
|
|
238
|
+
"""
|
|
239
|
+
return 0 if self._pull is None else self._pull.reserved_rpc_declines
|
|
240
|
+
|
|
224
241
|
def pull_registration_state(self) -> PullRegistrationState:
|
|
225
242
|
"""Return an immutable snapshot of pull queue registrations.
|
|
226
243
|
|