Skip to content

Commit f8487e0

Browse files
committed
Add scheduled tasks feature and delayed entity signals
Add a recurring schedule feature (durabletask.scheduled) for parity with durabletask-dotnet, built on durable entities and a helper orchestrator. Enable it on a worker with configure_scheduled_tasks(worker) and manage schedules from the client via ScheduledTaskClient / ScheduleClient (create, describe, list, update, pause, resume, delete). To support the schedule entity's self-rearming, add an optional signal_time parameter to entity, orchestration-context, and client signal_entity methods, and make the in-memory backend honor delayed (scheduled) entity signals. Includes unit tests, in-memory E2E tests, and live DTS E2E tests for both the schedule feature and delayed signals, plus an example and changelog entries.
1 parent c115409 commit f8487e0

28 files changed

Lines changed: 2146 additions & 26 deletions

CHANGELOG.md

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

88
## Unreleased
99

10-
N/A
10+
ADDED
11+
12+
- Added `durabletask.scheduled`, a recurring schedule feature built on durable
13+
entities. Use `configure_scheduled_tasks(worker)` to enable it on a worker,
14+
then manage schedules from the client via `ScheduledTaskClient` (and the
15+
per-schedule `ScheduleClient`). Supports creating, describing, listing,
16+
updating, pausing, resuming, and deleting schedules with configurable
17+
`interval`, `start_at`, `end_at`, and `start_immediately_if_late` options.
18+
- Added an optional `signal_time` parameter to `EntityContext.signal_entity`
19+
and `DurableEntity.signal_entity`, allowing an entity signal to be scheduled
20+
for future delivery.
21+
- Added an optional `signal_time` parameter to `OrchestrationContext.signal_entity`
22+
and to the client `signal_entity` methods (sync and async), allowing entity
23+
signals to be scheduled for future delivery from orchestrations and clients.
1124

1225
## v1.6.0
1326

durabletask/client.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -719,8 +719,9 @@ def purge_orchestrations_by(self,
719719
def signal_entity(self,
720720
entity_instance_id: EntityInstanceId,
721721
operation_name: str,
722-
input: Any | None = None) -> None:
723-
req = build_signal_entity_req(entity_instance_id, operation_name, input)
722+
input: Any | None = None,
723+
signal_time: datetime | None = None) -> None:
724+
req = build_signal_entity_req(entity_instance_id, operation_name, input, signal_time)
724725
self._logger.info(f"Signaling entity '{entity_instance_id}' operation '{operation_name}'.")
725726
if self._payload_store is not None:
726727
payload_helpers.externalize_payloads(
@@ -1199,8 +1200,9 @@ async def purge_orchestrations_by(self,
11991200
async def signal_entity(self,
12001201
entity_instance_id: EntityInstanceId,
12011202
operation_name: str,
1202-
input: Any | None = None) -> None:
1203-
req = build_signal_entity_req(entity_instance_id, operation_name, input)
1203+
input: Any | None = None,
1204+
signal_time: datetime | None = None) -> None:
1205+
req = build_signal_entity_req(entity_instance_id, operation_name, input, signal_time)
12041206
self._logger.info(f"Signaling entity '{entity_instance_id}' operation '{operation_name}'.")
12051207
if self._payload_store is not None:
12061208
await payload_helpers.externalize_payloads_async(

durabletask/entities/durable_entity.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Licensed under the MIT License.
33

44
from typing import Any, TypeVar, overload
5+
from datetime import datetime
56

67
from durabletask.entities.entity_context import EntityContext
78
from durabletask.entities.entity_instance_id import EntityInstanceId
@@ -52,7 +53,9 @@ def set_state(self, state: Any):
5253
"""
5354
self.entity_context.set_state(state)
5455

55-
def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str, input: Any | None = None) -> None:
56+
def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str,
57+
input: Any | None = None,
58+
signal_time: datetime | None = None) -> None:
5659
"""Signal another entity to perform an operation.
5760
5861
Parameters
@@ -63,8 +66,12 @@ def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str, in
6366
The operation to perform on the entity.
6467
input : Any, optional
6568
The input to provide to the entity for the operation.
69+
signal_time : datetime, optional
70+
The time at which the signal should be delivered. If None, the signal is
71+
delivered as soon as possible. Use this to schedule a future operation,
72+
for example to have an entity wake itself up at a later time.
6673
"""
67-
self.entity_context.signal_entity(entity_instance_id, operation, input)
74+
self.entity_context.signal_entity(entity_instance_id, operation, input, signal_time)
6875

6976
def schedule_new_orchestration(self, orchestration_name: str, input: Any | None = None, instance_id: str | None = None) -> str:
7077
"""Schedule a new orchestration instance.

durabletask/entities/entity_context.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4+
from datetime import datetime
45
from typing import Any, TypeVar, overload
56
import uuid
7+
from google.protobuf import timestamp_pb2
68
from durabletask.entities.entity_instance_id import EntityInstanceId
79
from durabletask.internal import helpers, shared
810
from durabletask.internal.entity_state_shim import StateShim
@@ -83,7 +85,9 @@ def set_state(self, new_state: Any) -> None:
8385
"""
8486
self._state.set_state(new_state)
8587

86-
def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str, input: Any | None = None) -> None:
88+
def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str,
89+
input: Any | None = None,
90+
signal_time: datetime | None = None) -> None:
8791
"""Signal another entity to perform an operation.
8892
8993
Parameters
@@ -94,15 +98,23 @@ def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str, in
9498
The operation to perform on the entity.
9599
input : Any, optional
96100
The input to provide to the entity for the operation.
101+
signal_time : datetime, optional
102+
The time at which the signal should be delivered. If None, the signal is
103+
delivered as soon as possible. Use this to schedule a future operation,
104+
for example to have an entity wake itself up at a later time.
97105
"""
98106
encoded_input: str | None = shared.to_json(input) if input is not None else None
107+
scheduled_time: timestamp_pb2.Timestamp | None = None
108+
if signal_time is not None:
109+
scheduled_time = timestamp_pb2.Timestamp()
110+
scheduled_time.FromDatetime(signal_time)
99111
self._state.add_operation_action(
100112
pb.OperationAction(
101113
sendSignal=pb.SendSignalAction(
102114
instanceId=str(entity_instance_id),
103115
name=operation,
104116
input=helpers.get_string_value(encoded_input),
105-
scheduledTime=None,
117+
scheduledTime=scheduled_time,
106118
requestTime=None,
107119
parentTraceContext=None,
108120
)

durabletask/internal/client_helpers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,14 +192,16 @@ def build_terminate_req(
192192
def build_signal_entity_req(
193193
entity_instance_id: EntityInstanceId,
194194
operation_name: str,
195-
input: Any | None = None) -> pb.SignalEntityRequest:
195+
input: Any | None = None,
196+
signal_time: datetime | None = None) -> pb.SignalEntityRequest:
196197
"""Build a SignalEntityRequest for signaling an entity."""
198+
scheduled_time = helpers.new_timestamp(signal_time) if signal_time is not None else None
197199
return pb.SignalEntityRequest(
198200
instanceId=str(entity_instance_id),
199201
name=operation_name,
200202
input=helpers.get_string_value(shared.to_json(input) if input is not None else None),
201203
requestId=str(uuid.uuid4()),
202-
scheduledTime=None,
204+
scheduledTime=scheduled_time,
203205
parentTraceContext=None,
204206
requestTime=helpers.new_timestamp(datetime.now(timezone.utc))
205207
)

durabletask/internal/helpers.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,16 @@ def new_signal_entity_action(id: int,
255255
entity_id: EntityInstanceId,
256256
operation: str,
257257
encoded_input: str | None,
258-
request_id: str) -> pb.OrchestratorAction:
258+
request_id: str,
259+
scheduled_time: datetime | None = None) -> pb.OrchestratorAction:
260+
scheduled_timestamp: timestamp_pb2.Timestamp | None = None
261+
if scheduled_time is not None:
262+
scheduled_timestamp = timestamp_pb2.Timestamp()
263+
scheduled_timestamp.FromDatetime(scheduled_time)
259264
return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationSignaled=pb.EntityOperationSignaledEvent(
260265
requestId=request_id,
261266
operation=operation,
262-
scheduledTime=None,
267+
scheduledTime=scheduled_timestamp,
263268
input=get_string_value(encoded_input),
264269
targetInstanceId=get_string_value(str(entity_id)),
265270
)))

durabletask/scheduled/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Scheduled tasks support for the Durable Task SDK.
5+
6+
This package provides a recurring schedule feature built on top of durable
7+
entities and a helper orchestrator. Register the entity and orchestrator with a
8+
worker via :func:`configure_scheduled_tasks`, then manage schedules from the
9+
client via :class:`ScheduledTaskClient`.
10+
"""
11+
12+
from durabletask.scheduled.client import ScheduleClient, ScheduledTaskClient
13+
from durabletask.scheduled.exceptions import (ScheduleClientValidationError,
14+
ScheduleError,
15+
ScheduleInvalidTransitionError,
16+
ScheduleNotFoundError)
17+
from durabletask.scheduled.models import (ScheduleCreationOptions,
18+
ScheduleDescription, ScheduleQuery,
19+
ScheduleUpdateOptions)
20+
from durabletask.scheduled.registration import configure_scheduled_tasks
21+
from durabletask.scheduled.schedule_status import ScheduleStatus
22+
23+
__all__ = [
24+
"ScheduledTaskClient",
25+
"ScheduleClient",
26+
"ScheduleCreationOptions",
27+
"ScheduleUpdateOptions",
28+
"ScheduleDescription",
29+
"ScheduleQuery",
30+
"ScheduleStatus",
31+
"ScheduleError",
32+
"ScheduleNotFoundError",
33+
"ScheduleClientValidationError",
34+
"ScheduleInvalidTransitionError",
35+
"configure_scheduled_tasks",
36+
]
37+
38+
PACKAGE_NAME = "durabletask.scheduled"

durabletask/scheduled/client.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import logging
5+
from dataclasses import asdict
6+
from typing import Any
7+
8+
from durabletask.client import (EntityQuery, OrchestrationStatus,
9+
TaskHubGrpcClient)
10+
from durabletask.entities import EntityInstanceId
11+
from durabletask.internal import shared
12+
from durabletask.scheduled import transitions
13+
from durabletask.scheduled.exceptions import ScheduleNotFoundError
14+
from durabletask.scheduled.models import (ScheduleCreationOptions,
15+
ScheduleDescription, ScheduleQuery,
16+
ScheduleState, ScheduleUpdateOptions)
17+
from durabletask.scheduled.orchestrator import (
18+
ScheduleOperationRequest, execute_schedule_operation_orchestrator)
19+
from durabletask.scheduled.schedule_entity import (DELETE_OPERATION,
20+
ENTITY_NAME)
21+
22+
logger = logging.getLogger("durabletask.scheduled")
23+
24+
25+
def _parse_state(serialized_state: Any) -> ScheduleState | None:
26+
if serialized_state is None:
27+
return None
28+
data = serialized_state
29+
if isinstance(data, str):
30+
if not data.strip():
31+
# A deleted (or never-initialized) entity reports empty state.
32+
return None
33+
data = shared.from_json(data)
34+
if isinstance(data, dict):
35+
return ScheduleState.from_dict(data)
36+
return None
37+
38+
39+
class ScheduleClient:
40+
"""Client for managing a single schedule instance."""
41+
42+
def __init__(self, client: TaskHubGrpcClient, schedule_id: str,
43+
*, operation_timeout: float = 60):
44+
if not schedule_id:
45+
raise ValueError("schedule_id cannot be empty.")
46+
self._client = client
47+
self._schedule_id = schedule_id
48+
self._entity_id = EntityInstanceId(ENTITY_NAME, schedule_id)
49+
self._operation_timeout = operation_timeout
50+
51+
@property
52+
def schedule_id(self) -> str:
53+
"""Gets the ID of this schedule."""
54+
return self._schedule_id
55+
56+
def _run_operation(self, operation_name: str, input: Any | None = None) -> None:
57+
request = ScheduleOperationRequest(
58+
entity_id=str(self._entity_id),
59+
operation_name=operation_name,
60+
input=input,
61+
)
62+
instance_id = self._client.schedule_new_orchestration(
63+
execute_schedule_operation_orchestrator, input=asdict(request))
64+
state = self._client.wait_for_orchestration_completion(
65+
instance_id, timeout=self._operation_timeout)
66+
if state is None or state.runtime_status != OrchestrationStatus.COMPLETED:
67+
failure = state.failure_details if state else None
68+
message = failure.message if failure else "unknown error"
69+
raise RuntimeError(
70+
f"Failed to '{operation_name}' schedule '{self._schedule_id}': {message}")
71+
72+
def create(self, options: ScheduleCreationOptions) -> None:
73+
"""Create or update this schedule with the given configuration."""
74+
self._run_operation(transitions.CREATE_SCHEDULE, options.to_dict())
75+
76+
def update(self, options: ScheduleUpdateOptions) -> None:
77+
"""Update this schedule's configuration."""
78+
self._run_operation(transitions.UPDATE_SCHEDULE, options.to_dict())
79+
80+
def pause(self) -> None:
81+
"""Pause this schedule."""
82+
self._run_operation(transitions.PAUSE_SCHEDULE)
83+
84+
def resume(self) -> None:
85+
"""Resume this schedule."""
86+
self._run_operation(transitions.RESUME_SCHEDULE)
87+
88+
def delete(self) -> None:
89+
"""Delete this schedule."""
90+
self._run_operation(DELETE_OPERATION)
91+
92+
def describe(self) -> ScheduleDescription:
93+
"""Retrieve the current details of this schedule."""
94+
metadata = self._client.get_entity(self._entity_id, include_state=True)
95+
if metadata is None:
96+
raise ScheduleNotFoundError(self._schedule_id)
97+
state = _parse_state(metadata.get_state())
98+
if state is None:
99+
raise ScheduleNotFoundError(self._schedule_id)
100+
return state.to_description()
101+
102+
103+
class ScheduledTaskClient:
104+
"""Client for managing scheduled tasks in a Durable Task application."""
105+
106+
def __init__(self, client: TaskHubGrpcClient, *, operation_timeout: float = 60):
107+
self._client = client
108+
self._operation_timeout = operation_timeout
109+
110+
def get_schedule_client(self, schedule_id: str) -> ScheduleClient:
111+
"""Get a handle to manage a specific schedule."""
112+
return ScheduleClient(self._client, schedule_id,
113+
operation_timeout=self._operation_timeout)
114+
115+
def create_schedule(self, options: ScheduleCreationOptions) -> ScheduleClient:
116+
"""Create a new schedule and return a client for managing it."""
117+
schedule_client = self.get_schedule_client(options.schedule_id)
118+
schedule_client.create(options)
119+
return schedule_client
120+
121+
def get_schedule(self, schedule_id: str) -> ScheduleDescription | None:
122+
"""Get a schedule description by ID, or None if it does not exist."""
123+
try:
124+
return self.get_schedule_client(schedule_id).describe()
125+
except ScheduleNotFoundError:
126+
return None
127+
128+
def list_schedules(self, filter: ScheduleQuery | None = None) -> list[ScheduleDescription]:
129+
"""List schedules matching the given filter criteria."""
130+
prefix = filter.schedule_id_prefix if filter and filter.schedule_id_prefix else ""
131+
page_size = filter.page_size if filter and filter.page_size else ScheduleQuery.DEFAULT_PAGE_SIZE
132+
query = EntityQuery(
133+
instance_id_starts_with=f"@{ENTITY_NAME}@{prefix}",
134+
include_state=True,
135+
page_size=page_size,
136+
)
137+
results: list[ScheduleDescription] = []
138+
for metadata in self._client.get_all_entities(query):
139+
state = _parse_state(metadata.get_state())
140+
if state is None or state.schedule_configuration is None:
141+
continue
142+
if not self._matches_filter(state, filter):
143+
continue
144+
results.append(state.to_description())
145+
return results
146+
147+
@staticmethod
148+
def _matches_filter(state: ScheduleState, filter: ScheduleQuery | None) -> bool:
149+
if filter is None:
150+
return True
151+
if filter.status is not None and state.status != filter.status:
152+
return False
153+
created_at = state.schedule_created_at
154+
if filter.created_from is not None and not (created_at and created_at > filter.created_from):
155+
return False
156+
if filter.created_to is not None and not (created_at and created_at < filter.created_to):
157+
return False
158+
return True
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
5+
class ScheduleError(Exception):
6+
"""Base class for schedule-related errors."""
7+
8+
9+
class ScheduleNotFoundError(ScheduleError):
10+
"""Raised when a requested schedule does not exist."""
11+
12+
def __init__(self, schedule_id: str):
13+
self.schedule_id = schedule_id
14+
super().__init__(f"Schedule with ID '{schedule_id}' was not found.")
15+
16+
17+
class ScheduleClientValidationError(ScheduleError):
18+
"""Raised when a schedule operation fails client-side validation."""
19+
20+
def __init__(self, schedule_id: str, message: str):
21+
self.schedule_id = schedule_id
22+
super().__init__(f"Validation failed for schedule '{schedule_id}': {message}")
23+
24+
25+
class ScheduleInvalidTransitionError(ScheduleError):
26+
"""Raised when an operation is not valid for the schedule's current status."""
27+
28+
def __init__(self, schedule_id: str, from_status: object, to_status: object, operation_name: str):
29+
self.schedule_id = schedule_id
30+
self.from_status = from_status
31+
self.to_status = to_status
32+
self.operation_name = operation_name
33+
super().__init__(
34+
f"Invalid state transition for schedule '{schedule_id}': operation "
35+
f"'{operation_name}' cannot transition from '{from_status}' to '{to_status}'."
36+
)

0 commit comments

Comments
 (0)