Skip to content

Commit ab871db

Browse files
committed
Merge branch 'main' into andystaples/add-scheduled-tasks
2 parents 7ea7478 + f6bfd44 commit ab871db

29 files changed

Lines changed: 2523 additions & 602 deletions

CHANGELOG.md

Lines changed: 48 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -24,59 +24,44 @@ ADDED
2424
- Added a pluggable `DataConverter` (`durabletask.serialization`) accepted by
2525
`TaskHubGrpcWorker`, `TaskHubGrpcClient`, and `AsyncTaskHubGrpcClient` via a
2626
`data_converter` argument. Every payload boundary (inputs, outputs, events,
27-
custom status, entity state) routes through it. The default
27+
custom status, entity state) routes through it, so one converter controls how
28+
Python values become JSON and how they are reconstructed. The default
2829
`JsonDataConverter` preserves existing behavior, so a custom converter (for
29-
example one backed by pydantic) is opt-in. Custom objects can opt in via a
30-
`to_json()` hook and a `from_json(value)` classmethod. Objects that contain
31-
other hook-using objects round-trip automatically: nested `to_json()` hooks
32-
fire at any depth during serialization, and a `from_json` hook may declare an
33-
optional second parameter (`from_json(cls, value, converter)`) to reconstruct
34-
nested typed values via `converter.coerce(child, ChildType)` instead of by
35-
hand.
36-
- `OrchestrationContext.call_activity`, `call_sub_orchestrator`, and
37-
`call_entity` accept an optional `return_type`, and `wait_for_external_event`
38-
accepts an optional `data_type`. When provided, the result/event payload is
39-
reconstructed as that type (dataclasses — including nested dataclass,
40-
`Optional`, and `list` fields — and `from_json()`-capable types) and the
41-
returned task is typed accordingly (e.g. `call_activity(..., return_type=Foo)`
42-
yields `CompletableTask[Foo]`). When omitted, the raw deserialized JSON is
43-
returned as before.
44-
- Inbound payloads are reconstructed from function type annotations. When an
45-
orchestrator, activity, or entity operation annotates its input parameter (or
46-
an activity its return value) with a dataclass or `from_json()`-capable type,
47-
the payload is reconstructed as that type. Builtins and unannotated/unknown
48-
types are passed through unchanged. An explicit `return_type` takes precedence
49-
over a discovered annotation.
50-
- Added typed accessors to `client.OrchestrationState`: `get_input()`,
51-
`get_output()`, and `get_custom_status()` each accept an optional
52-
`expected_type` and deserialize the corresponding payload, reconstructing
53-
dataclasses and `from_json()`-capable types. The raw `serialized_*` fields are
54-
retained.
55-
- Objects exposing a `to_json()` method are now JSON-serializable when passed as
56-
activity/orchestrator inputs or outputs.
57-
- Added `EntityMetadata.get_typed_state(intended_type=...)`, which deserializes
58-
the entity's persisted state and reconstructs dataclasses and
59-
`from_json()`-capable types. The existing `get_state()` is unchanged: with no
60-
argument it returns the raw serialized JSON payload, and `get_state(some_type)`
61-
applies constructor-based coercion (`some_type(raw)`).
62-
- Entity runtime state retrieval (`EntityContext.get_state(intended_type=...)` /
63-
`DurableEntity.get_state(...)`) now also reconstructs dataclasses and
64-
`from_json()`-capable types, in addition to the existing constructor-based
65-
coercion.
30+
example one backed by pydantic) is fully opt-in.
31+
- Custom objects can participate in serialization by exposing a `to_json()`
32+
method and a `from_json(value)` classmethod. Both are honored recursively, so
33+
nested custom objects round-trip through their own hooks.
34+
- Payloads are reconstructed into a caller-supplied type — dataclasses
35+
(including nested fields), `from_json()`-capable types, and `enum.Enum`
36+
members, recursing through `list`, `dict`, `tuple`, and `Optional`/`Union`
37+
hints. The type comes from a function's annotations, from an explicit
38+
`return_type` on `call_activity` / `call_sub_orchestrator` / `call_entity`
39+
(or `data_type` on `wait_for_external_event`), or from the typed accessors
40+
`get_input()` / `get_output()` / `get_custom_status()` on
41+
`client.OrchestrationState` and `EntityMetadata.get_typed_state(...)`. It is
42+
never inferred from the payload. Which annotated types are eligible is decided
43+
by the converter via the overridable `DataConverter.can_reconstruct(...)`; a
44+
custom converter can override it to recognize its own types (for example
45+
`pydantic.BaseModel` subclasses).
6646

6747
CHANGED
6848

6949
- Custom objects (dataclasses, `SimpleNamespace`, namedtuples) are now
7050
serialized as plain JSON. Decoding such a payload *without* a type hint now
7151
yields a plain `dict` (previously a `SimpleNamespace`; a namedtuple now
72-
round-trips as a JSON array). To get the original type back, pass the new
73-
`return_type` / `data_type` arguments, annotate the consuming function's
74-
parameter or return type, or use the typed client accessors. Payloads produced
75-
by older SDK versions still deserialize — including into a `SimpleNamespace`
76-
when no type is supplied — so in-flight orchestrations continue to replay
77-
across an upgrade.
52+
round-trips as a JSON array). To get the original type back, supply a type via
53+
one of the mechanisms above. Payloads produced by older SDK versions still
54+
deserialize — including into a `SimpleNamespace` when no type is supplied — so
55+
in-flight orchestrations continue to replay across an upgrade.
7856
- JSON serialization failures now raise a `TypeError` that chains the original
7957
error (`__cause__`) and names the offending type.
58+
- `EntityContext.get_state()` / `DurableEntity.get_state()` now return a freshly
59+
reconstructed value on every call rather than a reference to a single cached
60+
object. This changes v1.6.0 behavior: mutating the returned value in place no
61+
longer affects persisted state — write it back with `set_state()`. State is
62+
also serialized eagerly at `set_state()` time, so a non-serializable value
63+
fails inside the operation (which rolls back) instead of after the batch has
64+
run.
8065

8166
FIXED
8267

@@ -85,19 +70,31 @@ FIXED
8570
"no state" and written as `None`, effectively deleting it; only an actual
8671
`None` state now clears the persisted entity state.
8772

88-
BREAKING CHANGES (type-level only — no runtime impact for typical users)
73+
DEPRECATED
74+
75+
- `durabletask.internal.shared.to_json` and `durabletask.internal.shared.from_json`
76+
are deprecated and now emit a `DeprecationWarning`. Use a
77+
`durabletask.serialization.DataConverter` (for example the default
78+
`JsonDataConverter`) instead. The functions continue to work for backwards
79+
compatibility.
80+
81+
BREAKING CHANGES (no runtime impact for typical users)
8982

90-
These changes do not alter runtime behavior, but because the package ships
91-
`py.typed`, consumers running strict type checkers (pyright/mypy) — or
92-
subclassing the public abstract types — may need to update their code:
83+
Most of these are type-level only: because the package ships `py.typed`,
84+
consumers running strict type checkers (pyright/mypy) — or subclassing the
85+
public abstract types — may need to update their code. The constructor change
86+
below also affects callers who *directly* construct the named classes, which is
87+
uncommon since they are normally handed to you by the SDK.
9388

9489
- `OrchestrationContext.call_activity`, `call_sub_orchestrator`, `call_entity`,
9590
and `wait_for_external_event` gained new keyword-only parameters
9691
(`return_type` / `data_type`). Subclasses overriding these methods should add
9792
the parameter to match the base signature.
98-
- `client.OrchestrationState` gained a non-public `_data_converter` field
99-
(excluded from equality and `repr`). Code constructing `OrchestrationState`
100-
positionally should pass it via the new field or rely on its default.
93+
- `EntityContext` and `EntityMetadata` (and its `from_entity_metadata` /
94+
`from_entity_response` factories) now require a `data_converter` argument.
95+
These objects are normally constructed by the SDK — you receive an
96+
`EntityContext` in an entity function and an `EntityMetadata` from the client —
97+
so this only affects code that constructs them directly.
10198

10299
## v1.6.0
103100

durabletask-azuremanaged/CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## Unreleased
99

10-
N/A
10+
ADDED
11+
12+
- `DurableTaskSchedulerWorker`, `DurableTaskSchedulerClient`, and the async
13+
client now accept a `data_converter` argument and forward it to the base
14+
worker/client, so a custom `durabletask.serialization.DataConverter` (for
15+
example a pydantic-backed one) can be used with the Durable Task Scheduler.
1116

1217
## v1.6.0
1318

durabletask-azuremanaged/durabletask/azuremanaged/client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
import durabletask.internal.shared as shared
2222
from durabletask.payload.store import PayloadStore
23+
from durabletask.serialization import DataConverter
2324

2425

2526
# Client class used for Durable Task Scheduler (DTS)
@@ -35,6 +36,7 @@ def __init__(self, *,
3536
resiliency_options: GrpcClientResiliencyOptions | None = None,
3637
default_version: str | None = None,
3738
payload_store: PayloadStore | None = None,
39+
data_converter: DataConverter | None = None,
3840
log_handler: logging.Handler | None = None,
3941
log_formatter: logging.Formatter | None = None):
4042

@@ -59,7 +61,8 @@ def __init__(self, *,
5961
channel_options=channel_options,
6062
resiliency_options=resiliency_options,
6163
default_version=default_version,
62-
payload_store=payload_store)
64+
payload_store=payload_store,
65+
data_converter=data_converter)
6366

6467

6568
# Async client class used for Durable Task Scheduler (DTS)
@@ -113,6 +116,7 @@ def __init__(self, *,
113116
resiliency_options: GrpcClientResiliencyOptions | None = None,
114117
default_version: str | None = None,
115118
payload_store: PayloadStore | None = None,
119+
data_converter: DataConverter | None = None,
116120
log_handler: logging.Handler | None = None,
117121
log_formatter: logging.Formatter | None = None):
118122

@@ -137,4 +141,5 @@ def __init__(self, *,
137141
channel_options=channel_options,
138142
resiliency_options=resiliency_options,
139143
default_version=default_version,
140-
payload_store=payload_store)
144+
payload_store=payload_store,
145+
data_converter=data_converter)

durabletask-azuremanaged/durabletask/azuremanaged/worker.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
import durabletask.internal.shared as shared
2020
from durabletask.payload.store import PayloadStore
21+
from durabletask.serialization import DataConverter
2122
from durabletask.worker import ConcurrencyOptions, TaskHubGrpcWorker
2223

2324

@@ -81,6 +82,7 @@ def __init__(self, *,
8182
resiliency_options: GrpcWorkerResiliencyOptions | None = None,
8283
concurrency_options: ConcurrencyOptions | None = None,
8384
payload_store: PayloadStore | None = None,
85+
data_converter: DataConverter | None = None,
8486
log_handler: logging.Handler | None = None,
8587
log_formatter: logging.Formatter | None = None):
8688

@@ -110,5 +112,6 @@ def __init__(self, *,
110112
concurrency_options=concurrency_options,
111113
# DTS natively supports long timers so chunking is unnecessary
112114
maximum_timer_interval=None,
113-
payload_store=payload_store
115+
payload_store=payload_store,
116+
data_converter=data_converter
114117
)

durabletask/entities/entity_context.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,11 @@
1818

1919
class EntityContext:
2020
def __init__(self, orchestration_id: str, operation: str, state: StateShim,
21-
entity_id: EntityInstanceId, data_converter: "DataConverter | None" = None):
21+
entity_id: EntityInstanceId, data_converter: "DataConverter"):
2222
self._orchestration_id = orchestration_id
2323
self._operation = operation
2424
self._state = state
2525
self._entity_id = entity_id
26-
if data_converter is None:
27-
from durabletask.serialization import JsonDataConverter
28-
data_converter = JsonDataConverter()
2926
self._data_converter = data_converter
3027

3128
@property

durabletask/entities/entity_metadata.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def __init__(self,
3636
locked_by: str,
3737
includes_state: bool,
3838
state: Any | None,
39-
data_converter: "DataConverter | None" = None):
39+
data_converter: "DataConverter"):
4040
"""Initializes a new instance of the EntityMetadata class.
4141
4242
Args:
@@ -48,20 +48,17 @@ def __init__(self,
4848
self._locked_by = locked_by
4949
self.includes_state = includes_state
5050
self._state = state
51-
if data_converter is None:
52-
from durabletask.serialization import JsonDataConverter
53-
data_converter = JsonDataConverter()
5451
self._data_converter = data_converter
5552

5653
@staticmethod
5754
def from_entity_response(entity_response: pb.GetEntityResponse, includes_state: bool,
58-
data_converter: "DataConverter | None" = None):
55+
data_converter: "DataConverter"):
5956
return EntityMetadata.from_entity_metadata(
6057
entity_response.entity, includes_state, data_converter)
6158

6259
@staticmethod
6360
def from_entity_metadata(entity: pb.EntityMetadata, includes_state: bool,
64-
data_converter: "DataConverter | None" = None):
61+
data_converter: "DataConverter"):
6562
try:
6663
entity_id = EntityInstanceId.parse(entity.instanceId)
6764
except ValueError:

durabletask/internal/entity_state_shim.py

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,49 @@
1212

1313

1414
class StateShim:
15-
def __init__(self, start_state: Any, data_converter: "DataConverter | None" = None):
16-
self._current_state: Any = start_state
17-
self._checkpoint_state: Any = start_state
15+
"""In-memory view of an entity's state during a batch.
16+
17+
The state is held internally as its serialized JSON string at all times.
18+
The raw payload off the wire is stored verbatim; a live value supplied via
19+
:meth:`set_state` (or as a non-serialized constructor argument) is
20+
serialized immediately. Keeping a single, always-serialized representation
21+
has two consequences worth noting:
22+
23+
* Deserialization is deferred to :meth:`get_state`, so the caller's
24+
requested type reaches the data converter together with the original
25+
payload (a custom converter can deserialize the string directly into the
26+
target type), and the unmodified wire payload is handed back by
27+
:meth:`encode_state` without being re-encoded.
28+
* Serialization errors surface inside the failing operation (at
29+
:meth:`set_state`) rather than after the batch has run, so a bad write
30+
rolls back just that operation.
31+
32+
Because the held value is always the serialized form, :meth:`get_state`
33+
returns a freshly reconstructed object on every call; it does **not** return
34+
a reference to a stored live object. Mutating a value read from
35+
:meth:`get_state` therefore has no effect on the persisted state unless it
36+
is written back with :meth:`set_state`.
37+
"""
38+
39+
def __init__(self, start_state: Any, data_converter: "DataConverter",
40+
*, is_serialized: bool = False):
41+
self._data_converter = data_converter
42+
# The state is normalized to its serialized string form. ``is_serialized``
43+
# marks ``start_state`` as a raw payload already off the wire (stored
44+
# verbatim); otherwise a live value is serialized now. ``None`` stays
45+
# ``None`` (no persisted state).
46+
serialized_start = self._serialize(start_state, is_serialized)
47+
self._current_state: str | None = serialized_start
48+
self._checkpoint_state: str | None = serialized_start
1849
self._operation_actions: list[pb.OperationAction] = []
1950
self._actions_checkpoint_state: int = 0
20-
if data_converter is None:
21-
from durabletask.serialization import JsonDataConverter
22-
data_converter = JsonDataConverter()
23-
self._data_converter = data_converter
51+
52+
def _serialize(self, state: Any, is_serialized: bool = False) -> str | None:
53+
if state is None:
54+
return None
55+
if is_serialized:
56+
return state
57+
return self._data_converter.serialize(state)
2458

2559
@overload
2660
def get_state(self, intended_type: type[TState], default: TState) -> TState:
@@ -35,31 +69,43 @@ def get_state(self, intended_type: None = None, default: Any = None) -> Any:
3569
...
3670

3771
def get_state(self, intended_type: type[TState] | None = None, default: TState | None = None) -> TState | Any | None:
38-
if self._current_state is None and default is not None:
72+
if self._current_state is None:
3973
return default
4074

75+
# Deferred deserialization: the converter receives the raw payload
76+
# together with the requested type.
4177
if intended_type is None:
42-
return self._current_state
43-
44-
coerced = self._data_converter.coerce(self._current_state, intended_type)
78+
return self._data_converter.deserialize(self._current_state)
79+
result = self._data_converter.deserialize(self._current_state, intended_type)
4580

4681
# An explicit ``intended_type`` is a request to receive that type. The
4782
# default converter is best-effort and would silently return the raw
4883
# value on a failed coercion; restore the stricter contract here by
4984
# raising when a non-None state could not be coerced to a concrete type.
5085
# ``intended_type`` may be a typing generic (e.g. ``list[int]``) at
5186
# runtime, which is not a ``type`` instance, so the guard is required.
52-
if (self._current_state is not None
53-
and isinstance(intended_type, type) # pyright: ignore[reportUnnecessaryIsInstance]
54-
and not isinstance(coerced, intended_type)):
87+
if (isinstance(intended_type, type) # pyright: ignore[reportUnnecessaryIsInstance]
88+
and not isinstance(result, intended_type)):
5589
raise TypeError(
56-
f"Could not convert state of type '{type(self._current_state).__name__}' to '{intended_type.__name__}'"
90+
f"Could not convert state of type '{type(result).__name__}' to '{intended_type.__name__}'"
5791
)
5892

59-
return coerced
93+
return result
6094

6195
def set_state(self, state: Any) -> None:
62-
self._current_state = state
96+
# Serialize eagerly so the held value is always the wire form and any
97+
# serialization error surfaces here, inside the failing operation.
98+
self._current_state = self._serialize(state)
99+
100+
def encode_state(self) -> str | None:
101+
"""Return the serialized current state for persistence back to the wire.
102+
103+
The state is already held in serialized form, so this is the stored
104+
value verbatim: ``None`` when there is no state (which clears the
105+
persisted entity state), otherwise the JSON string. No re-encoding
106+
occurs, so a payload that was never modified round-trips unchanged.
107+
"""
108+
return self._current_state
63109

64110
def add_operation_action(self, action: pb.OperationAction) -> None:
65111
self._operation_actions.append(action)

0 commit comments

Comments
 (0)