Skip to content

Commit ff203b7

Browse files
committed
More E2E test edge cases, fixes, add scheduled tasks
1 parent 4a0ed32 commit ff203b7

14 files changed

Lines changed: 668 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ CHANGED
2020

2121
FIXED
2222

23+
- Fixed `OrchestrationContext.lock_entities` failing when used over the legacy
24+
entity protocol (used by the Azure Functions Durable extension). Acquiring an
25+
entity lock raised a `JSONDecodeError` because the worker tried to deserialize
26+
an operation result from the lock-granted event, which carries none; the
27+
result is now only read for entity operation calls, not lock acquisitions.
2328
- Fixed `OrchestrationContext.version` returning an empty string (`''`) instead
2429
of `None` for orchestrations started without an explicit version. The version
2530
field is a protobuf wrapper (a singular message that is always truthy), so it

azure-functions-durable/azure/durable_functions/decorators/durable_app.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,29 @@ def _register_builtin_http_functions(self) -> None:
6767
context_name="context",
6868
orchestration=BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME)(builtin_http_poll_orchestrator)
6969

70+
def configure_scheduled_tasks(self) -> None:
71+
"""Opt in to durabletask scheduled tasks by registering their built-ins.
72+
73+
Unlike durable HTTP (which is always available), scheduled tasks are
74+
opt-in: most apps don't use them, so their schedule entity and
75+
operation orchestrator are only registered when this method is called.
76+
After calling it, manage schedules from the client with
77+
:class:`durabletask.scheduled.ScheduledTaskClient`.
78+
79+
The schedule entity is self-driving (it re-arms itself with delayed
80+
self-signals), so no additional worker configuration is required in the
81+
host-driven Functions model.
82+
"""
83+
from durabletask.scheduled.orchestrator import (
84+
execute_schedule_operation_orchestrator,
85+
)
86+
from durabletask.scheduled.schedule_entity import ENTITY_NAME, Schedule
87+
88+
self.entity_trigger(
89+
context_name="context", entity_name=ENTITY_NAME)(Schedule)
90+
self.orchestration_trigger(
91+
context_name="context")(execute_schedule_operation_orchestrator)
92+
7093
def _configure_orchestrator_callable(
7194
self,
7295
wrap: Callable[[Callable[..., Any]], FunctionBuilder],

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/worker.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2857,12 +2857,15 @@ def _handle_entity_event_raised(self,
28572857
return
28582858

28592859
result = None
2860-
if response is not None:
2860+
if response is not None and not is_lock_event:
28612861
# The legacy protocol wraps the result as {"result": <serialized>},
28622862
# where the value is a serialized JSON string (like the new protocol's
28632863
# entityOperationCompleted.output). Deserialize it -- not coerce -- so
28642864
# the value is fully parsed and the expected type applied; coercing
28652865
# would skip JSON parsing and leave it double-encoded (e.g. '"done"').
2866+
# Skipped for lock-granted events: those carry no operation result
2867+
# (the payload's "result" is empty), and the lock branch below
2868+
# ignores ``result`` entirely, so deserializing it would only raise.
28662869
result = self._data_converter.deserialize(
28672870
response["result"],
28682871
entity_task._expected_type, # pyright: ignore[reportPrivateUsage]

tests/azure-functions-durable/e2e/_harness.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -379,10 +379,58 @@ def read_entity(self, name: str, key: str) -> dict[str, Any]:
379379
assert result.status == 200, f"entity read failed: {result.status} {result.body}"
380380
return result.json()
381381

382-
def signal_entity(self, name: str, key: str, op: str, input: Any = None) -> None:
383-
"""Signal an entity via the app's ``/api/signal/{name}/{key}/{op}`` route."""
382+
def list_entities(self, starts_with: Optional[str] = None) -> dict[str, Any]:
383+
"""List entities via the app's ``/api/entities`` route.
384+
385+
An optional ``starts_with`` filters by the entity instance-id prefix
386+
(entity IDs are formatted ``@name@key``).
387+
"""
388+
url = f"{self.base_url}/api/entities"
389+
if starts_with is not None:
390+
url += f"?starts_with={urllib.parse.quote(starts_with)}"
391+
result = http_request("GET", url)
392+
assert result.status == 200, f"list entities failed: {result.status} {result.body}"
393+
return result.json()
394+
395+
def clean_entity_storage(self) -> dict[str, Any]:
396+
"""Trigger entity storage cleanup via the app's ``/api/clean-entities`` route."""
397+
result = http_request("POST", f"{self.base_url}/api/clean-entities")
398+
assert result.status == 200, f"clean entities failed: {result.status} {result.body}"
399+
return result.json()
400+
401+
def create_schedule(self, schedule_id: str, interval_seconds: float = 2,
402+
input: Any = None) -> dict[str, Any]:
403+
"""Create a scheduled task via the app's ``/api/schedule/{id}`` route."""
404+
result = http_request(
405+
"POST", f"{self.base_url}/api/schedule/{schedule_id}",
406+
data={"interval_seconds": interval_seconds, "input": input}, timeout=90)
407+
assert result.status == 200, f"create schedule failed: {result.status} {result.body}"
408+
return result.json()
409+
410+
def describe_schedule(self, schedule_id: str) -> dict[str, Any]:
411+
"""Describe a scheduled task via the app's ``/api/schedule/{id}`` route."""
412+
result = http_request("GET", f"{self.base_url}/api/schedule/{schedule_id}", timeout=90)
413+
assert result.status == 200, f"describe schedule failed: {result.status} {result.body}"
414+
return result.json()
415+
416+
def delete_schedule(self, schedule_id: str) -> None:
417+
"""Delete a scheduled task via the app's ``/api/schedule/{id}/delete`` route."""
418+
result = http_request(
419+
"POST", f"{self.base_url}/api/schedule/{schedule_id}/delete", timeout=90)
420+
assert result.status == 202, f"delete schedule failed: {result.status} {result.body}"
421+
422+
def signal_entity(self, name: str, key: str, op: str, input: Any = None,
423+
delay_seconds: Optional[float] = None) -> None:
424+
"""Signal an entity via the app's ``/api/signal/{name}/{key}/{op}`` route.
425+
426+
When ``delay_seconds`` is provided, the app schedules the signal for
427+
future delivery (a delayed/scheduled entity signal).
428+
"""
429+
data: dict[str, Any] = {"input": input}
430+
if delay_seconds is not None:
431+
data["delay_seconds"] = delay_seconds
384432
result = http_request(
385-
"POST", f"{self.base_url}/api/signal/{name}/{key}/{op}", data={"input": input})
433+
"POST", f"{self.base_url}/api/signal/{name}/{key}/{op}", data=data)
386434
assert result.status == 202, f"signal failed: {result.status} {result.body}"
387435

388436
def wait_for_entity(

0 commit comments

Comments
 (0)