dex-python-sdk 0.7.0__tar.gz → 0.9.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 (65) hide show
  1. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/PKG-INFO +16 -2
  2. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/README.md +15 -1
  3. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_async_value_hydrator.py +54 -17
  4. dex_python_sdk-0.9.0/dex/_server_protocol.py +93 -0
  5. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_value_hydrator.py +41 -17
  6. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_value_mapper.py +5 -6
  7. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/async_client.py +13 -3
  8. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/async_worker.py +29 -1
  9. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/client.py +13 -3
  10. dex_python_sdk-0.9.0/dex/dexpb/dex_pb2.py +505 -0
  11. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/dexpb/dex_pb2.pyi +22 -4
  12. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/dexpb/dex_pb2_grpc.py +44 -0
  13. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/wait.py +6 -1
  14. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/wait_options.py +16 -6
  15. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/worker.py +29 -1
  16. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/pyproject.toml +1 -1
  17. dex_python_sdk-0.7.0/dex/dexpb/dex_pb2.py +0 -501
  18. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/LEGACY_NOTICES.md +0 -0
  19. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/LICENSE +0 -0
  20. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/__init__.py +0 -0
  21. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_async_worker_dispatcher.py +0 -0
  22. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_async_worker_service.py +0 -0
  23. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_grpc_errors.py +0 -0
  24. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_invocation_context.py +0 -0
  25. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_native.pyi +0 -0
  26. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_utils.py +0 -0
  27. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_worker_dispatcher.py +0 -0
  28. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/_worker_service.py +0 -0
  29. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/attribute.py +0 -0
  30. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/attribute_match.py +0 -0
  31. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/blob_cache.py +0 -0
  32. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/channel.py +0 -0
  33. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/client_options.py +0 -0
  34. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/codec.py +0 -0
  35. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/condition.py +0 -0
  36. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/context.py +0 -0
  37. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/dexpb/__init__.py +0 -0
  38. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/flow.py +0 -0
  39. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/flow_config.py +0 -0
  40. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/flow_info.py +0 -0
  41. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/flow_options.py +0 -0
  42. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/flow_result.py +0 -0
  43. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/py.typed +0 -0
  44. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/runtime_errors.py +0 -0
  45. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/step.py +0 -0
  46. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/step_execution.py +0 -0
  47. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/stream.py +0 -0
  48. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/subflow.py +0 -0
  49. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/timer.py +0 -0
  50. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/dex/worker_options.py +0 -0
  51. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/Cargo.lock +0 -0
  52. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/Cargo.toml +0 -0
  53. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  54. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  55. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  56. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  57. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  58. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  59. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  60. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  61. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  62. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  63. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  64. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  65. {dex_python_sdk-0.7.0 → dex_python_sdk-0.9.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: 0.7.0
3
+ Version: 0.9.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
@@ -455,7 +455,10 @@ client operations, and assertions as the Java suite against an isolated
455
455
  The strongly typed contracts, registry, synchronous Client/Worker, optional
456
456
  `AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
457
457
  implemented. Python owns its gRPC transport; the native bridge is limited to
458
- the shared BlobCache. Design notes:
458
+ the shared BlobCache. Serialized object values use wire encoding `json` for JSON
459
+ and `raw` for raw bytes. Internal Blob references are opaque. Hydration sends the
460
+ owning Flow ID with each reference, and the cache is isolated by
461
+ `(flow_id, blob_ref)`. Design notes:
459
462
  [`python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md) and
460
463
  [`python-sdk-step-streaming.md`](../docs/design/plan/python-sdk-step-streaming.md).
461
464
 
@@ -557,6 +560,17 @@ The dispatch `version` input is stamped the same way as a release tag.
557
560
 
558
561
  See [CONTRIBUTING.md](../CONTRIBUTING.md#releases-monorepo-tags) for monorepo tag conventions.
559
562
 
563
+ ## Server protocol compatibility
564
+
565
+ Both `Worker` and `AsyncWorker` call `GetServerInfo`, negotiate the highest
566
+ common protocol, synchronize Attribute indexes, and only then bind
567
+ WorkerService. The initial Python SDK interval is `[1,1]`. Missing information,
568
+ invalid or disjoint intervals, and RPC failures stop startup before binding.
569
+
570
+ The diagnostic SDK version comes from Python distribution metadata for
571
+ `dex-python-sdk` and falls back to `dev` when the distribution is unavailable.
572
+ It does not affect protocol compatibility.
573
+
560
574
  ## License
561
575
 
562
576
  [Sustainable Use License 1.0](LICENSE), with legacy portions under their
@@ -439,7 +439,10 @@ client operations, and assertions as the Java suite against an isolated
439
439
  The strongly typed contracts, registry, synchronous Client/Worker, optional
440
440
  `AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
441
441
  implemented. Python owns its gRPC transport; the native bridge is limited to
442
- the shared BlobCache. Design notes:
442
+ the shared BlobCache. Serialized object values use wire encoding `json` for JSON
443
+ and `raw` for raw bytes. Internal Blob references are opaque. Hydration sends the
444
+ owning Flow ID with each reference, and the cache is isolated by
445
+ `(flow_id, blob_ref)`. Design notes:
443
446
  [`python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md) and
444
447
  [`python-sdk-step-streaming.md`](../docs/design/plan/python-sdk-step-streaming.md).
445
448
 
@@ -541,6 +544,17 @@ The dispatch `version` input is stamped the same way as a release tag.
541
544
 
542
545
  See [CONTRIBUTING.md](../CONTRIBUTING.md#releases-monorepo-tags) for monorepo tag conventions.
543
546
 
547
+ ## Server protocol compatibility
548
+
549
+ Both `Worker` and `AsyncWorker` call `GetServerInfo`, negotiate the highest
550
+ common protocol, synchronize Attribute indexes, and only then bind
551
+ WorkerService. The initial Python SDK interval is `[1,1]`. Missing information,
552
+ invalid or disjoint intervals, and RPC failures stop startup before binding.
553
+
554
+ The diagnostic SDK version comes from Python distribution metadata for
555
+ `dex-python-sdk` and falls back to `dev` when the distribution is unavailable.
556
+ It does not affect protocol compatibility.
557
+
544
558
  ## License
545
559
 
546
560
  [Sustainable Use License 1.0](LICENSE), with legacy portions under their
@@ -20,6 +20,7 @@ _LOGGER = logging.getLogger(__name__)
20
20
 
21
21
  @dataclass
22
22
  class _PendingBlob:
23
+ flow_id: str
23
24
  blob_id: str
24
25
  is_object: bool
25
26
  request: pb.Value
@@ -36,21 +37,37 @@ class AsyncValueHydrator:
36
37
  self._service = service
37
38
  self._cache = cache
38
39
 
39
- async def hydrate(self, value: pb.Value) -> pb.Value:
40
- return (await self.hydrate_all([value]))[0]
40
+ async def hydrate(self, flow_id: str, value: pb.Value) -> pb.Value:
41
+ return (await self.hydrate_all(flow_id, [value]))[0]
41
42
 
42
- async def hydrate_all(self, values: list[pb.Value]) -> list[pb.Value]:
43
+ async def hydrate_all(
44
+ self,
45
+ flow_id: str,
46
+ values: list[pb.Value],
47
+ ) -> list[pb.Value]:
48
+ return await self.hydrate_all_for_flows(
49
+ [(flow_id, value) for value in values]
50
+ )
51
+
52
+ async def hydrate_all_for_flows(
53
+ self,
54
+ flow_values: list[tuple[str, pb.Value]],
55
+ ) -> list[pb.Value]:
56
+ values = [value for _, value in flow_values]
43
57
  hydrated = list(values)
44
- pending: dict[tuple[str, bool], _PendingBlob] = {}
45
- for index, value in enumerate(values):
58
+ pending: dict[tuple[str, str, bool], _PendingBlob] = {}
59
+ for index, (flow_id, value) in enumerate(flow_values):
60
+ if not flow_id:
61
+ raise ValueError(f"Flow ID at index {index} is required")
46
62
  key = self._blob_key(value)
47
63
  if key is None:
48
64
  self._validate_concrete(value)
49
65
  continue
50
- blob = pending.get(key)
66
+ flow_blob_key = (flow_id, key[0], key[1])
67
+ blob = pending.get(flow_blob_key)
51
68
  if blob is None:
52
- blob = _PendingBlob(key[0], key[1], value)
53
- pending[key] = blob
69
+ blob = _PendingBlob(flow_id, key[0], key[1], value)
70
+ pending[flow_blob_key] = blob
54
71
  blob.indexes.append(index)
55
72
 
56
73
  misses: list[_PendingBlob] = []
@@ -86,7 +103,7 @@ class AsyncValueHydrator:
86
103
  for message in channel_values.messages
87
104
  ]
88
105
  values.extend(message.value for message in channel_messages)
89
- hydrated = iter(await self.hydrate_all(values))
106
+ hydrated = iter(await self.hydrate_all(request.context.flow_id, values))
90
107
  result.step_input.CopyFrom(next(hydrated))
91
108
  if has_heartbeat:
92
109
  result.context.last_heartbeat_value.CopyFrom(next(hydrated))
@@ -122,7 +139,11 @@ class AsyncValueHydrator:
122
139
  values.extend(message.value for message in channel_messages)
123
140
  for channel_result in request.condition_results.channel_results:
124
141
  values.extend(channel_result.values)
125
- hydrated = iter(await self.hydrate_all(values))
142
+ for flow_result in request.condition_results.sub_flow_results:
143
+ values.extend(
144
+ completion.completed_step_output for completion in flow_result.results
145
+ )
146
+ hydrated = iter(await self.hydrate_all(request.context.flow_id, values))
126
147
  if has_step_input:
127
148
  result.step_input.CopyFrom(next(hydrated))
128
149
  if has_heartbeat:
@@ -141,6 +162,9 @@ class AsyncValueHydrator:
141
162
  for channel_result in result.condition_results.channel_results:
142
163
  for value in channel_result.values:
143
164
  value.CopyFrom(next(hydrated))
165
+ for flow_result in result.condition_results.sub_flow_results:
166
+ for completion in flow_result.results:
167
+ completion.completed_step_output.CopyFrom(next(hydrated))
144
168
  return result
145
169
 
146
170
  async def rpc_request(
@@ -156,7 +180,7 @@ class AsyncValueHydrator:
156
180
  for message in channel_values.messages
157
181
  ]
158
182
  values.extend(message.value for message in channel_messages)
159
- hydrated = await self.hydrate_all(values)
183
+ hydrated = await self.hydrate_all(request.context.flow_id, values)
160
184
  result.input.CopyFrom(hydrated[0])
161
185
  hydrated_entries = iter(hydrated[1:])
162
186
  for entry in result.attributes:
@@ -172,10 +196,11 @@ class AsyncValueHydrator:
172
196
 
173
197
  async def step_outputs(
174
198
  self,
199
+ flow_id: str,
175
200
  outputs: list[pb.StepCompletionOutput],
176
201
  ) -> list[pb.StepCompletionOutput]:
177
202
  values = [output.completed_step_output for output in outputs]
178
- hydrated = await self.hydrate_all(values)
203
+ hydrated = await self.hydrate_all(flow_id, values)
179
204
  results: list[pb.StepCompletionOutput] = []
180
205
  for output, value in zip(outputs, hydrated):
181
206
  result = pb.StepCompletionOutput()
@@ -188,7 +213,15 @@ class AsyncValueHydrator:
188
213
  if not misses:
189
214
  return
190
215
  response = await self._service.LoadBlobs(
191
- pb.LoadBlobsRequest(values=[miss.request for miss in misses])
216
+ pb.LoadBlobsRequest(
217
+ entries=[
218
+ pb.LoadBlobRequestEntry(
219
+ flow_id=miss.flow_id,
220
+ blob_value=miss.request,
221
+ )
222
+ for miss in misses
223
+ ]
224
+ )
192
225
  )
193
226
  for miss in misses:
194
227
  concrete = response.values.get(miss.blob_id)
@@ -200,7 +233,7 @@ class AsyncValueHydrator:
200
233
 
201
234
  def _read_cache(self, blob: _PendingBlob) -> pb.Value | None:
202
235
  try:
203
- payload = self._cache.get(blob.blob_id)
236
+ payload = self._cache.get(self._cache_key(blob))
204
237
  if payload is None:
205
238
  return None
206
239
  if blob.is_object:
@@ -212,7 +245,7 @@ class AsyncValueHydrator:
212
245
  except Exception:
213
246
  _LOGGER.warning("cannot read cached blob %s", blob.blob_id, exc_info=True)
214
247
  try:
215
- self._cache.delete(blob.blob_id)
248
+ self._cache.delete(self._cache_key(blob))
216
249
  except Exception:
217
250
  _LOGGER.warning(
218
251
  "cannot delete cached blob %s",
@@ -228,7 +261,7 @@ class AsyncValueHydrator:
228
261
  if blob.is_object
229
262
  else concrete.string_value.encode("utf-8")
230
263
  )
231
- self._cache.put(blob.blob_id, payload)
264
+ self._cache.put(self._cache_key(blob), payload)
232
265
  except Exception:
233
266
  _LOGGER.warning("cannot cache blob %s", blob.blob_id, exc_info=True)
234
267
 
@@ -247,6 +280,10 @@ class AsyncValueHydrator:
247
280
  return blob_id, True
248
281
  return None
249
282
 
283
+ @staticmethod
284
+ def _cache_key(blob: _PendingBlob) -> str:
285
+ return f"{len(blob.flow_id)}:{blob.flow_id}{blob.blob_id}"
286
+
250
287
  @staticmethod
251
288
  def _validate_hydrated(blob: _PendingBlob, value: pb.Value) -> None:
252
289
  expected = "obj_value" if blob.is_object else "string_value"
@@ -268,7 +305,7 @@ class AsyncValueHydrator:
268
305
  raise ValueError("non-finite numbers are unsupported")
269
306
  return
270
307
  if kind == "obj_value":
271
- if value.obj_value.encoding not in ("json", "rawbytes"):
308
+ if value.obj_value.encoding not in ("json", "raw"):
272
309
  raise ValueError(
273
310
  f"unsupported object encoding {value.obj_value.encoding}"
274
311
  )
@@ -0,0 +1,93 @@
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
+ from __future__ import annotations
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ import grpc
14
+
15
+ from dex.dexpb import dex_pb2 as pb
16
+
17
+ MINIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION = 1
18
+ MAXIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION = 1
19
+
20
+
21
+ def sdk_version() -> str:
22
+ try:
23
+ return version("dex-python-sdk")
24
+ except PackageNotFoundError:
25
+ return "dev"
26
+
27
+
28
+ def negotiate_server_protocol(server_info: pb.ServerInfo, artifact_version: str) -> int:
29
+ server_minimum = server_info.minimum_supported_protocol_version
30
+ server_current = server_info.current_protocol_version
31
+ if (
32
+ MINIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION == 0
33
+ or MAXIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION == 0
34
+ or MINIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION
35
+ > MAXIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION
36
+ ):
37
+ raise _compatibility_error(
38
+ artifact_version,
39
+ server_info.server_version,
40
+ server_minimum,
41
+ server_current,
42
+ "Python SDK protocol interval is invalid",
43
+ )
44
+ if server_minimum == 0 or server_current == 0 or server_minimum > server_current:
45
+ raise _compatibility_error(
46
+ artifact_version,
47
+ server_info.server_version,
48
+ server_minimum,
49
+ server_current,
50
+ "Server protocol interval is invalid",
51
+ )
52
+ negotiated = min(server_current, MAXIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION)
53
+ if (
54
+ negotiated < server_minimum
55
+ or negotiated < MINIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION
56
+ ):
57
+ raise _compatibility_error(
58
+ artifact_version,
59
+ server_info.server_version,
60
+ server_minimum,
61
+ server_current,
62
+ "protocol intervals do not overlap",
63
+ )
64
+ return negotiated
65
+
66
+
67
+ def server_info_request_error(
68
+ artifact_version: str, failure: grpc.RpcError
69
+ ) -> RuntimeError:
70
+ return RuntimeError(
71
+ f'Python SDK version "{artifact_version}" protocol '
72
+ f"[{MINIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION},"
73
+ f"{MAXIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION}] is incompatible "
74
+ 'with Server version "unknown" protocol [unknown,unknown]: '
75
+ f"GetServerInfo failed: {failure}"
76
+ )
77
+
78
+
79
+ def _compatibility_error(
80
+ artifact_version: str,
81
+ server_version: str,
82
+ server_minimum: int,
83
+ server_current: int,
84
+ reason: str,
85
+ ) -> RuntimeError:
86
+ displayed_server_version = server_version or "unknown"
87
+ return RuntimeError(
88
+ f'Python SDK version "{artifact_version}" protocol '
89
+ f"[{MINIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION},"
90
+ f"{MAXIMUM_SUPPORTED_SERVER_PROTOCOL_VERSION}] is incompatible with "
91
+ f'Server version "{displayed_server_version}" protocol '
92
+ f"[{server_minimum},{server_current}]: {reason}"
93
+ )
@@ -20,6 +20,7 @@ _LOGGER = logging.getLogger(__name__)
20
20
 
21
21
  @dataclass
22
22
  class _PendingBlob:
23
+ flow_id: str
23
24
  blob_id: str
24
25
  is_object: bool
25
26
  request: pb.Value
@@ -36,21 +37,31 @@ class ValueHydrator:
36
37
  self._service = service
37
38
  self._cache = cache
38
39
 
39
- def hydrate(self, value: pb.Value) -> pb.Value:
40
- return self.hydrate_all([value])[0]
40
+ def hydrate(self, flow_id: str, value: pb.Value) -> pb.Value:
41
+ return self.hydrate_all(flow_id, [value])[0]
41
42
 
42
- def hydrate_all(self, values: list[pb.Value]) -> list[pb.Value]:
43
+ def hydrate_all(self, flow_id: str, values: list[pb.Value]) -> list[pb.Value]:
44
+ return self.hydrate_all_for_flows([(flow_id, value) for value in values])
45
+
46
+ def hydrate_all_for_flows(
47
+ self,
48
+ flow_values: list[tuple[str, pb.Value]],
49
+ ) -> list[pb.Value]:
50
+ values = [value for _, value in flow_values]
43
51
  hydrated = list(values)
44
- pending: dict[tuple[str, bool], _PendingBlob] = {}
45
- for index, value in enumerate(values):
52
+ pending: dict[tuple[str, str, bool], _PendingBlob] = {}
53
+ for index, (flow_id, value) in enumerate(flow_values):
54
+ if not flow_id:
55
+ raise ValueError(f"Flow ID at index {index} is required")
46
56
  key = self._blob_key(value)
47
57
  if key is None:
48
58
  self._validate_concrete(value)
49
59
  continue
50
- blob = pending.get(key)
60
+ flow_blob_key = (flow_id, key[0], key[1])
61
+ blob = pending.get(flow_blob_key)
51
62
  if blob is None:
52
- blob = _PendingBlob(key[0], key[1], value)
53
- pending[key] = blob
63
+ blob = _PendingBlob(flow_id, key[0], key[1], value)
64
+ pending[flow_blob_key] = blob
54
65
  blob.indexes.append(index)
55
66
 
56
67
  misses: list[_PendingBlob] = []
@@ -86,7 +97,7 @@ class ValueHydrator:
86
97
  for message in channel_values.messages
87
98
  ]
88
99
  values.extend(message.value for message in channel_messages)
89
- hydrated = iter(self.hydrate_all(values))
100
+ hydrated = iter(self.hydrate_all(request.context.flow_id, values))
90
101
  result.step_input.CopyFrom(next(hydrated))
91
102
  if has_heartbeat:
92
103
  result.context.last_heartbeat_value.CopyFrom(next(hydrated))
@@ -126,7 +137,7 @@ class ValueHydrator:
126
137
  values.extend(
127
138
  completion.completed_step_output for completion in flow_result.results
128
139
  )
129
- hydrated = iter(self.hydrate_all(values))
140
+ hydrated = iter(self.hydrate_all(request.context.flow_id, values))
130
141
  if has_step_input:
131
142
  result.step_input.CopyFrom(next(hydrated))
132
143
  if has_heartbeat:
@@ -163,7 +174,7 @@ class ValueHydrator:
163
174
  for message in channel_values.messages
164
175
  ]
165
176
  values.extend(message.value for message in channel_messages)
166
- hydrated = self.hydrate_all(values)
177
+ hydrated = self.hydrate_all(request.context.flow_id, values)
167
178
  result.input.CopyFrom(hydrated[0])
168
179
  hydrated_entries = iter(hydrated[1:])
169
180
  for entry in result.attributes:
@@ -179,10 +190,11 @@ class ValueHydrator:
179
190
 
180
191
  def step_outputs(
181
192
  self,
193
+ flow_id: str,
182
194
  outputs: list[pb.StepCompletionOutput],
183
195
  ) -> list[pb.StepCompletionOutput]:
184
196
  values = [output.completed_step_output for output in outputs]
185
- hydrated = self.hydrate_all(values)
197
+ hydrated = self.hydrate_all(flow_id, values)
186
198
  results: list[pb.StepCompletionOutput] = []
187
199
  for output, value in zip(outputs, hydrated):
188
200
  result = pb.StepCompletionOutput()
@@ -195,7 +207,15 @@ class ValueHydrator:
195
207
  if not misses:
196
208
  return
197
209
  response = self._service.LoadBlobs(
198
- pb.LoadBlobsRequest(values=[miss.request for miss in misses])
210
+ pb.LoadBlobsRequest(
211
+ entries=[
212
+ pb.LoadBlobRequestEntry(
213
+ flow_id=miss.flow_id,
214
+ blob_value=miss.request,
215
+ )
216
+ for miss in misses
217
+ ]
218
+ )
199
219
  )
200
220
  for miss in misses:
201
221
  concrete = response.values.get(miss.blob_id)
@@ -207,7 +227,7 @@ class ValueHydrator:
207
227
 
208
228
  def _read_cache(self, blob: _PendingBlob) -> pb.Value | None:
209
229
  try:
210
- payload = self._cache.get(blob.blob_id)
230
+ payload = self._cache.get(self._cache_key(blob))
211
231
  if payload is None:
212
232
  return None
213
233
  if blob.is_object:
@@ -219,7 +239,7 @@ class ValueHydrator:
219
239
  except Exception:
220
240
  _LOGGER.warning("cannot read cached blob %s", blob.blob_id, exc_info=True)
221
241
  try:
222
- self._cache.delete(blob.blob_id)
242
+ self._cache.delete(self._cache_key(blob))
223
243
  except Exception:
224
244
  _LOGGER.warning(
225
245
  "cannot delete cached blob %s",
@@ -235,7 +255,7 @@ class ValueHydrator:
235
255
  if blob.is_object
236
256
  else concrete.string_value.encode("utf-8")
237
257
  )
238
- self._cache.put(blob.blob_id, payload)
258
+ self._cache.put(self._cache_key(blob), payload)
239
259
  except Exception:
240
260
  _LOGGER.warning("cannot cache blob %s", blob.blob_id, exc_info=True)
241
261
 
@@ -254,6 +274,10 @@ class ValueHydrator:
254
274
  return blob_id, True
255
275
  return None
256
276
 
277
+ @staticmethod
278
+ def _cache_key(blob: _PendingBlob) -> str:
279
+ return f"{len(blob.flow_id)}:{blob.flow_id}{blob.blob_id}"
280
+
257
281
  @staticmethod
258
282
  def _validate_hydrated(blob: _PendingBlob, value: pb.Value) -> None:
259
283
  expected = "obj_value" if blob.is_object else "string_value"
@@ -275,7 +299,7 @@ class ValueHydrator:
275
299
  raise ValueError("non-finite numbers are unsupported")
276
300
  return
277
301
  if kind == "obj_value":
278
- if value.obj_value.encoding not in ("json", "rawbytes"):
302
+ if value.obj_value.encoding not in ("json", "raw"):
279
303
  raise ValueError(
280
304
  f"unsupported object encoding {value.obj_value.encoding}"
281
305
  )
@@ -22,7 +22,7 @@ from dex.dexpb import dex_pb2 as pb
22
22
  from dex.runtime_errors import ValueMappingError
23
23
 
24
24
  _JSON_ENCODING = "json"
25
- _RAW_BYTES_ENCODING = "rawbytes"
25
+ _RAW_BYTES_ENCODING = "raw"
26
26
 
27
27
 
28
28
  class ValueMapper:
@@ -43,10 +43,7 @@ class ValueMapper:
43
43
  def _encode(self, value: Any, codec: Codec[Any]) -> pb.Value:
44
44
  if value is None:
45
45
  return pb.Value(
46
- obj_value=pb.EncodedObject(
47
- encoding=_JSON_ENCODING,
48
- payload=b"null",
49
- )
46
+ null_value=cast(struct_pb2.NullValue, struct_pb2.NULL_VALUE)
50
47
  )
51
48
  logical = codec.encode(value)
52
49
  if logical.kind is WireKind.STRING:
@@ -134,7 +131,7 @@ class ValueMapper:
134
131
  ):
135
132
  raise ValueError("blob-backed Value was not hydrated")
136
133
  if kind == "null_value":
137
- raise ValueError("attribute deletion marker cannot be decoded")
134
+ return None
138
135
  raise TypeError(f"cannot decode {kind or 'empty Value'} as {codec.type_name}")
139
136
 
140
137
  def to_value(self, value: pb.Value) -> Value:
@@ -166,6 +163,8 @@ class ValueMapper:
166
163
  "internal_blob_id_for_obj_value",
167
164
  ):
168
165
  raise ValueError("blob-backed Value was not hydrated")
166
+ if kind == "null_value":
167
+ return Value(WireKind.JSON, "null")
169
168
  raise TypeError(f"cannot wrap {kind or 'empty Value'} as Value")
170
169
 
171
170
  @staticmethod
@@ -308,7 +308,7 @@ class AsyncClient:
308
308
  if rpc.output_codec is None:
309
309
  return None
310
310
  return self._values.decode(
311
- await self._hydrator.hydrate(response.output),
311
+ await self._hydrator.hydrate(flow_id, response.output),
312
312
  rpc.output_codec,
313
313
  )
314
314
 
@@ -505,7 +505,7 @@ class AsyncClient:
505
505
  DexServiceError: If FlowService cannot perform the wait.
506
506
  """
507
507
  response = await self._wait_for_flow_response(flow_id, timeout)
508
- hydrated = await self._hydrator.step_outputs(list(response.results))
508
+ hydrated = await self._hydrator.step_outputs(flow_id, list(response.results))
509
509
  mapped = pb.FlowResult()
510
510
  mapped.CopyFrom(response)
511
511
  del mapped.results[:]
@@ -625,7 +625,9 @@ class AsyncClient:
625
625
  self, entry: pb.SearchFlowsResponseEntry
626
626
  ) -> SearchFlowEntry:
627
627
  attributes = {
628
- kv.key: self._values.to_value(await self._hydrator.hydrate(kv.value))
628
+ kv.key: self._values.to_value(
629
+ await self._hydrator.hydrate(entry.flow_id, kv.value)
630
+ )
629
631
  for kv in entry.indexed_attributes
630
632
  }
631
633
  closed_at = (
@@ -734,6 +736,10 @@ class AsyncClient:
734
736
 
735
737
  The server derives a stable Request ID from the Step execution when none
736
738
  is supplied. Transport long polls automatically reattach to the same wait.
739
+ Leave the maximum wait time at zero for ordinary infinite waits. A positive
740
+ value releases per-Flow in-flight Update capacity for abandoned waits.
741
+ The budget spans Continue-as-New and is distinct from the caller's lifetime.
742
+ Retrying after expiry creates a new Update generation.
737
743
 
738
744
  Args:
739
745
  flow_id: The non-empty active Flow ID.
@@ -780,6 +786,10 @@ class AsyncClient:
780
786
 
781
787
  The Client returns the value observed by the successful wait. The server
782
788
  derives a stable Request ID from the condition when none is supplied.
789
+ Leave the maximum wait time at zero for ordinary infinite waits. A positive
790
+ value releases per-Flow in-flight Update capacity for rarely matching waits.
791
+ The budget spans transport reattachments and Continue-as-New, and is distinct
792
+ from the caller's lifetime. Retrying after expiry creates a new Update generation.
783
793
  JSON, bytes, and null operands raise ``ValueError`` before transport.
784
794
 
785
795
  Args:
@@ -12,7 +12,13 @@ import asyncio
12
12
  from types import TracebackType
13
13
 
14
14
  import grpc
15
+ from google.protobuf import empty_pb2
15
16
 
17
+ from dex._server_protocol import (
18
+ negotiate_server_protocol,
19
+ sdk_version,
20
+ server_info_request_error,
21
+ )
16
22
  from dex._async_value_hydrator import AsyncValueHydrator
17
23
  from dex._async_worker_dispatcher import AsyncWorkerDispatcher
18
24
  from dex._async_worker_service import AsyncWorkerService
@@ -64,6 +70,8 @@ class AsyncWorker:
64
70
  self._flow_service = dex_pb2_grpc.FlowServiceStub( # type: ignore[no-untyped-call]
65
71
  self._flow_channel
66
72
  )
73
+ self._sdk_version = sdk_version()
74
+ self._negotiated_protocol_version = 0
67
75
  values = ValueMapper(registry.codec_registry)
68
76
  dispatcher = AsyncWorkerDispatcher(
69
77
  registry,
@@ -116,7 +124,7 @@ class AsyncWorker:
116
124
  await self.close()
117
125
 
118
126
  async def start(self) -> None:
119
- """Synchronize Attribute indexes, serve WorkerService, and await shutdown.
127
+ """Negotiate protocol, synchronize Attribute indexes, and serve.
120
128
 
121
129
  ``start`` may be awaited exactly once. It contacts FlowService before binding
122
130
  and completes only after ``stop`` terminates the server.
@@ -126,6 +134,26 @@ class AsyncWorker:
126
134
  """
127
135
  if self._state != "created":
128
136
  raise RuntimeError(f"AsyncWorker cannot start from state {self._state}")
137
+ try:
138
+ server_info = await self._flow_service.GetServerInfo(
139
+ empty_pb2.Empty(),
140
+ timeout=self.options.attribute_index_sync_timeout.total_seconds(),
141
+ )
142
+ except grpc.RpcError as failure:
143
+ self._state = "stopped"
144
+ await self._flow_channel.close(None)
145
+ self._stopped.set()
146
+ raise server_info_request_error(self._sdk_version, failure) from failure
147
+ try:
148
+ self._negotiated_protocol_version = negotiate_server_protocol(
149
+ server_info,
150
+ self._sdk_version,
151
+ )
152
+ except RuntimeError:
153
+ self._state = "stopped"
154
+ await self._flow_channel.close(None)
155
+ self._stopped.set()
156
+ raise
129
157
  try:
130
158
  await self._flow_service.SyncAttributeIndexes(
131
159
  pb.SyncAttributeIndexRequest(
@@ -308,7 +308,7 @@ class Client:
308
308
  if rpc.output_codec is None:
309
309
  return None
310
310
  return self._values.decode(
311
- self._hydrator.hydrate(response.output),
311
+ self._hydrator.hydrate(flow_id, response.output),
312
312
  rpc.output_codec,
313
313
  )
314
314
 
@@ -505,7 +505,7 @@ class Client:
505
505
  DexServiceError: If FlowService cannot perform the wait.
506
506
  """
507
507
  response = self._wait_for_flow_response(flow_id, timeout)
508
- hydrated = self._hydrator.step_outputs(list(response.results))
508
+ hydrated = self._hydrator.step_outputs(flow_id, list(response.results))
509
509
  mapped = pb.FlowResult()
510
510
  mapped.CopyFrom(response)
511
511
  del mapped.results[:]
@@ -624,7 +624,9 @@ class Client:
624
624
 
625
625
  def _map_search_entry(self, entry: pb.SearchFlowsResponseEntry) -> SearchFlowEntry:
626
626
  attributes = {
627
- kv.key: self._values.to_value(self._hydrator.hydrate(kv.value))
627
+ kv.key: self._values.to_value(
628
+ self._hydrator.hydrate(entry.flow_id, kv.value)
629
+ )
628
630
  for kv in entry.indexed_attributes
629
631
  }
630
632
  closed_at = (
@@ -731,6 +733,10 @@ class Client:
731
733
 
732
734
  The server derives a stable Request ID from the Step execution when none
733
735
  is supplied. Transport long polls automatically reattach to the same wait.
736
+ Leave the maximum wait time at zero for ordinary infinite waits. A positive
737
+ value releases per-Flow in-flight Update capacity for abandoned waits.
738
+ The budget spans Continue-as-New and is distinct from the caller's lifetime.
739
+ Retrying after expiry creates a new Update generation.
734
740
 
735
741
  Args:
736
742
  flow_id: The non-empty active Flow ID.
@@ -777,6 +783,10 @@ class Client:
777
783
 
778
784
  The Client returns the value observed by the successful wait. The server
779
785
  derives a stable Request ID from the condition when none is supplied.
786
+ Leave the maximum wait time at zero for ordinary infinite waits. A positive
787
+ value releases per-Flow in-flight Update capacity for rarely matching waits.
788
+ The budget spans transport reattachments and Continue-as-New, and is distinct
789
+ from the caller's lifetime. Retrying after expiry creates a new Update generation.
780
790
  JSON, bytes, and null operands raise ``ValueError`` before transport.
781
791
 
782
792
  Args: