Skip to content

Commit 5722cd4

Browse files
authored
Fix durabletask issues surfaced during Azure Functions investigation (#174)
* Apply durabletask fixes surfaced during Azure Functions investigation
1 parent e7b5f15 commit 5722cd4

6 files changed

Lines changed: 101 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1010
ADDED
1111

1212
- Added `TaskHubGrpcClient.rewind_orchestration()` to rewind a failed orchestration instance to its last known good state. Failed activity and sub-orchestration results are removed from the history and the orchestration replays from the last successful checkpoint, retrying only the failed work. The in-memory testing backend supports rewind as well.
13+
- Added a `Task.result` property as a read-only alias for `Task.get_result()`.
14+
- Exported `bind_context` and `clear_context` from `durabletask.extensions.history_export` for managing the ambient history-export context.
15+
16+
FIXED
17+
18+
- Fixed timers created with a timezone-aware `fire_at` datetime being compared against the orchestration's naive UTC clock, which could cause timers to fire at the wrong time. Timezone-aware fire times are now normalized to naive UTC before scheduling.
19+
- Fixed the orchestration version being set from an unset `executionStarted.version` field. Presence is now checked with `HasField`, so `OrchestrationContext` no longer reports a version when none was provided.
20+
- Fixed orchestrations failing with `OrchestrationStateError` when a `genericEvent` history event was replayed (for example, the marker the Durable Functions extension appends when rewinding an orchestration). Such informational events are now ignored during replay, matching the .NET worker.
21+
- Fixed a lock-granted entity response over the legacy entity protocol raising while trying to deserialize an empty operation result. Lock-granted events no longer attempt result deserialization.
1322

1423
## v1.7.2
1524

durabletask/extensions/history_export/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
)
2323
from durabletask.extensions.history_export.activities import (
2424
HistoryExportContext,
25+
bind_context,
26+
clear_context,
2527
)
2628
from durabletask.extensions.history_export.client import (
2729
ExportHistoryClient,
@@ -76,5 +78,7 @@
7678
"ExportMode",
7779
"HistoryExportContext",
7880
"HistoryWriter",
81+
"bind_context",
82+
"clear_context",
7983
"orchestrator_instance_id_for",
8084
]

durabletask/scheduled/models.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from durabletask.internal.helpers import ensure_aware
1010
from durabletask.scheduled.schedule_status import ScheduleStatus
11-
from durabletask.serialization import JsonDataConverter
11+
from durabletask.serialization import DataConverter, JsonDataConverter
1212

1313
MINIMUM_INTERVAL = timedelta(seconds=1)
1414

@@ -425,13 +425,23 @@ def to_json(self) -> dict[str, Any]:
425425
}
426426

427427
@classmethod
428-
def from_json(cls, data: dict[str, Any]) -> "ScheduleState":
429-
# The nested configuration is reconstructed by calling its own
430-
# ``from_json`` hook directly. ``ScheduleConfiguration`` is an internal
431-
# type, so there is no need to route it through a (possibly custom)
432-
# converter -- keeping this hook converter-free means it round-trips
433-
# under any code path, not only the worker's threaded converter. Reads
434-
# accept both the .NET-compatible and legacy snake_case shapes.
428+
def from_json(cls, data: dict[str, Any],
429+
converter: DataConverter | None = None) -> "ScheduleState":
430+
# Reads accept both the .NET-compatible and legacy snake_case shapes.
431+
#
432+
# The nested ``ScheduleConfiguration`` must round-trip under any
433+
# conforming converter. Converters differ in how they hand nested
434+
# custom objects to this hook:
435+
# * The default JSON converter leaves nested values as plain dicts and
436+
# expects the parent hook to rebuild them; it passes itself as
437+
# ``converter`` (a hook that declares the parameter opts in) so the
438+
# reconstruction can defer to ``converter.coerce``.
439+
# * The Azure Functions ``df`` codec rebuilds nested ``to_json`` /
440+
# ``from_json`` envelopes bottom-up, so it invokes this hook with the
441+
# configuration *already* reconstructed (and without a converter).
442+
# Handle both: skip reconstruction when it is already a
443+
# ``ScheduleConfiguration``, otherwise route a raw dict through the
444+
# converter when one was supplied, falling back to the hook directly.
435445
state = cls()
436446
state.status = ScheduleStatus.from_dotnet(_get(data, "Status", "status"))
437447
# Preserve the token generated by ``__init__`` when the field is absent;
@@ -446,8 +456,7 @@ def from_json(cls, data: dict[str, Any]) -> "ScheduleState":
446456
state.schedule_last_modified_at = _from_iso(
447457
_get(data, "ScheduleLastModifiedAt", "schedule_last_modified_at"))
448458
config_data = _get(data, "ScheduleConfiguration", "schedule_configuration")
449-
state.schedule_configuration = (
450-
ScheduleConfiguration.from_json(config_data) if config_data is not None else None)
459+
state.schedule_configuration = _rebuild_configuration(config_data, converter)
451460
return state
452461

453462
def to_description(self) -> ScheduleDescription:
@@ -470,3 +479,21 @@ def to_description(self) -> ScheduleDescription:
470479

471480
def _new_token() -> str:
472481
return uuid.uuid4().hex
482+
483+
484+
def _rebuild_configuration(
485+
config_data: Any, converter: DataConverter | None) -> "ScheduleConfiguration | None":
486+
"""Reconstruct a nested ``ScheduleConfiguration`` for any converter.
487+
488+
``config_data`` may be ``None``, an already-reconstructed
489+
``ScheduleConfiguration`` (codecs that rebuild nested envelopes bottom-up,
490+
e.g. the Azure Functions ``df`` codec), or a plain mapping (the default JSON
491+
converter, which leaves nested values as dicts). When a ``converter`` is
492+
supplied its ``coerce`` drives reconstruction; otherwise the hook is called
493+
directly.
494+
"""
495+
if config_data is None or isinstance(config_data, ScheduleConfiguration):
496+
return config_data
497+
if converter is not None:
498+
return converter.coerce(config_data, ScheduleConfiguration)
499+
return ScheduleConfiguration.from_json(config_data)

durabletask/scheduled/orchestrator.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,38 @@
1313
class ScheduleOperationRequest:
1414
"""Request describing an operation to execute against a schedule entity.
1515
16-
A plain dataclass: the serializer round-trips it automatically. ``input`` is
17-
typed ``Any``, so it is reconstructed as the raw deserialized payload; the
18-
concrete options type is rebuilt later, at the entity-method boundary, from
19-
that method's parameter annotation.
16+
``input`` is typed ``Any``, so it is reconstructed as the raw deserialized
17+
payload; the concrete options type is rebuilt later, at the entity-method
18+
boundary, from that method's parameter annotation.
19+
20+
The ``to_json`` / ``from_json`` hooks mirror the plain dataclass field
21+
mapping so the wire format is unchanged for the default JSON converter,
22+
while also making the type serializable by converters that require an
23+
explicit hook (for example the Azure Functions ``df`` codec, which cannot
24+
serialize a bare dataclass). This matches the sibling schedule models
25+
(``ScheduleState``, ``ScheduleCreationOptions``), which already define these
26+
hooks.
2027
"""
2128

2229
entity_id: str
2330
operation_name: str
2431
input: Any | None = None
2532

33+
def to_json(self) -> dict[str, Any]:
34+
return {
35+
"entity_id": self.entity_id,
36+
"operation_name": self.operation_name,
37+
"input": self.input,
38+
}
39+
40+
@classmethod
41+
def from_json(cls, data: dict[str, Any]) -> "ScheduleOperationRequest":
42+
return cls(
43+
entity_id=data["entity_id"],
44+
operation_name=data["operation_name"],
45+
input=data.get("input"),
46+
)
47+
2648

2749
def execute_schedule_operation_orchestrator(
2850
ctx: task.OrchestrationContext,

durabletask/task.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,11 @@ def is_failed(self) -> bool:
508508
"""Returns True if the task has failed, False otherwise."""
509509
return self._exception is not None
510510

511+
@property
512+
def result(self) -> T:
513+
"""Returns the result of the task (alias for :meth:`get_result`)."""
514+
return self.get_result()
515+
511516
def get_result(self) -> T:
512517
"""Returns the result of the task."""
513518
if not self._is_complete:

durabletask/worker.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1434,8 +1434,7 @@ def _execute_entity_batch(
14341434
stub.CompleteEntityTask(batch_result)
14351435
except Exception as ex:
14361436
self._logger.exception(
1437-
f"Failed to deliver entity response for '{entity_instance_id}' of orchestration ID '{instance_id}' to sidecar: {ex}"
1438-
)
1437+
f"Failed to deliver entity response for '{entity_instance_id}' of orchestration ID '{instance_id}' to sidecar: {ex}")
14391438

14401439
# TODO: Reset context
14411440

@@ -1674,6 +1673,14 @@ def create_timer_internal(
16741673
else:
16751674
final_fire_at = fire_at
16761675

1676+
# Normalize timezone-aware datetimes to naive UTC so they can be safely
1677+
# compared against and combined with the orchestration's naive UTC clock.
1678+
# A datetime is only truly aware when utcoffset() returns a value; a
1679+
# tzinfo whose utcoffset() is None is still naive and must be left as-is
1680+
# (calling astimezone() on it would raise ValueError).
1681+
if final_fire_at.utcoffset() is not None:
1682+
final_fire_at = final_fire_at.astimezone(timezone.utc).replace(tzinfo=None)
1683+
16771684
next_fire_at: datetime = final_fire_at
16781685

16791686
if (
@@ -2350,7 +2357,7 @@ def process_event(
23502357
f"A '{event.executionStarted.name}' orchestrator was not registered."
23512358
)
23522359

2353-
if event.executionStarted.version:
2360+
if event.executionStarted.HasField("version"):
23542361
ctx._version = event.executionStarted.version.value # pyright: ignore[reportPrivateUsage]
23552362

23562363
# Store the parent orchestration instance ID (set for
@@ -2913,6 +2920,12 @@ def _cancel_timer() -> None:
29132920
elif event.HasField("executionRewound"):
29142921
# Informational event added when an orchestration is rewound. No action needed.
29152922
pass
2923+
elif event.HasField("genericEvent"):
2924+
# Informational history event with no execution semantics (for
2925+
# example, the marker the Durable Functions extension appends
2926+
# when rewinding an orchestration). Ignored during replay,
2927+
# matching the .NET worker.
2928+
pass
29162929
elif event.HasField("eventSent"):
29172930
# Check if this eventSent corresponds to an entity operation call after being translated to the old
29182931
# entity protocol by the Durable WebJobs extension. If so, treat this message similarly to
@@ -2985,12 +2998,15 @@ def _handle_entity_event_raised(self,
29852998
return
29862999

29873000
result = None
2988-
if response is not None:
3001+
if response is not None and not is_lock_event:
29893002
# The legacy protocol wraps the result as {"result": <serialized>},
29903003
# where the value is a serialized JSON string (like the new protocol's
29913004
# entityOperationCompleted.output). Deserialize it -- not coerce -- so
29923005
# the value is fully parsed and the expected type applied; coercing
29933006
# would skip JSON parsing and leave it double-encoded (e.g. '"done"').
3007+
# Skipped for lock-granted events: those carry no operation result
3008+
# (the payload's "result" is empty), and the lock branch below
3009+
# ignores ``result`` entirely, so deserializing it would only raise.
29943010
result = self._data_converter.deserialize(
29953011
response["result"],
29963012
entity_task._expected_type, # pyright: ignore[reportPrivateUsage]

0 commit comments

Comments
 (0)