Skip to content

Commit e84790b

Browse files
committed
PR Feedback (DataConverter pattern, small fixes)
1 parent 7a8d7a0 commit e84790b

11 files changed

Lines changed: 446 additions & 122 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ ADDED
5252
uses a deterministic instance ID (`export-job-{job_id}`, exposed via
5353
`orchestrator_instance_id_for(...)`) so callers can correlate a job ID
5454
with its orchestrator for logging, monitoring, and restart.
55+
- Added a pluggable `DataConverter` abstraction (`durabletask.serialization`)
56+
consumed by both the worker and the client. `TaskHubGrpcWorker`,
57+
`TaskHubGrpcClient`, and `AsyncTaskHubGrpcClient` accept a `data_converter`
58+
argument; every payload serialization boundary (inputs, outputs, events,
59+
custom status, entity state) routes through it. The default
60+
`JsonDataConverter` preserves existing behavior, so supplying a custom
61+
converter (for example, one backed by pydantic) is purely opt-in. Custom
62+
objects opt in via a `to_json()` hook -- invoked as `type(obj).to_json(obj)`
63+
so both instance methods and `@staticmethod` hooks work -- and a
64+
`from_json(value)` classmethod, matching the `azure-functions-durable`
65+
convention.
5566
- Type-aware deserialization of payloads. `OrchestrationContext.call_activity`,
5667
`call_sub_orchestrator`, and `call_entity` accept an optional `return_type`,
5768
and `wait_for_external_event` accepts an optional `data_type`. When provided,
@@ -65,9 +76,11 @@ ADDED
6576
- Inbound payloads are reconstructed from function type annotations. When an
6677
orchestrator, activity, or entity operation annotates its input parameter with
6778
a dataclass or a `from_json()`-capable type, the incoming payload is
68-
automatically coerced to that type. Discovery is best-effort and conservative:
69-
builtins and unannotated/unknown types are passed through unchanged, and a
70-
payload that cannot be coerced falls back to the raw value.
79+
automatically coerced to that type. Coercion is best-effort: builtins and
80+
unannotated/unknown types are passed through unchanged, and a payload that
81+
cannot be coerced to the requested type falls back to the raw deserialized
82+
value (logged at debug level) rather than raising. This best-effort policy is
83+
owned by the default `JsonDataConverter`; a stricter converter can change it.
7184
- `call_activity` results are reconstructed from the activity's return
7285
annotation. When an activity function reference is passed (not a string name)
7386
and its return type is annotated with a dataclass or `from_json()`-capable

durabletask/client.py

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import time
88
import uuid
99
from collections.abc import AsyncIterable, Iterable, Sequence
10-
from dataclasses import dataclass
10+
from dataclasses import dataclass, field
1111
from datetime import datetime
1212
from enum import Enum
1313
from typing import Any, Generic, Protocol, TypeVar, cast, overload
@@ -50,6 +50,7 @@
5050
)
5151
from durabletask.payload import helpers as payload_helpers
5252
from durabletask.payload.store import PayloadStore
53+
from durabletask.serialization import DEFAULT_DATA_CONVERTER, DataConverter, JsonDataConverter
5354

5455
TInput = TypeVar('TInput')
5556
TOutput = TypeVar('TOutput')
@@ -82,6 +83,12 @@ class OrchestrationState:
8283
serialized_output: str | None
8384
serialized_custom_status: str | None
8485
failure_details: task.FailureDetails | None
86+
# Converter used by the typed accessors below. Defaults to the SDK's JSON
87+
# converter; the client populates it with its own converter so custom
88+
# serialization applies on the read side too. Excluded from equality/repr so
89+
# two states with equal payloads remain equal regardless of converter.
90+
_data_converter: DataConverter = field(
91+
default=DEFAULT_DATA_CONVERTER, compare=False, repr=False)
8592

8693
@overload
8794
def get_input(self, expected_type: type[T]) -> T | None:
@@ -111,7 +118,7 @@ def get_input(self, expected_type: type | None = None) -> Any:
111118
"""
112119
if self.serialized_input is None:
113120
return None
114-
return shared.from_json(self.serialized_input, expected_type)
121+
return self._data_converter.deserialize(self.serialized_input, expected_type)
115122

116123
@overload
117124
def get_output(self, expected_type: type[T]) -> T | None:
@@ -141,7 +148,7 @@ def get_output(self, expected_type: type | None = None) -> Any:
141148
"""
142149
if self.serialized_output is None:
143150
return None
144-
return shared.from_json(self.serialized_output, expected_type)
151+
return self._data_converter.deserialize(self.serialized_output, expected_type)
145152

146153
@overload
147154
def get_custom_status(self, expected_type: type[T]) -> T | None:
@@ -171,7 +178,7 @@ def get_custom_status(self, expected_type: type | None = None) -> Any:
171178
"""
172179
if self.serialized_custom_status is None:
173180
return None
174-
return shared.from_json(self.serialized_custom_status, expected_type)
181+
return self._data_converter.deserialize(self.serialized_custom_status, expected_type)
175182

176183
def raise_if_failed(self):
177184
if self.failure_details is not None:
@@ -229,18 +236,22 @@ def failure_details(self):
229236
return self._failure_details
230237

231238

232-
def new_orchestration_state(instance_id: str, res: pb.GetInstanceResponse) -> OrchestrationState | None:
239+
def new_orchestration_state(
240+
instance_id: str, res: pb.GetInstanceResponse,
241+
data_converter: DataConverter | None = None) -> OrchestrationState | None:
233242
if not res.exists:
234243
return None
235244

236245
state = res.orchestrationState
237246

238-
new_state = parse_orchestration_state(state)
247+
new_state = parse_orchestration_state(state, data_converter)
239248
new_state.instance_id = instance_id # Override instance_id with the one from the request, to match old behavior
240249
return new_state
241250

242251

243-
def parse_orchestration_state(state: pb.OrchestrationState) -> OrchestrationState:
252+
def parse_orchestration_state(
253+
state: pb.OrchestrationState,
254+
data_converter: DataConverter | None = None) -> OrchestrationState:
244255
failure_details = None
245256
if state.failureDetails.errorMessage != '' or state.failureDetails.errorType != '':
246257
failure_details = task.FailureDetails(
@@ -257,7 +268,8 @@ def parse_orchestration_state(state: pb.OrchestrationState) -> OrchestrationStat
257268
state.input.value if not helpers.is_empty(state.input) else None,
258269
state.output.value if not helpers.is_empty(state.output) else None,
259270
state.customStatus.value if not helpers.is_empty(state.customStatus) else None,
260-
failure_details)
271+
failure_details,
272+
data_converter if data_converter is not None else DEFAULT_DATA_CONVERTER)
261273

262274

263275
# Grace period before a retired SDK-owned channel is force-closed. Long enough
@@ -400,9 +412,11 @@ def __init__(self, *,
400412
channel_options: GrpcChannelOptions | None = None,
401413
resiliency_options: GrpcClientResiliencyOptions | None = None,
402414
default_version: str | None = None,
403-
payload_store: PayloadStore | None = None):
415+
payload_store: PayloadStore | None = None,
416+
data_converter: DataConverter | None = None):
404417

405418
self._owns_channel = channel is None
419+
self._data_converter = data_converter if data_converter is not None else JsonDataConverter()
406420
self._host_address = (
407421
host_address if host_address else shared.get_default_host_address()
408422
)
@@ -605,7 +619,8 @@ def schedule_new_orchestration(self, orchestrator: task.Orchestrator[TInput, TOu
605619
req = build_schedule_new_orchestration_req(
606620
orchestrator, input=input, instance_id=instance_id, start_at=start_at,
607621
reuse_id_policy=reuse_id_policy, tags=tags,
608-
version=version if version else self.default_version)
622+
version=version if version else self.default_version,
623+
data_converter=self._data_converter)
609624

610625
# Inject the active PRODUCER span context into the request so the sidecar
611626
# stores it in the executionStarted event and the worker can parent all
@@ -629,7 +644,7 @@ def get_orchestration_state(self, instance_id: str, *, fetch_payloads: bool = Tr
629644
# De-externalize any large-payload tokens in the response
630645
if self._payload_store is not None and res.exists:
631646
payload_helpers.deexternalize_payloads(res, self._payload_store)
632-
return new_orchestration_state(req.instanceId, res)
647+
return new_orchestration_state(req.instanceId, res, self._data_converter)
633648

634649
def get_orchestration_history(self,
635650
instance_id: str, *,
@@ -685,7 +700,7 @@ def get_all_orchestration_states(self,
685700
resp: pb.QueryInstancesResponse = self._stub.QueryInstances(req)
686701
if self._payload_store is not None:
687702
payload_helpers.deexternalize_payloads(resp, self._payload_store)
688-
states += [parse_orchestration_state(res) for res in resp.orchestrationState]
703+
states += [parse_orchestration_state(res, self._data_converter) for res in resp.orchestrationState]
689704
if check_continuation_token(resp.continuationToken, _continuation_token, self._logger):
690705
_continuation_token = resp.continuationToken
691706
else:
@@ -705,7 +720,7 @@ def wait_for_orchestration_start(self, instance_id: str, *,
705720
)
706721
if self._payload_store is not None and res.exists:
707722
payload_helpers.deexternalize_payloads(res, self._payload_store)
708-
return new_orchestration_state(req.instanceId, res)
723+
return new_orchestration_state(req.instanceId, res, self._data_converter)
709724
except grpc.RpcError as rpc_error:
710725
if rpc_error.code() == grpc.StatusCode.DEADLINE_EXCEEDED: # type: ignore
711726
# Replace gRPC error with the built-in TimeoutError
@@ -725,7 +740,7 @@ def wait_for_orchestration_completion(self, instance_id: str, *,
725740
)
726741
if self._payload_store is not None and res.exists:
727742
payload_helpers.deexternalize_payloads(res, self._payload_store)
728-
state = new_orchestration_state(req.instanceId, res)
743+
state = new_orchestration_state(req.instanceId, res, self._data_converter)
729744
log_completion_state(self._logger, instance_id, state)
730745
return state
731746
except grpc.RpcError as rpc_error:
@@ -737,7 +752,7 @@ def wait_for_orchestration_completion(self, instance_id: str, *,
737752
def raise_orchestration_event(self, instance_id: str, event_name: str, *,
738753
data: Any | None = None) -> None:
739754
with tracing.start_raise_event_span(event_name, instance_id):
740-
req = build_raise_event_req(instance_id, event_name, data)
755+
req = build_raise_event_req(instance_id, event_name, data, self._data_converter)
741756
self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
742757
if self._payload_store is not None:
743758
payload_helpers.externalize_payloads(
@@ -748,7 +763,7 @@ def raise_orchestration_event(self, instance_id: str, event_name: str, *,
748763
def terminate_orchestration(self, instance_id: str, *,
749764
output: Any | None = None,
750765
recursive: bool = True) -> None:
751-
req = build_terminate_req(instance_id, output, recursive)
766+
req = build_terminate_req(instance_id, output, recursive, self._data_converter)
752767

753768
self._logger.info(f"Terminating instance '{instance_id}'.")
754769
if self._payload_store is not None:
@@ -811,7 +826,7 @@ def signal_entity(self,
811826
entity_instance_id: EntityInstanceId,
812827
operation_name: str,
813828
input: Any | None = None) -> None:
814-
req = build_signal_entity_req(entity_instance_id, operation_name, input)
829+
req = build_signal_entity_req(entity_instance_id, operation_name, input, self._data_converter)
815830
self._logger.info(f"Signaling entity '{entity_instance_id}' operation '{operation_name}'.")
816831
if self._payload_store is not None:
817832
payload_helpers.externalize_payloads(
@@ -896,9 +911,11 @@ def __init__(self, *,
896911
channel_options: GrpcChannelOptions | None = None,
897912
resiliency_options: GrpcClientResiliencyOptions | None = None,
898913
default_version: str | None = None,
899-
payload_store: PayloadStore | None = None):
914+
payload_store: PayloadStore | None = None,
915+
data_converter: DataConverter | None = None):
900916

901917
self._owns_channel = channel is None
918+
self._data_converter = data_converter if data_converter is not None else JsonDataConverter()
902919
self._host_address = (
903920
host_address if host_address else shared.get_default_host_address()
904921
)
@@ -1089,7 +1106,8 @@ async def schedule_new_orchestration(self, orchestrator: task.Orchestrator[TInpu
10891106
req = build_schedule_new_orchestration_req(
10901107
orchestrator, input=input, instance_id=instance_id, start_at=start_at,
10911108
reuse_id_policy=reuse_id_policy, tags=tags,
1092-
version=version if version else self.default_version)
1109+
version=version if version else self.default_version,
1110+
data_converter=self._data_converter)
10931111

10941112
parent_trace_ctx = tracing.get_current_trace_context()
10951113
if parent_trace_ctx is not None:
@@ -1110,7 +1128,7 @@ async def get_orchestration_state(self, instance_id: str, *,
11101128
res: pb.GetInstanceResponse = await self._stub.GetInstance(req)
11111129
if self._payload_store is not None and res.exists:
11121130
await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
1113-
return new_orchestration_state(req.instanceId, res)
1131+
return new_orchestration_state(req.instanceId, res, self._data_converter)
11141132

11151133
async def get_orchestration_history(self,
11161134
instance_id: str, *,
@@ -1166,7 +1184,7 @@ async def get_all_orchestration_states(self,
11661184
resp: pb.QueryInstancesResponse = await self._stub.QueryInstances(req)
11671185
if self._payload_store is not None:
11681186
await payload_helpers.deexternalize_payloads_async(resp, self._payload_store)
1169-
states += [parse_orchestration_state(res) for res in resp.orchestrationState]
1187+
states += [parse_orchestration_state(res, self._data_converter) for res in resp.orchestrationState]
11701188
if check_continuation_token(resp.continuationToken, _continuation_token, self._logger):
11711189
_continuation_token = resp.continuationToken
11721190
else:
@@ -1186,7 +1204,7 @@ async def wait_for_orchestration_start(self, instance_id: str, *,
11861204
)
11871205
if self._payload_store is not None and res.exists:
11881206
await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
1189-
return new_orchestration_state(req.instanceId, res)
1207+
return new_orchestration_state(req.instanceId, res, self._data_converter)
11901208
except grpc.aio.AioRpcError as rpc_error:
11911209
if rpc_error.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
11921210
raise TimeoutError("Timed-out waiting for the orchestration to start")
@@ -1205,7 +1223,7 @@ async def wait_for_orchestration_completion(self, instance_id: str, *,
12051223
)
12061224
if self._payload_store is not None and res.exists:
12071225
await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
1208-
state = new_orchestration_state(req.instanceId, res)
1226+
state = new_orchestration_state(req.instanceId, res, self._data_converter)
12091227
log_completion_state(self._logger, instance_id, state)
12101228
return state
12111229
except grpc.aio.AioRpcError as rpc_error:
@@ -1217,7 +1235,7 @@ async def wait_for_orchestration_completion(self, instance_id: str, *,
12171235
async def raise_orchestration_event(self, instance_id: str, event_name: str, *,
12181236
data: Any | None = None) -> None:
12191237
with tracing.start_raise_event_span(event_name, instance_id):
1220-
req = build_raise_event_req(instance_id, event_name, data)
1238+
req = build_raise_event_req(instance_id, event_name, data, self._data_converter)
12211239
self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
12221240
if self._payload_store is not None:
12231241
await payload_helpers.externalize_payloads_async(
@@ -1228,7 +1246,7 @@ async def raise_orchestration_event(self, instance_id: str, event_name: str, *,
12281246
async def terminate_orchestration(self, instance_id: str, *,
12291247
output: Any | None = None,
12301248
recursive: bool = True) -> None:
1231-
req = build_terminate_req(instance_id, output, recursive)
1249+
req = build_terminate_req(instance_id, output, recursive, self._data_converter)
12321250

12331251
self._logger.info(f"Terminating instance '{instance_id}'.")
12341252
if self._payload_store is not None:
@@ -1291,7 +1309,7 @@ async def signal_entity(self,
12911309
entity_instance_id: EntityInstanceId,
12921310
operation_name: str,
12931311
input: Any | None = None) -> None:
1294-
req = build_signal_entity_req(entity_instance_id, operation_name, input)
1312+
req = build_signal_entity_req(entity_instance_id, operation_name, input, self._data_converter)
12951313
self._logger.info(f"Signaling entity '{entity_instance_id}' operation '{operation_name}'.")
12961314
if self._payload_store is not None:
12971315
await payload_helpers.externalize_payloads_async(

durabletask/entities/entity_context.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11

2-
from typing import Any, TypeVar, overload
2+
from typing import TYPE_CHECKING, Any, TypeVar, overload
33
import uuid
44
from durabletask.entities.entity_instance_id import EntityInstanceId
5-
from durabletask.internal import helpers, shared
5+
from durabletask.internal import helpers
66
from durabletask.internal.entity_state_shim import StateShim
77
import durabletask.internal.orchestrator_service_pb2 as pb
88

9+
if TYPE_CHECKING:
10+
from durabletask.serialization import DataConverter
11+
912
TState = TypeVar("TState")
1013

1114

1215
class EntityContext:
13-
def __init__(self, orchestration_id: str, operation: str, state: StateShim, entity_id: EntityInstanceId):
16+
def __init__(self, orchestration_id: str, operation: str, state: StateShim,
17+
entity_id: EntityInstanceId, data_converter: "DataConverter | None" = None):
1418
self._orchestration_id = orchestration_id
1519
self._operation = operation
1620
self._state = state
1721
self._entity_id = entity_id
22+
if data_converter is None:
23+
from durabletask.serialization import JsonDataConverter
24+
data_converter = JsonDataConverter()
25+
self._data_converter = data_converter
1826

1927
@property
2028
def orchestration_id(self) -> str:
@@ -93,7 +101,7 @@ def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str, in
93101
input : Any, optional
94102
The input to provide to the entity for the operation.
95103
"""
96-
encoded_input: str | None = shared.to_json(input) if input is not None else None
104+
encoded_input: str | None = self._data_converter.serialize(input)
97105
self._state.add_operation_action(
98106
pb.OperationAction(
99107
sendSignal=pb.SendSignalAction(
@@ -124,7 +132,7 @@ def schedule_new_orchestration(self, orchestration_name: str, input: Any | None
124132
str
125133
The instance ID of the scheduled orchestration.
126134
"""
127-
encoded_input: str | None = shared.to_json(input) if input is not None else None
135+
encoded_input: str | None = self._data_converter.serialize(input)
128136
if not instance_id:
129137
instance_id = uuid.uuid4().hex
130138
self._state.add_operation_action(

0 commit comments

Comments
 (0)