Skip to content

Commit 9e3e4a8

Browse files
committed
PR Feedback
1 parent 4bccaea commit 9e3e4a8

6 files changed

Lines changed: 91 additions & 16 deletions

File tree

azure/durable_functions/models/DurableEntityContext.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,9 @@ def from_json(cls, json_str: str) -> Tuple['DurableEntityContext', List[Dict[str
108108
json_dict["key"] = json_dict["self"]["key"]
109109
json_dict.pop("self")
110110

111+
# Keep the raw serialized state (a JSON string) so get_state() can
112+
# deserialize lazily with an expected_type supplied by the user.
111113
serialized_state = json_dict["state"]
112-
if serialized_state is not None:
113-
# Keep the raw serialized form so get_state() can deserialize
114-
# lazily with an expected_type supplied by the user.
115-
json_dict["state"] = serialized_state
116-
else:
117-
json_dict["state"] = None
118114

119115
batch = json_dict.pop("batch")
120116
ctx = cls(**json_dict)
@@ -134,6 +130,10 @@ def set_state(self, state: Any) -> None:
134130

135131
# should only serialize the state at the end of the batch
136132
self._state = state
133+
# The new state is a live Python value, not the raw JSON string
134+
# loaded from the payload. Clear the raw flag so a subsequent
135+
# get_state() in the same batch does not try to re-decode it.
136+
self._state_is_raw = False
137137

138138
def get_state(self, initializer: Optional[Callable[[], Any]] = None,
139139
expected_type: Optional[type] = None) -> Any:
@@ -145,7 +145,11 @@ def get_state(self, initializer: Optional[Callable[[], Any]] = None,
145145
A 0-argument function to provide an initial state. Defaults to None.
146146
expected_type: Optional[type]
147147
The type to decode the state as. When set, the codec uses
148-
this type directly without consulting ``sys.modules``.
148+
this type directly without consulting ``sys.modules``. Note that
149+
the persisted state is decoded lazily on the **first** get_state
150+
call within a batch; an ``expected_type`` supplied on a later
151+
call (after the state has already been decoded or replaced via
152+
set_state) has no effect.
149153
150154
Returns
151155
-------
@@ -199,6 +203,7 @@ def destruct_on_exit(self) -> None:
199203
"""Delete this entity after the operation completes."""
200204
self._exists = False
201205
self._state = None
206+
self._state_is_raw = False
202207

203208

204209
def from_json_util(json_str: str, expected_type: Optional[type] = None) -> Any:

azure/durable_functions/models/OrchestratorState.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import json
21
from typing import List, Any, Dict, Optional
32

43
from azure.durable_functions.models.ReplaySchema import ReplaySchema
54

65
from .utils.json_utils import add_attrib
7-
from .utils.df_serialization import _get_serialize_default
6+
from .utils.df_serialization import df_dumps
87
from azure.durable_functions.models.actions.Action import Action
98

109

@@ -114,4 +113,4 @@ def to_json_string(self) -> str:
114113
The instance of the object in json string format
115114
"""
116115
json_dict = self.to_json()
117-
return json.dumps(json_dict, default=_get_serialize_default())
116+
return df_dumps(json_dict)

azure/durable_functions/models/utils/type_discovery.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212

1313
from __future__ import annotations
1414

15+
import functools
1516
import inspect
1617
import logging
18+
import typing
1719
from typing import Any, Callable, Optional
1820

1921
logger = logging.getLogger(__name__)
@@ -33,12 +35,35 @@ def _unwrap_function_builder(name_or_callable: Any) -> Optional[Callable]:
3335
return None
3436

3537

38+
@functools.lru_cache(maxsize=None)
3639
def _return_annotation(fn: Callable) -> Optional[type]:
40+
"""Resolve *fn*'s return annotation to a concrete ``type``, or ``None``.
41+
42+
``typing.get_type_hints`` is tried first so that string annotations
43+
(``from __future__ import annotations`` / PEP 563) are resolved to the
44+
real object. Results are memoized per function because this runs on
45+
every ``call_activity`` / ``call_sub_orchestrator`` (including replay).
46+
47+
Limitation: generic aliases such as ``list[Order]`` or
48+
``Optional[Order]`` are not concrete ``type`` objects, so they resolve
49+
to ``None`` and the caller falls back to module-only resolution.
50+
"""
51+
ann: Any = inspect.Signature.empty
3752
try:
38-
sig = inspect.signature(fn)
39-
except (TypeError, ValueError):
40-
return None
41-
ann = sig.return_annotation
53+
hints = typing.get_type_hints(fn)
54+
except Exception:
55+
hints = None
56+
if hints is not None and "return" in hints:
57+
ann = hints["return"]
58+
else:
59+
# get_type_hints couldn't resolve (e.g. forward ref it can't see);
60+
# fall back to the raw signature annotation.
61+
try:
62+
sig = inspect.signature(fn)
63+
except (TypeError, ValueError):
64+
return None
65+
ann = sig.return_annotation
66+
4267
if ann is inspect.Signature.empty:
4368
return None
4469
return ann if isinstance(ann, type) else None

eng/templates/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,4 @@ jobs:
6464
- script: |
6565
pip install pytest pytest-azurepipelines
6666
pytest --ignore=samples-v2
67-
displayName: 'pytest'
67+
displayName: 'pytest'

tests/orchestrator/test_entity.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,18 @@ def counter_entity_function_raises_exception(context):
9494
def counter_entity_function_raises_exception_with_pystein(context):
9595
raise Exception("boom!")
9696

97+
def set_then_get_entity(context):
98+
"""Entity that sets state (without first reading it) in one operation and
99+
reads it in a later operation. Used to exercise set-then-get across a
100+
batch when the entity already has persisted state.
101+
"""
102+
operation = context.operation_name
103+
if operation == "set":
104+
context.set_state(10)
105+
context.set_result("set")
106+
elif operation == "get":
107+
context.set_result(context.get_state(lambda: 0))
108+
97109
def test_entity_raises_exception():
98110
# Create input batch
99111
batch = []
@@ -163,6 +175,40 @@ def test_entity_signal_then_call():
163175
#assert_valid_schema(result)
164176
assert_entity_state_equals(expected, result)
165177

178+
def test_entity_set_then_get_with_preexisting_raw_state():
179+
"""Regression test: an entity that already has persisted state must be
180+
able to set_state in one operation and get_state in a later operation
181+
within the same batch.
182+
183+
``from_json`` keeps the persisted state in its raw (undecoded) form and
184+
marks it as raw so the first ``get_state`` can decode it lazily with a
185+
user-supplied ``expected_type``. ``set_state`` replaces that raw value
186+
with a live Python value, so it must clear the raw flag -- otherwise a
187+
later ``get_state`` would try to re-decode an already-live value and the
188+
operation would fail.
189+
"""
190+
# Pre-existing persisted state (single-encoded JSON string) is what makes
191+
# from_json mark the loaded state as raw.
192+
batch = []
193+
add_to_batch(batch, name="set")
194+
add_to_batch(batch, name="get")
195+
context_builder = EntityContextBuilder(batch=batch, state=json.dumps(5))
196+
197+
# Run the entity, get observed result
198+
result = get_entity_state_result(
199+
context_builder,
200+
set_then_get_entity,
201+
)
202+
203+
# Both operations should succeed; the "get" must observe the value set by
204+
# the earlier "set" (10), not crash trying to re-decode it.
205+
expected_state = entity_base_expected_state()
206+
apply_operation(expected_state, result="set", state=10)
207+
apply_operation(expected_state, result=10, state=10)
208+
expected = expected_state.to_json()
209+
210+
assert_entity_state_equals(expected, result)
211+
166212
def test_entity_signal_then_call_with_pystein():
167213
"""Tests that a simple counter entity outputs the correct value
168214
after a sequence of operations. Mostly just a sanity check.

tests/orchestrator/test_external_event.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,4 @@ def test_external_event_with_expected_type():
8585
context_builder, generator_function_with_expected_type)
8686

8787
assert result["isDone"] is True
88-
assert result["output"] == "hello"
88+
assert result["output"] == "hello"

0 commit comments

Comments
 (0)