Centralize JSON serialization and add type-aware payload encoding/decoding#606
Centralize JSON serialization and add type-aware payload encoding/decoding#606andystaples wants to merge 7 commits into
Conversation
nytian
left a comment
There was a problem hiding this comment.
Left a few comments. Thanks for the PR!
|
|
||
| # Keep the raw serialized state (a JSON string) so get_state() can | ||
| # deserialize lazily with an expected_type supplied by the user. | ||
| serialized_state = json_dict["state"] |
There was a problem hiding this comment.
So seem like we will only decode when calling set/get entity state, but EntityState.tojson however will always encode the json at end of batch operations. Is it possible we are accessing a entity without get/set then this will have double encode?
There was a problem hiding this comment.
Good catch — this was a real bug. If a batch never called get_state/set_state, the state stayed in its raw (single-encoded) form but EntityState.to_json still ran df_dumps on it, double-encoding it.
Fixed by tracking whether the state is still raw (state_is_raw) and threading that through to serialization: untouched raw state is now written back verbatim, while live values are still encoded once. Added a regression test (test_entity_untouched_raw_state_is_not_double_encoded) covering a batch that never touches state.
| df_dumps, | ||
| df_loads, | ||
| ) | ||
| except ImportError: |
There was a problem hiding this comment.
This warns at import time, so every 3.9 user sees it even if they never hit this path. Can we defer it to first use instead? Or maybe use debug level?
There was a problem hiding this comment.
Agreed. Switched from an import-time warnings.warn to a one-time logger.debug emitted on first actual use of the fallback serializers, so users who never hit the fallback path see nothing and those who do get a single actionable hint at debug level.
| return _serialize_custom_object | ||
|
|
||
|
|
||
| __all__ = ["df_dumps", "df_loads", "_get_serialize_default"] |
There was a problem hiding this comment.
get-serialize-default is private, should we include here?
There was a problem hiding this comment.
Removed it from __all__ — it stays importable for its unit test but is no longer advertised as public API.
| ignored on this fallback path; type validation is only performed | ||
| by the SDK's ``df_loads`` when it is available. | ||
| """ | ||
| return json.loads(s, object_hook=_deserialize_custom_object) |
There was a problem hiding this comment.
expected_type is accepted but ignored on 3.9, so type-aware decoding is silently not support here, right? If it's y design, maybe we should add a comment so that customer is aware of this behavior?
There was a problem hiding this comment.
Correct — on the fallback path expected_type is accepted for call-site compatibility but ignored, since type validation only exists in the SDK's df_loads. This is by design and is documented in the fallback df_loads docstring; the new first-use debug log also calls out that type validation is unavailable on that path.
Worth noting too: Python 3.9 is no longer supported in Azure Functions, so we'll be dropping it (and eventually this whole fallback branch) from this repo soon.
| from azure.durable_functions.models.utils.type_discovery import ( | ||
| activity_output_type, | ||
| sub_orchestrator_output_type, | ||
| entity_operation_input_type, |
There was a problem hiding this comment.
Is entity_operation_input_type still kept for backward compatibility? It looks like this is dead code and we can remove. Or if we need to keep it for old versions, lets add a comment for this class that it doens't support at new version.
There was a problem hiding this comment.
You're right, it was unused (always returned None and only referenced by its own test). Removed entity_operation_input_type and its tests; we'll reintroduce a proper implementation if/when class-based entity dispatch lands.
Summary
This PR centralizes all JSON serialization/deserialization of user payloads and adds optional, type-aware encoding/decoding driven by user-supplied type hints — while keeping the on-the-wire format and existing behavior unchanged.
All payload serialization (orchestrator inputs/outputs, activity arguments and results, sub-orchestrator payloads, entity inputs/outputs, and client inputs) now flows through a single shim, replacing the scattered
json.dumps(…, default=_serialize_custom_object)/json.loads(…, object_hook=_deserialize_custom_object)calls.What changed
Centralized serialization (
models/utils/df_serialization.py)df_dumps/df_loadshelpers act as a thin shim over the Azure Functions SDK serializers.azure-functionsexposesdf_dumps/df_loads(the centralized serializers with type-validation and strict-typing support), they are used directly so our serialization matches the SDK'sActivityTriggerConverterat the host boundary._serialize_custom_object/_deserialize_custom_objecthooks, which exist in every supportedazure-functionsrelease, keeping both sides symmetric.{"__class__", "__module__", "__data__"}convention.Type-aware decoding
df_loads(s, expected_type=...)validates/decodes the deserialized payload against an expected type when the SDK supports it (the argument is accepted but ignored on olderazure-functionsreleases).models/utils/type_discovery.py): best-effort resolution of the concrete return annotation from a V2 decorated activity / sub-orchestrator, used to supplyexpected_typeautomatically. Failures degrade gracefully to module-only resolution and, ultimately, the legacy decoder.call_activity/call_sub_orchestratorgain an optionalexpected_typeargument that takes precedence over the discovered annotation; the resolved type is threaded throughTask→TaskOrchestrationExecutorso custom classes can be decoded without consultingsys.modules/importlib.@app.orchestration_trigger(input_type=...): a decorator-declared input type is stashed on the orchestrator handle and propagated socontext.get_input()(andget_input(expected_type=...)) can decode the input type-safely.Entities
DurableEntityContextnow keeps persisted state in its raw (undecoded) form and decodes it lazily onget_state(..., expected_type=...).set_stateclears the raw flag so a laterget_statein the same batch does not attempt to re-decode an already-live value.Behavior / compatibility
CI
azure-functionsbeta that first shipsdf_dumps/df_loads).Tests
expected_typeround-trips, decoratorinput_type, call-site override precedence, and an entity set-then-get regression across a batch with pre-existing raw state.Notes
TODOs mark the temporaryazure-functions>=2.2.0b5override; these should switch toazure-functions>=2.2.0once 2.2.0 GA ships, dropping the explicit install/override steps.