durabletask 1.4.0.dev30__py3-none-any.whl → 1.4.0.dev32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
durabletask/__init__.py CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  """Durable Task SDK for Python"""
5
5
 
6
+ from durabletask.grpc_options import GrpcChannelOptions, GrpcRetryPolicyOptions
6
7
  from durabletask.payload.store import LargePayloadStorageOptions, PayloadStore
7
8
  from durabletask.worker import (
8
9
  ActivityWorkItemFilter,
@@ -17,6 +18,8 @@ __all__ = [
17
18
  "ActivityWorkItemFilter",
18
19
  "ConcurrencyOptions",
19
20
  "EntityWorkItemFilter",
21
+ "GrpcChannelOptions",
22
+ "GrpcRetryPolicyOptions",
20
23
  "LargePayloadStorageOptions",
21
24
  "OrchestrationWorkItemFilter",
22
25
  "PayloadStore",
durabletask/client.py CHANGED
@@ -6,14 +6,17 @@ import uuid
6
6
  from dataclasses import dataclass
7
7
  from datetime import datetime
8
8
  from enum import Enum
9
- from typing import Any, List, Optional, Sequence, TypeVar, Union
9
+ from typing import Any, Generic, List, Optional, Sequence, TypeVar, Union
10
10
 
11
11
  import grpc
12
12
  import grpc.aio
13
13
 
14
+ import durabletask.history as history
14
15
  from durabletask.entities import EntityInstanceId
15
16
  from durabletask.entities.entity_metadata import EntityMetadata
17
+ from durabletask.grpc_options import GrpcChannelOptions
16
18
  import durabletask.internal.helpers as helpers
19
+ import durabletask.internal.history_helpers as history_helpers
17
20
  import durabletask.internal.orchestrator_service_pb2 as pb
18
21
  import durabletask.internal.orchestrator_service_pb2_grpc as stubs
19
22
  import durabletask.internal.shared as shared
@@ -37,6 +40,7 @@ from durabletask.payload.store import PayloadStore
37
40
 
38
41
  TInput = TypeVar('TInput')
39
42
  TOutput = TypeVar('TOutput')
43
+ TItem = TypeVar('TItem')
40
44
 
41
45
 
42
46
  class OrchestrationStatus(Enum):
@@ -99,6 +103,12 @@ class PurgeInstancesResult:
99
103
  is_complete: bool
100
104
 
101
105
 
106
+ @dataclass
107
+ class Page(Generic[TItem]):
108
+ items: List[TItem]
109
+ continuation_token: Optional[str]
110
+
111
+
102
112
  @dataclass
103
113
  class CleanEntityStorageResult:
104
114
  empty_entities_removed: int
@@ -152,18 +162,22 @@ class TaskHubGrpcClient:
152
162
  metadata: Optional[list[tuple[str, str]]] = None,
153
163
  log_handler: Optional[logging.Handler] = None,
154
164
  log_formatter: Optional[logging.Formatter] = None,
165
+ channel: Optional[grpc.Channel] = None,
155
166
  secure_channel: bool = False,
156
167
  interceptors: Optional[Sequence[shared.ClientInterceptor]] = None,
168
+ channel_options: Optional[GrpcChannelOptions] = None,
157
169
  default_version: Optional[str] = None,
158
170
  payload_store: Optional[PayloadStore] = None):
159
171
 
160
- interceptors = prepare_sync_interceptors(metadata, interceptors)
161
-
162
- channel = shared.get_grpc_channel(
163
- host_address=host_address,
164
- secure_channel=secure_channel,
165
- interceptors=interceptors
166
- )
172
+ self._owns_channel = channel is None
173
+ if channel is None:
174
+ interceptors = prepare_sync_interceptors(metadata, interceptors)
175
+ channel = shared.get_grpc_channel(
176
+ host_address=host_address,
177
+ secure_channel=secure_channel,
178
+ interceptors=interceptors,
179
+ channel_options=channel_options,
180
+ )
167
181
  self._channel = channel
168
182
  self._stub = stubs.TaskHubSidecarServiceStub(channel)
169
183
  self._logger = shared.get_logger("client", log_handler, log_formatter)
@@ -171,8 +185,15 @@ class TaskHubGrpcClient:
171
185
  self._payload_store = payload_store
172
186
 
173
187
  def close(self) -> None:
174
- """Closes the underlying gRPC channel."""
175
- self._channel.close()
188
+ """Closes the underlying gRPC channel.
189
+
190
+ Only closes channels created internally. If a pre-configured channel
191
+ was passed via the ``channel`` constructor parameter, this method is
192
+ a no-op — the caller retains ownership and is responsible for closing
193
+ it.
194
+ """
195
+ if self._owns_channel:
196
+ self._channel.close()
176
197
 
177
198
  def schedule_new_orchestration(self, orchestrator: Union[task.Orchestrator[TInput, TOutput], str], *,
178
199
  input: Optional[TInput] = None,
@@ -218,6 +239,44 @@ class TaskHubGrpcClient:
218
239
  payload_helpers.deexternalize_payloads(res, self._payload_store)
219
240
  return new_orchestration_state(req.instanceId, res)
220
241
 
242
+ def get_orchestration_history(self,
243
+ instance_id: str, *,
244
+ execution_id: Optional[str] = None,
245
+ for_work_item_processing: bool = False) -> List[history.HistoryEvent]:
246
+ req = pb.StreamInstanceHistoryRequest(
247
+ instanceId=instance_id,
248
+ executionId=helpers.get_string_value(execution_id),
249
+ forWorkItemProcessing=for_work_item_processing,
250
+ )
251
+ self._logger.info(f"Retrieving history for instance '{instance_id}'.")
252
+ stream = self._stub.StreamInstanceHistory(req)
253
+ return history_helpers.collect_history_events(stream, self._payload_store)
254
+
255
+ def list_instance_ids(self,
256
+ runtime_status: Optional[List[OrchestrationStatus]] = None,
257
+ completed_time_from: Optional[datetime] = None,
258
+ completed_time_to: Optional[datetime] = None,
259
+ page_size: Optional[int] = None,
260
+ continuation_token: Optional[str] = None) -> Page[str]:
261
+ req = pb.ListInstanceIdsRequest(
262
+ runtimeStatus=[status.value for status in runtime_status] if runtime_status else [],
263
+ completedTimeFrom=helpers.new_timestamp(completed_time_from) if completed_time_from else None,
264
+ completedTimeTo=helpers.new_timestamp(completed_time_to) if completed_time_to else None,
265
+ pageSize=page_size or 0,
266
+ lastInstanceKey=helpers.get_string_value(continuation_token),
267
+ )
268
+ self._logger.info(
269
+ "Listing terminal instance IDs with filters: "
270
+ f"runtime_status={[str(status) for status in runtime_status] if runtime_status else None}, "
271
+ f"completed_time_from={completed_time_from}, "
272
+ f"completed_time_to={completed_time_to}, "
273
+ f"page_size={page_size}, "
274
+ f"continuation_token={continuation_token}"
275
+ )
276
+ resp: pb.ListInstanceIdsResponse = self._stub.ListInstanceIds(req)
277
+ next_token = resp.lastInstanceKey.value if resp.HasField("lastInstanceKey") else None
278
+ return Page(items=list(resp.instanceIds), continuation_token=next_token)
279
+
221
280
  def get_all_orchestration_states(self,
222
281
  orchestration_query: Optional[OrchestrationQuery] = None
223
282
  ) -> List[OrchestrationState]:
@@ -433,18 +492,22 @@ class AsyncTaskHubGrpcClient:
433
492
  metadata: Optional[list[tuple[str, str]]] = None,
434
493
  log_handler: Optional[logging.Handler] = None,
435
494
  log_formatter: Optional[logging.Formatter] = None,
495
+ channel: Optional[grpc.aio.Channel] = None,
436
496
  secure_channel: bool = False,
437
497
  interceptors: Optional[Sequence[shared.AsyncClientInterceptor]] = None,
498
+ channel_options: Optional[GrpcChannelOptions] = None,
438
499
  default_version: Optional[str] = None,
439
500
  payload_store: Optional[PayloadStore] = None):
440
501
 
441
- interceptors = prepare_async_interceptors(metadata, interceptors)
442
-
443
- channel = shared.get_async_grpc_channel(
444
- host_address=host_address,
445
- secure_channel=secure_channel,
446
- interceptors=interceptors
447
- )
502
+ self._owns_channel = channel is None
503
+ if channel is None:
504
+ interceptors = prepare_async_interceptors(metadata, interceptors)
505
+ channel = shared.get_async_grpc_channel(
506
+ host_address=host_address,
507
+ secure_channel=secure_channel,
508
+ interceptors=interceptors,
509
+ channel_options=channel_options,
510
+ )
448
511
  self._channel = channel
449
512
  self._stub = stubs.TaskHubSidecarServiceStub(channel)
450
513
  self._logger = shared.get_logger("async_client", log_handler, log_formatter)
@@ -452,8 +515,15 @@ class AsyncTaskHubGrpcClient:
452
515
  self._payload_store = payload_store
453
516
 
454
517
  async def close(self) -> None:
455
- """Closes the underlying gRPC channel."""
456
- await self._channel.close()
518
+ """Closes the underlying gRPC channel.
519
+
520
+ Only closes channels created internally. If a pre-configured channel
521
+ was passed via the ``channel`` constructor parameter, this method is
522
+ a no-op — the caller retains ownership and is responsible for closing
523
+ it.
524
+ """
525
+ if self._owns_channel:
526
+ await self._channel.close()
457
527
 
458
528
  async def __aenter__(self):
459
529
  return self
@@ -502,6 +572,44 @@ class AsyncTaskHubGrpcClient:
502
572
  await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
503
573
  return new_orchestration_state(req.instanceId, res)
504
574
 
575
+ async def get_orchestration_history(self,
576
+ instance_id: str, *,
577
+ execution_id: Optional[str] = None,
578
+ for_work_item_processing: bool = False) -> List[history.HistoryEvent]:
579
+ req = pb.StreamInstanceHistoryRequest(
580
+ instanceId=instance_id,
581
+ executionId=helpers.get_string_value(execution_id),
582
+ forWorkItemProcessing=for_work_item_processing,
583
+ )
584
+ self._logger.info(f"Retrieving history for instance '{instance_id}'.")
585
+ stream = self._stub.StreamInstanceHistory(req)
586
+ return await history_helpers.collect_history_events_async(stream, self._payload_store)
587
+
588
+ async def list_instance_ids(self,
589
+ runtime_status: Optional[List[OrchestrationStatus]] = None,
590
+ completed_time_from: Optional[datetime] = None,
591
+ completed_time_to: Optional[datetime] = None,
592
+ page_size: Optional[int] = None,
593
+ continuation_token: Optional[str] = None) -> Page[str]:
594
+ req = pb.ListInstanceIdsRequest(
595
+ runtimeStatus=[status.value for status in runtime_status] if runtime_status else [],
596
+ completedTimeFrom=helpers.new_timestamp(completed_time_from) if completed_time_from else None,
597
+ completedTimeTo=helpers.new_timestamp(completed_time_to) if completed_time_to else None,
598
+ pageSize=page_size or 0,
599
+ lastInstanceKey=helpers.get_string_value(continuation_token),
600
+ )
601
+ self._logger.info(
602
+ "Listing terminal instance IDs with filters: "
603
+ f"runtime_status={[str(status) for status in runtime_status] if runtime_status else None}, "
604
+ f"completed_time_from={completed_time_from}, "
605
+ f"completed_time_to={completed_time_to}, "
606
+ f"page_size={page_size}, "
607
+ f"continuation_token={continuation_token}"
608
+ )
609
+ resp: pb.ListInstanceIdsResponse = await self._stub.ListInstanceIds(req)
610
+ next_token = resp.lastInstanceKey.value if resp.HasField("lastInstanceKey") else None
611
+ return Page(items=list(resp.instanceIds), continuation_token=next_token)
612
+
505
613
  async def get_all_orchestration_states(self,
506
614
  orchestration_query: Optional[OrchestrationQuery] = None
507
615
  ) -> List[OrchestrationState]:
@@ -0,0 +1,102 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ import json
8
+ from typing import Any, Optional
9
+
10
+
11
+ @dataclass
12
+ class GrpcRetryPolicyOptions:
13
+ """Configuration for transport-level gRPC retries."""
14
+
15
+ max_attempts: int = 4
16
+ initial_backoff_seconds: float = 0.05
17
+ max_backoff_seconds: float = 0.25
18
+ backoff_multiplier: float = 2.0
19
+ retryable_status_codes: list[str] = field(default_factory=lambda: ["UNAVAILABLE"])
20
+
21
+ def __post_init__(self) -> None:
22
+ if self.max_attempts < 2:
23
+ raise ValueError("max_attempts must be >= 2")
24
+ if self.initial_backoff_seconds <= 0:
25
+ raise ValueError("initial_backoff_seconds must be > 0")
26
+ if self.max_backoff_seconds <= 0:
27
+ raise ValueError("max_backoff_seconds must be > 0")
28
+ if self.backoff_multiplier <= 0:
29
+ raise ValueError("backoff_multiplier must be > 0")
30
+ if self.max_backoff_seconds < self.initial_backoff_seconds:
31
+ raise ValueError("max_backoff_seconds must be >= initial_backoff_seconds")
32
+ if len(self.retryable_status_codes) == 0:
33
+ raise ValueError("retryable_status_codes cannot be empty")
34
+ # Validate that backoff values are representable as non-zero gRPC duration strings.
35
+ self._format_duration(self.initial_backoff_seconds)
36
+ self._format_duration(self.max_backoff_seconds)
37
+
38
+ @staticmethod
39
+ def _format_duration(seconds: float) -> str:
40
+ formatted = f"{seconds:.9f}".rstrip('0')
41
+ if formatted.endswith('.'):
42
+ formatted += '0'
43
+ if float(formatted) == 0:
44
+ raise ValueError(
45
+ f"Duration {seconds!r} rounds to zero; use a value large enough to "
46
+ "produce a non-zero gRPC duration string."
47
+ )
48
+ return f"{formatted}s"
49
+
50
+ def to_service_config(self) -> dict[str, Any]:
51
+ return {
52
+ "methodConfig": [
53
+ {
54
+ "name": [{}],
55
+ "retryPolicy": {
56
+ "maxAttempts": self.max_attempts,
57
+ "initialBackoff": self._format_duration(self.initial_backoff_seconds),
58
+ "maxBackoff": self._format_duration(self.max_backoff_seconds),
59
+ "backoffMultiplier": self.backoff_multiplier,
60
+ "retryableStatusCodes": self.retryable_status_codes,
61
+ },
62
+ }
63
+ ]
64
+ }
65
+
66
+
67
+ @dataclass
68
+ class GrpcChannelOptions:
69
+ """Configuration for transport-level gRPC channel behavior."""
70
+
71
+ max_receive_message_length: Optional[int] = None
72
+ max_send_message_length: Optional[int] = None
73
+ keepalive_time_ms: Optional[int] = None
74
+ keepalive_timeout_ms: Optional[int] = None
75
+ keepalive_permit_without_calls: Optional[bool] = None
76
+ retry_policy: Optional[GrpcRetryPolicyOptions] = None
77
+ raw_options: list[tuple[str, Any]] = field(default_factory=list)
78
+
79
+ def to_grpc_options(self) -> list[tuple[str, Any]]:
80
+ options = list(self.raw_options)
81
+
82
+ if self.max_receive_message_length is not None:
83
+ options.append(("grpc.max_receive_message_length", self.max_receive_message_length))
84
+ if self.max_send_message_length is not None:
85
+ options.append(("grpc.max_send_message_length", self.max_send_message_length))
86
+ if self.keepalive_time_ms is not None:
87
+ options.append(("grpc.keepalive_time_ms", self.keepalive_time_ms))
88
+ if self.keepalive_timeout_ms is not None:
89
+ options.append(("grpc.keepalive_timeout_ms", self.keepalive_timeout_ms))
90
+ if self.keepalive_permit_without_calls is not None:
91
+ options.append(
92
+ (
93
+ "grpc.keepalive_permit_without_calls",
94
+ 1 if self.keepalive_permit_without_calls else 0,
95
+ )
96
+ )
97
+
98
+ if self.retry_policy is not None:
99
+ options.append(("grpc.enable_retries", 1))
100
+ options.append(("grpc.service_config", json.dumps(self.retry_policy.to_service_config())))
101
+
102
+ return options
durabletask/history.py ADDED
@@ -0,0 +1,535 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import asdict, dataclass
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Optional
9
+
10
+ from google.protobuf import json_format
11
+ from google.protobuf.message import Message
12
+
13
+ from durabletask import task
14
+ import durabletask.internal.orchestrator_service_pb2 as pb
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class OrchestrationInstance:
19
+ instance_id: str
20
+ execution_id: Optional[str] = None
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class ParentInstanceInfo:
25
+ task_scheduled_id: int
26
+ name: Optional[str] = None
27
+ version: Optional[str] = None
28
+ orchestration_instance: Optional[OrchestrationInstance] = None
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class TraceContext:
33
+ trace_parent: str
34
+ span_id: str
35
+ trace_state: Optional[str] = None
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class HistoryEvent:
40
+ event_id: int
41
+ timestamp: datetime
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ return _to_serializable(asdict(self))
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class ExecutionStartedEvent(HistoryEvent):
49
+ name: str
50
+ version: Optional[str] = None
51
+ input: Optional[str] = None
52
+ orchestration_instance: Optional[OrchestrationInstance] = None
53
+ parent_instance: Optional[ParentInstanceInfo] = None
54
+ scheduled_start_timestamp: Optional[datetime] = None
55
+ parent_trace_context: Optional[TraceContext] = None
56
+ orchestration_span_id: Optional[str] = None
57
+ tags: Optional[dict[str, str]] = None
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class ExecutionCompletedEvent(HistoryEvent):
62
+ orchestration_status: int
63
+ result: Optional[str] = None
64
+ failure_details: Optional[task.FailureDetails] = None
65
+
66
+
67
+ @dataclass(slots=True)
68
+ class ExecutionTerminatedEvent(HistoryEvent):
69
+ input: Optional[str] = None
70
+ recurse: bool = False
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class TaskScheduledEvent(HistoryEvent):
75
+ name: str
76
+ version: Optional[str] = None
77
+ input: Optional[str] = None
78
+ parent_trace_context: Optional[TraceContext] = None
79
+ tags: Optional[dict[str, str]] = None
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class TaskCompletedEvent(HistoryEvent):
84
+ task_scheduled_id: int
85
+ result: Optional[str] = None
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class TaskFailedEvent(HistoryEvent):
90
+ task_scheduled_id: int
91
+ failure_details: Optional[task.FailureDetails] = None
92
+
93
+
94
+ @dataclass(slots=True)
95
+ class SubOrchestrationInstanceCreatedEvent(HistoryEvent):
96
+ instance_id: str
97
+ name: str
98
+ version: Optional[str] = None
99
+ input: Optional[str] = None
100
+ parent_trace_context: Optional[TraceContext] = None
101
+ tags: Optional[dict[str, str]] = None
102
+
103
+
104
+ @dataclass(slots=True)
105
+ class SubOrchestrationInstanceCompletedEvent(HistoryEvent):
106
+ task_scheduled_id: int
107
+ result: Optional[str] = None
108
+
109
+
110
+ @dataclass(slots=True)
111
+ class SubOrchestrationInstanceFailedEvent(HistoryEvent):
112
+ task_scheduled_id: int
113
+ failure_details: Optional[task.FailureDetails] = None
114
+
115
+
116
+ @dataclass(slots=True)
117
+ class TimerCreatedEvent(HistoryEvent):
118
+ fire_at: datetime
119
+
120
+
121
+ @dataclass(slots=True)
122
+ class TimerFiredEvent(HistoryEvent):
123
+ fire_at: datetime
124
+ timer_id: int
125
+
126
+
127
+ @dataclass(slots=True)
128
+ class OrchestratorStartedEvent(HistoryEvent):
129
+ pass
130
+
131
+
132
+ @dataclass(slots=True)
133
+ class OrchestratorCompletedEvent(HistoryEvent):
134
+ pass
135
+
136
+
137
+ @dataclass(slots=True)
138
+ class EventSentEvent(HistoryEvent):
139
+ instance_id: str
140
+ name: str
141
+ input: Optional[str] = None
142
+
143
+
144
+ @dataclass(slots=True)
145
+ class EventRaisedEvent(HistoryEvent):
146
+ name: str
147
+ input: Optional[str] = None
148
+
149
+
150
+ @dataclass(slots=True)
151
+ class GenericEvent(HistoryEvent):
152
+ data: Optional[str] = None
153
+
154
+
155
+ @dataclass(slots=True)
156
+ class HistoryStateEvent(HistoryEvent):
157
+ orchestration_state: dict[str, Any]
158
+
159
+
160
+ @dataclass(slots=True)
161
+ class ContinueAsNewEvent(HistoryEvent):
162
+ input: Optional[str] = None
163
+
164
+
165
+ @dataclass(slots=True)
166
+ class ExecutionSuspendedEvent(HistoryEvent):
167
+ input: Optional[str] = None
168
+
169
+
170
+ @dataclass(slots=True)
171
+ class ExecutionResumedEvent(HistoryEvent):
172
+ input: Optional[str] = None
173
+
174
+
175
+ @dataclass(slots=True)
176
+ class EntityOperationSignaledEvent(HistoryEvent):
177
+ request_id: str
178
+ operation: str
179
+ scheduled_time: Optional[datetime] = None
180
+ input: Optional[str] = None
181
+ target_instance_id: Optional[str] = None
182
+
183
+
184
+ @dataclass(slots=True)
185
+ class EntityOperationCalledEvent(HistoryEvent):
186
+ request_id: str
187
+ operation: str
188
+ scheduled_time: Optional[datetime] = None
189
+ input: Optional[str] = None
190
+ parent_instance_id: Optional[str] = None
191
+ parent_execution_id: Optional[str] = None
192
+ target_instance_id: Optional[str] = None
193
+
194
+
195
+ @dataclass(slots=True)
196
+ class EntityOperationCompletedEvent(HistoryEvent):
197
+ request_id: str
198
+ output: Optional[str] = None
199
+
200
+
201
+ @dataclass(slots=True)
202
+ class EntityOperationFailedEvent(HistoryEvent):
203
+ request_id: str
204
+ failure_details: Optional[task.FailureDetails] = None
205
+
206
+
207
+ @dataclass(slots=True)
208
+ class EntityLockRequestedEvent(HistoryEvent):
209
+ critical_section_id: str
210
+ lock_set: list[str]
211
+ position: int
212
+ parent_instance_id: Optional[str] = None
213
+
214
+
215
+ @dataclass(slots=True)
216
+ class EntityLockGrantedEvent(HistoryEvent):
217
+ critical_section_id: str
218
+
219
+
220
+ @dataclass(slots=True)
221
+ class EntityUnlockSentEvent(HistoryEvent):
222
+ critical_section_id: str
223
+ parent_instance_id: Optional[str] = None
224
+ target_instance_id: Optional[str] = None
225
+
226
+
227
+ @dataclass(slots=True)
228
+ class ExecutionRewoundEvent(HistoryEvent):
229
+ reason: Optional[str] = None
230
+ parent_execution_id: Optional[str] = None
231
+ instance_id: Optional[str] = None
232
+ parent_trace_context: Optional[TraceContext] = None
233
+ name: Optional[str] = None
234
+ version: Optional[str] = None
235
+ input: Optional[str] = None
236
+ parent_instance: Optional[ParentInstanceInfo] = None
237
+ tags: Optional[dict[str, str]] = None
238
+
239
+
240
+ def _from_protobuf(event: pb.HistoryEvent) -> HistoryEvent:
241
+ event_type = event.WhichOneof('eventType')
242
+ if event_type is None:
243
+ raise ValueError('History event does not have an eventType set')
244
+ converter = _EVENT_CONVERTERS.get(event_type)
245
+ if converter is None:
246
+ raise ValueError(f'Unsupported history event type: {event_type}')
247
+ return converter(event)
248
+
249
+
250
+ def to_dict(event: HistoryEvent) -> dict[str, Any]:
251
+ return event.to_dict()
252
+
253
+
254
+ def _base_kwargs(event: pb.HistoryEvent) -> dict[str, Any]:
255
+ return {
256
+ 'event_id': event.eventId,
257
+ 'timestamp': event.timestamp.ToDatetime(timezone.utc),
258
+ }
259
+
260
+
261
+ def _string_value(msg: Message, field_name: str) -> Optional[str]:
262
+ if msg.HasField(field_name):
263
+ return getattr(msg, field_name).value
264
+ return None
265
+
266
+
267
+ def _timestamp_value(msg: Message, field_name: str) -> Optional[datetime]:
268
+ if msg.HasField(field_name):
269
+ return getattr(msg, field_name).ToDatetime(timezone.utc)
270
+ return None
271
+
272
+
273
+ def _failure_details(msg: Message, field_name: str) -> Optional[task.FailureDetails]:
274
+ if not msg.HasField(field_name):
275
+ return None
276
+ details = getattr(msg, field_name)
277
+ return task.FailureDetails(
278
+ details.errorMessage,
279
+ details.errorType,
280
+ details.stackTrace.value if details.HasField('stackTrace') else None,
281
+ )
282
+
283
+
284
+ def _trace_context(msg: Message, field_name: str) -> Optional[TraceContext]:
285
+ if not msg.HasField(field_name):
286
+ return None
287
+ value = getattr(msg, field_name)
288
+ return TraceContext(
289
+ trace_parent=value.traceParent,
290
+ span_id=value.spanID,
291
+ trace_state=value.traceState.value if value.HasField('traceState') else None,
292
+ )
293
+
294
+
295
+ def _orchestration_instance(msg: Message, field_name: str) -> Optional[OrchestrationInstance]:
296
+ if not msg.HasField(field_name):
297
+ return None
298
+ value = getattr(msg, field_name)
299
+ return OrchestrationInstance(
300
+ instance_id=value.instanceId,
301
+ execution_id=value.executionId.value if value.HasField('executionId') else None,
302
+ )
303
+
304
+
305
+ def _parent_instance(msg: Message, field_name: str) -> Optional[ParentInstanceInfo]:
306
+ if not msg.HasField(field_name):
307
+ return None
308
+ value = getattr(msg, field_name)
309
+ orchestration_instance = None
310
+ if value.HasField('orchestrationInstance'):
311
+ orchestration_instance = OrchestrationInstance(
312
+ instance_id=value.orchestrationInstance.instanceId,
313
+ execution_id=value.orchestrationInstance.executionId.value
314
+ if value.orchestrationInstance.HasField('executionId') else None,
315
+ )
316
+ return ParentInstanceInfo(
317
+ task_scheduled_id=value.taskScheduledId,
318
+ name=value.name.value if value.HasField('name') else None,
319
+ version=value.version.value if value.HasField('version') else None,
320
+ orchestration_instance=orchestration_instance,
321
+ )
322
+
323
+
324
+ def _message_to_dict(msg: Message) -> dict[str, Any]:
325
+ return json_format.MessageToDict(msg, preserving_proto_field_name=True)
326
+
327
+
328
+ def _to_serializable(value: Any) -> Any:
329
+ if isinstance(value, datetime):
330
+ return value.isoformat()
331
+ if isinstance(value, list):
332
+ return [_to_serializable(item) for item in value]
333
+ if isinstance(value, dict):
334
+ return {key: _to_serializable(item) for key, item in value.items()}
335
+ return value
336
+
337
+
338
+ _EVENT_CONVERTERS: dict[str, Any] = {
339
+ 'executionStarted': lambda event: ExecutionStartedEvent(
340
+ **_base_kwargs(event),
341
+ name=event.executionStarted.name,
342
+ version=_string_value(event.executionStarted, 'version'),
343
+ input=_string_value(event.executionStarted, 'input'),
344
+ orchestration_instance=_orchestration_instance(event.executionStarted, 'orchestrationInstance'),
345
+ parent_instance=_parent_instance(event.executionStarted, 'parentInstance'),
346
+ scheduled_start_timestamp=_timestamp_value(event.executionStarted, 'scheduledStartTimestamp'),
347
+ parent_trace_context=_trace_context(event.executionStarted, 'parentTraceContext'),
348
+ orchestration_span_id=_string_value(event.executionStarted, 'orchestrationSpanID'),
349
+ tags=dict(event.executionStarted.tags) if event.executionStarted.tags else None,
350
+ ),
351
+ 'executionCompleted': lambda event: ExecutionCompletedEvent(
352
+ **_base_kwargs(event),
353
+ orchestration_status=event.executionCompleted.orchestrationStatus,
354
+ result=_string_value(event.executionCompleted, 'result'),
355
+ failure_details=_failure_details(event.executionCompleted, 'failureDetails'),
356
+ ),
357
+ 'executionTerminated': lambda event: ExecutionTerminatedEvent(
358
+ **_base_kwargs(event),
359
+ input=_string_value(event.executionTerminated, 'input'),
360
+ recurse=event.executionTerminated.recurse,
361
+ ),
362
+ 'taskScheduled': lambda event: TaskScheduledEvent(
363
+ **_base_kwargs(event),
364
+ name=event.taskScheduled.name,
365
+ version=_string_value(event.taskScheduled, 'version'),
366
+ input=_string_value(event.taskScheduled, 'input'),
367
+ parent_trace_context=_trace_context(event.taskScheduled, 'parentTraceContext'),
368
+ tags=dict(event.taskScheduled.tags) if event.taskScheduled.tags else None,
369
+ ),
370
+ 'taskCompleted': lambda event: TaskCompletedEvent(
371
+ **_base_kwargs(event),
372
+ task_scheduled_id=event.taskCompleted.taskScheduledId,
373
+ result=_string_value(event.taskCompleted, 'result'),
374
+ ),
375
+ 'taskFailed': lambda event: TaskFailedEvent(
376
+ **_base_kwargs(event),
377
+ task_scheduled_id=event.taskFailed.taskScheduledId,
378
+ failure_details=_failure_details(event.taskFailed, 'failureDetails'),
379
+ ),
380
+ 'subOrchestrationInstanceCreated': lambda event: SubOrchestrationInstanceCreatedEvent(
381
+ **_base_kwargs(event),
382
+ instance_id=event.subOrchestrationInstanceCreated.instanceId,
383
+ name=event.subOrchestrationInstanceCreated.name,
384
+ version=_string_value(event.subOrchestrationInstanceCreated, 'version'),
385
+ input=_string_value(event.subOrchestrationInstanceCreated, 'input'),
386
+ parent_trace_context=_trace_context(event.subOrchestrationInstanceCreated, 'parentTraceContext'),
387
+ tags=dict(event.subOrchestrationInstanceCreated.tags) if event.subOrchestrationInstanceCreated.tags else None,
388
+ ),
389
+ 'subOrchestrationInstanceCompleted': lambda event: SubOrchestrationInstanceCompletedEvent(
390
+ **_base_kwargs(event),
391
+ task_scheduled_id=event.subOrchestrationInstanceCompleted.taskScheduledId,
392
+ result=_string_value(event.subOrchestrationInstanceCompleted, 'result'),
393
+ ),
394
+ 'subOrchestrationInstanceFailed': lambda event: SubOrchestrationInstanceFailedEvent(
395
+ **_base_kwargs(event),
396
+ task_scheduled_id=event.subOrchestrationInstanceFailed.taskScheduledId,
397
+ failure_details=_failure_details(event.subOrchestrationInstanceFailed, 'failureDetails'),
398
+ ),
399
+ 'timerCreated': lambda event: TimerCreatedEvent(
400
+ **_base_kwargs(event),
401
+ fire_at=event.timerCreated.fireAt.ToDatetime(timezone.utc),
402
+ ),
403
+ 'timerFired': lambda event: TimerFiredEvent(
404
+ **_base_kwargs(event),
405
+ fire_at=event.timerFired.fireAt.ToDatetime(timezone.utc),
406
+ timer_id=event.timerFired.timerId,
407
+ ),
408
+ 'orchestratorStarted': lambda event: OrchestratorStartedEvent(**_base_kwargs(event)),
409
+ 'orchestratorCompleted': lambda event: OrchestratorCompletedEvent(**_base_kwargs(event)),
410
+ 'eventSent': lambda event: EventSentEvent(
411
+ **_base_kwargs(event),
412
+ instance_id=event.eventSent.instanceId,
413
+ name=event.eventSent.name,
414
+ input=_string_value(event.eventSent, 'input'),
415
+ ),
416
+ 'eventRaised': lambda event: EventRaisedEvent(
417
+ **_base_kwargs(event),
418
+ name=event.eventRaised.name,
419
+ input=_string_value(event.eventRaised, 'input'),
420
+ ),
421
+ 'genericEvent': lambda event: GenericEvent(
422
+ **_base_kwargs(event),
423
+ data=_string_value(event.genericEvent, 'data'),
424
+ ),
425
+ 'historyState': lambda event: HistoryStateEvent(
426
+ **_base_kwargs(event),
427
+ orchestration_state=_message_to_dict(event.historyState.orchestrationState),
428
+ ),
429
+ 'continueAsNew': lambda event: ContinueAsNewEvent(
430
+ **_base_kwargs(event),
431
+ input=_string_value(event.continueAsNew, 'input'),
432
+ ),
433
+ 'executionSuspended': lambda event: ExecutionSuspendedEvent(
434
+ **_base_kwargs(event),
435
+ input=_string_value(event.executionSuspended, 'input'),
436
+ ),
437
+ 'executionResumed': lambda event: ExecutionResumedEvent(
438
+ **_base_kwargs(event),
439
+ input=_string_value(event.executionResumed, 'input'),
440
+ ),
441
+ 'entityOperationSignaled': lambda event: EntityOperationSignaledEvent(
442
+ **_base_kwargs(event),
443
+ request_id=event.entityOperationSignaled.requestId,
444
+ operation=event.entityOperationSignaled.operation,
445
+ scheduled_time=_timestamp_value(event.entityOperationSignaled, 'scheduledTime'),
446
+ input=_string_value(event.entityOperationSignaled, 'input'),
447
+ target_instance_id=_string_value(event.entityOperationSignaled, 'targetInstanceId'),
448
+ ),
449
+ 'entityOperationCalled': lambda event: EntityOperationCalledEvent(
450
+ **_base_kwargs(event),
451
+ request_id=event.entityOperationCalled.requestId,
452
+ operation=event.entityOperationCalled.operation,
453
+ scheduled_time=_timestamp_value(event.entityOperationCalled, 'scheduledTime'),
454
+ input=_string_value(event.entityOperationCalled, 'input'),
455
+ parent_instance_id=_string_value(event.entityOperationCalled, 'parentInstanceId'),
456
+ parent_execution_id=_string_value(event.entityOperationCalled, 'parentExecutionId'),
457
+ target_instance_id=_string_value(event.entityOperationCalled, 'targetInstanceId'),
458
+ ),
459
+ 'entityOperationCompleted': lambda event: EntityOperationCompletedEvent(
460
+ **_base_kwargs(event),
461
+ request_id=event.entityOperationCompleted.requestId,
462
+ output=_string_value(event.entityOperationCompleted, 'output'),
463
+ ),
464
+ 'entityOperationFailed': lambda event: EntityOperationFailedEvent(
465
+ **_base_kwargs(event),
466
+ request_id=event.entityOperationFailed.requestId,
467
+ failure_details=_failure_details(event.entityOperationFailed, 'failureDetails'),
468
+ ),
469
+ 'entityLockRequested': lambda event: EntityLockRequestedEvent(
470
+ **_base_kwargs(event),
471
+ critical_section_id=event.entityLockRequested.criticalSectionId,
472
+ lock_set=list(event.entityLockRequested.lockSet),
473
+ position=event.entityLockRequested.position,
474
+ parent_instance_id=_string_value(event.entityLockRequested, 'parentInstanceId'),
475
+ ),
476
+ 'entityLockGranted': lambda event: EntityLockGrantedEvent(
477
+ **_base_kwargs(event),
478
+ critical_section_id=event.entityLockGranted.criticalSectionId,
479
+ ),
480
+ 'entityUnlockSent': lambda event: EntityUnlockSentEvent(
481
+ **_base_kwargs(event),
482
+ critical_section_id=event.entityUnlockSent.criticalSectionId,
483
+ parent_instance_id=_string_value(event.entityUnlockSent, 'parentInstanceId'),
484
+ target_instance_id=_string_value(event.entityUnlockSent, 'targetInstanceId'),
485
+ ),
486
+ 'executionRewound': lambda event: ExecutionRewoundEvent(
487
+ **_base_kwargs(event),
488
+ reason=_string_value(event.executionRewound, 'reason'),
489
+ parent_execution_id=_string_value(event.executionRewound, 'parentExecutionId'),
490
+ instance_id=_string_value(event.executionRewound, 'instanceId'),
491
+ parent_trace_context=_trace_context(event.executionRewound, 'parentTraceContext'),
492
+ name=_string_value(event.executionRewound, 'name'),
493
+ version=_string_value(event.executionRewound, 'version'),
494
+ input=_string_value(event.executionRewound, 'input'),
495
+ parent_instance=_parent_instance(event.executionRewound, 'parentInstance'),
496
+ tags=dict(event.executionRewound.tags) if event.executionRewound.tags else None,
497
+ ),
498
+ }
499
+
500
+
501
+ __all__ = [
502
+ 'ContinueAsNewEvent',
503
+ 'EntityLockGrantedEvent',
504
+ 'EntityLockRequestedEvent',
505
+ 'EntityOperationCalledEvent',
506
+ 'EntityOperationCompletedEvent',
507
+ 'EntityOperationFailedEvent',
508
+ 'EntityOperationSignaledEvent',
509
+ 'EntityUnlockSentEvent',
510
+ 'EventRaisedEvent',
511
+ 'EventSentEvent',
512
+ 'ExecutionCompletedEvent',
513
+ 'ExecutionResumedEvent',
514
+ 'ExecutionRewoundEvent',
515
+ 'ExecutionStartedEvent',
516
+ 'ExecutionSuspendedEvent',
517
+ 'ExecutionTerminatedEvent',
518
+ 'GenericEvent',
519
+ 'HistoryEvent',
520
+ 'HistoryStateEvent',
521
+ 'OrchestrationInstance',
522
+ 'OrchestratorCompletedEvent',
523
+ 'OrchestratorStartedEvent',
524
+ 'ParentInstanceInfo',
525
+ 'SubOrchestrationInstanceCompletedEvent',
526
+ 'SubOrchestrationInstanceCreatedEvent',
527
+ 'SubOrchestrationInstanceFailedEvent',
528
+ 'TaskCompletedEvent',
529
+ 'TaskFailedEvent',
530
+ 'TaskScheduledEvent',
531
+ 'TimerCreatedEvent',
532
+ 'TimerFiredEvent',
533
+ 'TraceContext',
534
+ 'to_dict',
535
+ ]
@@ -0,0 +1,68 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import AsyncIterable, Iterable, Optional
7
+
8
+ import durabletask.history as history
9
+ import durabletask.internal.orchestrator_service_pb2 as pb
10
+ from durabletask.payload import helpers as payload_helpers
11
+ from durabletask.payload.store import PayloadStore
12
+
13
+
14
+ def collect_history_events(
15
+ chunks: Iterable[pb.HistoryChunk],
16
+ payload_store: Optional[PayloadStore] = None,
17
+ ) -> list[history.HistoryEvent]:
18
+ events: list[history.HistoryEvent] = []
19
+ for chunk in chunks:
20
+ events.extend(_clone_and_convert_events(chunk.events, payload_store))
21
+ return events
22
+
23
+
24
+ async def collect_history_events_async(
25
+ chunks: AsyncIterable[pb.HistoryChunk],
26
+ payload_store: Optional[PayloadStore] = None,
27
+ ) -> list[history.HistoryEvent]:
28
+ events: list[history.HistoryEvent] = []
29
+ async for chunk in chunks:
30
+ events.extend(await _clone_and_convert_events_async(chunk.events, payload_store))
31
+ return events
32
+
33
+
34
+ def history_event_to_dict(event: history.HistoryEvent) -> dict:
35
+ return history.to_dict(event)
36
+
37
+
38
+ def _clone_and_convert_events(
39
+ source_events: Iterable[pb.HistoryEvent],
40
+ payload_store: Optional[PayloadStore],
41
+ ) -> list[history.HistoryEvent]:
42
+ events: list[history.HistoryEvent] = []
43
+ for source_event in source_events:
44
+ event = source_event
45
+ if payload_store is not None:
46
+ # deexternalize_payloads mutates messages in-place, so clone to avoid
47
+ # mutating protobuf instances owned by gRPC/deserializer internals.
48
+ event = pb.HistoryEvent()
49
+ event.CopyFrom(source_event)
50
+ payload_helpers.deexternalize_payloads(event, payload_store)
51
+ events.append(history._from_protobuf(event))
52
+ return events
53
+
54
+
55
+ async def _clone_and_convert_events_async(
56
+ source_events: Iterable[pb.HistoryEvent],
57
+ payload_store: Optional[PayloadStore],
58
+ ) -> list[history.HistoryEvent]:
59
+ events: list[history.HistoryEvent] = []
60
+ for source_event in source_events:
61
+ event = source_event
62
+ if payload_store is not None:
63
+ # Async deexternalization mutates messages in-place, so clone first.
64
+ event = pb.HistoryEvent()
65
+ event.CopyFrom(source_event)
66
+ await payload_helpers.deexternalize_payloads_async(event, payload_store)
67
+ events.append(history._from_protobuf(event))
68
+ return events
@@ -9,6 +9,7 @@ from typing import Any, Optional, Sequence, Union
9
9
 
10
10
  import grpc
11
11
  import grpc.aio
12
+ from durabletask.grpc_options import GrpcChannelOptions
12
13
 
13
14
  ClientInterceptor = Union[
14
15
  grpc.UnaryUnaryClientInterceptor,
@@ -39,7 +40,8 @@ def get_default_host_address() -> str:
39
40
  def get_grpc_channel(
40
41
  host_address: Optional[str],
41
42
  secure_channel: bool = False,
42
- interceptors: Optional[Sequence[ClientInterceptor]] = None) -> grpc.Channel:
43
+ interceptors: Optional[Sequence[ClientInterceptor]] = None,
44
+ channel_options: Optional[GrpcChannelOptions] = None) -> grpc.Channel:
43
45
 
44
46
  if host_address is None:
45
47
  host_address = get_default_host_address()
@@ -59,10 +61,21 @@ def get_grpc_channel(
59
61
  break
60
62
 
61
63
  # Create the base channel
64
+ options = channel_options.to_grpc_options() if channel_options is not None else None
62
65
  if secure_channel:
63
- channel = grpc.secure_channel(host_address, grpc.ssl_channel_credentials())
66
+ if options is None:
67
+ channel = grpc.secure_channel(host_address, grpc.ssl_channel_credentials())
68
+ else:
69
+ channel = grpc.secure_channel(
70
+ host_address,
71
+ grpc.ssl_channel_credentials(),
72
+ options=options,
73
+ )
64
74
  else:
65
- channel = grpc.insecure_channel(host_address)
75
+ if options is None:
76
+ channel = grpc.insecure_channel(host_address)
77
+ else:
78
+ channel = grpc.insecure_channel(host_address, options=options)
66
79
 
67
80
  # Apply interceptors ONLY if they exist
68
81
  if interceptors:
@@ -73,7 +86,8 @@ def get_grpc_channel(
73
86
  def get_async_grpc_channel(
74
87
  host_address: Optional[str],
75
88
  secure_channel: bool = False,
76
- interceptors: Optional[Sequence[AsyncClientInterceptor]] = None) -> grpc.aio.Channel:
89
+ interceptors: Optional[Sequence[AsyncClientInterceptor]] = None,
90
+ channel_options: Optional[GrpcChannelOptions] = None) -> grpc.aio.Channel:
77
91
 
78
92
  if host_address is None:
79
93
  host_address = get_default_host_address()
@@ -90,14 +104,34 @@ def get_async_grpc_channel(
90
104
  host_address = host_address[len(protocol):]
91
105
  break
92
106
 
107
+ options = channel_options.to_grpc_options() if channel_options is not None else None
108
+
93
109
  if secure_channel:
94
- channel = grpc.aio.secure_channel(
95
- host_address, grpc.ssl_channel_credentials(),
96
- interceptors=interceptors)
110
+ if options is None:
111
+ channel = grpc.aio.secure_channel(
112
+ host_address,
113
+ grpc.ssl_channel_credentials(),
114
+ interceptors=interceptors,
115
+ )
116
+ else:
117
+ channel = grpc.aio.secure_channel(
118
+ host_address,
119
+ grpc.ssl_channel_credentials(),
120
+ interceptors=interceptors,
121
+ options=options,
122
+ )
97
123
  else:
98
- channel = grpc.aio.insecure_channel(
99
- host_address,
100
- interceptors=interceptors)
124
+ if options is None:
125
+ channel = grpc.aio.insecure_channel(
126
+ host_address,
127
+ interceptors=interceptors,
128
+ )
129
+ else:
130
+ channel = grpc.aio.insecure_channel(
131
+ host_address,
132
+ interceptors=interceptors,
133
+ options=options,
134
+ )
101
135
 
102
136
  return channel
103
137
 
@@ -10,6 +10,7 @@ unit testing and integration testing scenarios where a sidecar process
10
10
  or external storage is not desired.
11
11
  """
12
12
 
13
+ import bisect
13
14
  import logging
14
15
  import threading
15
16
  import time
@@ -41,6 +42,7 @@ class OrchestrationInstance:
41
42
  custom_status: Optional[str] = None
42
43
  created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
43
44
  last_updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
45
+ completed_at: Optional[datetime] = None
44
46
  failure_details: Optional[pb.TaskFailureDetails] = None
45
47
  history: list[pb.HistoryEvent] = field(default_factory=list)
46
48
  pending_events: list[pb.HistoryEvent] = field(default_factory=list)
@@ -97,6 +99,10 @@ class StateWaiter:
97
99
  result: Optional[OrchestrationInstance] = None
98
100
 
99
101
 
102
+ _DEFAULT_PAGE_SIZE = 100
103
+ _TOKEN_SEP = '|'
104
+
105
+
100
106
  class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
101
107
  """
102
108
  In-memory backend for durable orchestrations suitable for testing.
@@ -238,6 +244,7 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
238
244
  input=request.input.value if request.input else None,
239
245
  created_at=now,
240
246
  last_updated_at=now,
247
+ completed_at=None,
241
248
  completion_token=self._next_completion_token,
242
249
  tags=dict(request.tags) if request.tags else None,
243
250
  )
@@ -453,6 +460,48 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
453
460
  f"Restarted instance '{request.instanceId}' as '{new_instance_id}'")
454
461
  return pb.RestartInstanceResponse(instanceId=new_instance_id)
455
462
 
463
+ def ListInstanceIds(self, request: pb.ListInstanceIdsRequest, context):
464
+ """Lists terminal orchestration instance IDs with completion-time pagination."""
465
+ with self._lock:
466
+ matching = []
467
+ for instance in self._instances.values():
468
+ if not self._is_terminal_status(instance.status):
469
+ continue
470
+ if request.runtimeStatus and instance.status not in request.runtimeStatus:
471
+ continue
472
+ if instance.completed_at is None:
473
+ continue
474
+ if request.HasField("completedTimeFrom") and instance.completed_at < request.completedTimeFrom.ToDatetime(timezone.utc):
475
+ continue
476
+ if request.HasField("completedTimeTo") and instance.completed_at >= request.completedTimeTo.ToDatetime(timezone.utc):
477
+ continue
478
+ matching.append(instance)
479
+
480
+ matching.sort(key=lambda i: (i.completed_at, i.instance_id))
481
+ sort_keys = [(i.completed_at, i.instance_id) for i in matching]
482
+
483
+ start_index = 0
484
+ if request.HasField("lastInstanceKey") and request.lastInstanceKey.value:
485
+ token = request.lastInstanceKey.value
486
+ sep_idx = token.index(_TOKEN_SEP)
487
+ token_ts = datetime.fromisoformat(token[:sep_idx]).replace(tzinfo=timezone.utc)
488
+ token_id = token[sep_idx + 1:]
489
+ # bisect_right positions us just after the cursor entry
490
+ start_index = bisect.bisect_right(sort_keys, (token_ts, token_id))
491
+
492
+ page_size = request.pageSize if request.pageSize > 0 else _DEFAULT_PAGE_SIZE
493
+ page = matching[start_index:start_index + page_size]
494
+ next_token = None
495
+ if start_index + page_size < len(matching) and page:
496
+ last = page[-1]
497
+ encoded = f"{last.completed_at.isoformat()}{_TOKEN_SEP}{last.instance_id}"
498
+ next_token = wrappers_pb2.StringValue(value=encoded)
499
+
500
+ return pb.ListInstanceIdsResponse(
501
+ instanceIds=[instance.instance_id for instance in page],
502
+ lastInstanceKey=next_token,
503
+ )
504
+
456
505
  @staticmethod
457
506
  def _parse_work_item_filters(request: pb.GetWorkItemsRequest):
458
507
  """Extract filters from the request.
@@ -1084,8 +1133,18 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
1084
1133
  )
1085
1134
 
1086
1135
  def StreamInstanceHistory(self, request: pb.StreamInstanceHistoryRequest, context):
1087
- """Streams instance history (not implemented)."""
1088
- context.abort(grpc.StatusCode.UNIMPLEMENTED, "StreamInstanceHistory not implemented")
1136
+ """Streams orchestration history for an instance."""
1137
+ with self._lock:
1138
+ instance = self._instances.get(request.instanceId)
1139
+ if instance is None:
1140
+ context.abort(grpc.StatusCode.NOT_FOUND,
1141
+ f"Orchestration instance '{request.instanceId}' not found")
1142
+ return
1143
+ history = [self._clone_history_event(event) for event in instance.history]
1144
+
1145
+ chunk_size = 100
1146
+ for offset in range(0, len(history), chunk_size):
1147
+ yield pb.HistoryChunk(events=history[offset:offset + chunk_size])
1089
1148
 
1090
1149
  def CreateTaskHub(self, request: pb.CreateTaskHubRequest, context):
1091
1150
  """Creates task hub resources (no-op for in-memory)."""
@@ -1178,6 +1237,7 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
1178
1237
  input=encoded_input,
1179
1238
  created_at=now,
1180
1239
  last_updated_at=now,
1240
+ completed_at=None,
1181
1241
  completion_token=self._next_completion_token,
1182
1242
  )
1183
1243
  self._next_completion_token += 1
@@ -1239,6 +1299,7 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
1239
1299
  orchestrationStatus=instance.status,
1240
1300
  createdTimestamp=created_ts,
1241
1301
  lastUpdatedTimestamp=updated_ts,
1302
+ completedTimestamp=helpers.new_timestamp(instance.completed_at) if instance.completed_at else None,
1242
1303
  input=wrappers_pb2.StringValue(value=instance.input) if include_payloads and instance.input else None,
1243
1304
  output=wrappers_pb2.StringValue(value=instance.output) if include_payloads and instance.output else None,
1244
1305
  customStatus=wrappers_pb2.StringValue(value=instance.custom_status) if instance.custom_status else None,
@@ -1319,6 +1380,7 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
1319
1380
  instance.status = status
1320
1381
  instance.output = complete_action.result.value if complete_action.result else None
1321
1382
  instance.failure_details = complete_action.failureDetails if complete_action.failureDetails else None
1383
+ instance.completed_at = datetime.now(timezone.utc) if self._is_terminal_status(status) else None
1322
1384
 
1323
1385
  if status == pb.ORCHESTRATION_STATUS_CONTINUED_AS_NEW:
1324
1386
  # Handle continue-as-new
@@ -1336,6 +1398,7 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
1336
1398
  instance.output = None
1337
1399
  instance.failure_details = None
1338
1400
  instance.status = pb.ORCHESTRATION_STATUS_PENDING
1401
+ instance.completed_at = None
1339
1402
 
1340
1403
  # Save any events that arrived during the in-flight dispatch so
1341
1404
  # they can be appended AFTER the new execution started events.
@@ -1357,6 +1420,12 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
1357
1420
 
1358
1421
  self._enqueue_orchestration(instance.instance_id)
1359
1422
 
1423
+ @staticmethod
1424
+ def _clone_history_event(event: pb.HistoryEvent) -> pb.HistoryEvent:
1425
+ cloned_event = pb.HistoryEvent()
1426
+ cloned_event.CopyFrom(event)
1427
+ return cloned_event
1428
+
1360
1429
  def _process_schedule_task_action(self, instance: OrchestrationInstance,
1361
1430
  action: pb.OrchestratorAction):
1362
1431
  """Processes a schedule task action."""
durabletask/worker.py CHANGED
@@ -21,6 +21,7 @@ from packaging.version import InvalidVersion, parse
21
21
  import grpc
22
22
  from google.protobuf import empty_pb2
23
23
 
24
+ from durabletask.grpc_options import GrpcChannelOptions
24
25
  from durabletask.entities.entity_operation_failed_exception import EntityOperationFailedException
25
26
  from durabletask.internal import helpers
26
27
  from durabletask.internal.entity_state_shim import StateShim
@@ -361,8 +362,13 @@ class TaskHubGrpcWorker:
361
362
  Defaults to None.
362
363
  secure_channel (bool, optional): Whether to use a secure gRPC channel (TLS).
363
364
  Defaults to False.
365
+ channel (Optional[grpc.Channel], optional): Pre-configured gRPC channel to use.
366
+ If set, host address, secure_channel, interceptors, and channel_options
367
+ are ignored when creating connections.
364
368
  interceptors (Optional[Sequence[shared.ClientInterceptor]], optional): Custom gRPC
365
369
  interceptors to apply to the channel. Defaults to None.
370
+ channel_options (Optional[GrpcChannelOptions], optional): Extra low-level gRPC
371
+ channel configuration including retry/service config options.
366
372
  concurrency_options (Optional[ConcurrencyOptions], optional): Configuration for
367
373
  controlling worker concurrency limits. If None, default settings are used.
368
374
 
@@ -426,8 +432,10 @@ class TaskHubGrpcWorker:
426
432
  metadata: Optional[list[tuple[str, str]]] = None,
427
433
  log_handler: Optional[logging.Handler] = None,
428
434
  log_formatter: Optional[logging.Formatter] = None,
435
+ channel: Optional[grpc.Channel] = None,
429
436
  secure_channel: bool = False,
430
437
  interceptors: Optional[Sequence[shared.ClientInterceptor]] = None,
438
+ channel_options: Optional[GrpcChannelOptions] = None,
431
439
  concurrency_options: Optional[ConcurrencyOptions] = None,
432
440
  maximum_timer_interval: Optional[timedelta] = DEFAULT_MAXIMUM_TIMER_INTERVAL,
433
441
  payload_store: Optional[PayloadStore] = None,
@@ -439,8 +447,10 @@ class TaskHubGrpcWorker:
439
447
  self._logger = shared.get_logger("worker", log_handler, log_formatter)
440
448
  self._shutdown = Event()
441
449
  self._is_running = False
450
+ self._channel = channel
442
451
  self._secure_channel = secure_channel
443
452
  self._payload_store = payload_store
453
+ self._channel_options = channel_options
444
454
 
445
455
  # Use provided concurrency options or create default ones
446
456
  self._concurrency_options = (
@@ -590,7 +600,7 @@ class TaskHubGrpcWorker:
590
600
 
591
601
  def create_fresh_connection():
592
602
  nonlocal current_channel, current_stub, conn_retry_count
593
- if current_channel:
603
+ if current_channel and self._channel is None:
594
604
  try:
595
605
  current_channel.close()
596
606
  except Exception:
@@ -598,16 +608,22 @@ class TaskHubGrpcWorker:
598
608
  current_channel = None
599
609
  current_stub = None
600
610
  try:
601
- current_channel = shared.get_grpc_channel(
602
- self._host_address, self._secure_channel, self._interceptors
603
- )
611
+ if self._channel is not None:
612
+ current_channel = self._channel
613
+ else:
614
+ current_channel = shared.get_grpc_channel(
615
+ self._host_address,
616
+ self._secure_channel,
617
+ self._interceptors,
618
+ channel_options=self._channel_options,
619
+ )
604
620
  current_stub = stubs.TaskHubSidecarServiceStub(current_channel)
605
621
  current_stub.Hello(empty_pb2.Empty())
606
622
  conn_retry_count = 0
607
623
  self._logger.info(f"Created fresh connection to {self._host_address}")
608
624
  except Exception as e:
609
625
  self._logger.warning(f"Failed to create connection: {e}")
610
- current_channel = None
626
+ current_channel = self._channel if self._channel is not None else None
611
627
  current_stub = None
612
628
  raise
613
629
 
@@ -632,12 +648,12 @@ class TaskHubGrpcWorker:
632
648
  current_reader_thread = None
633
649
 
634
650
  # Close the channel
635
- if current_channel:
651
+ if current_channel and self._channel is None:
636
652
  try:
637
653
  current_channel.close()
638
654
  except Exception:
639
655
  pass
640
- current_channel = None
656
+ current_channel = self._channel if self._channel is not None else None
641
657
  current_stub = None
642
658
 
643
659
  def should_invalidate_connection(rpc_error):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durabletask
3
- Version: 1.4.0.dev30
3
+ Version: 1.4.0.dev32
4
4
  Summary: A Durable Task Client SDK for Python
5
5
  License: MIT License
6
6
 
@@ -1,8 +1,10 @@
1
- durabletask/__init__.py,sha256=OdfKCNlS_NJawRfLWsFNj7YIHeGSQkh2VH3OzG0Oric,644
2
- durabletask/client.py,sha256=NbIdDTQR7XI_ZiqsGMP0q5vmbe5-ShyGUQre1qgB-Ag,34107
1
+ durabletask/__init__.py,sha256=GhLZFOSOVlP7Jomi6f-nNYiUxDf5a2wNylkBAcshEBA,780
2
+ durabletask/client.py,sha256=PUOTYi0Kw7JoREbz-W8Gw5tjN_VCZxBLMqLq0T97unQ,40086
3
+ durabletask/grpc_options.py,sha256=k1xXnKzK5b_86wibuh7Ype396A-Gs6P4nbHBaUSpE9U,4174
4
+ durabletask/history.py,sha256=VhrWb5eFr9TZSaazpotdMzKt2quwLuJVRk9j39WbTPs,18680
3
5
  durabletask/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
6
  durabletask/task.py,sha256=QS7RPUaWhb4Xq5hi7nnueooJqQSDO6ipEoiAiSVkAIs,24009
5
- durabletask/worker.py,sha256=B8aYoOguNuYccmmtGF9GFr3YEEdfTGJqcOoox0kByQM,133661
7
+ durabletask/worker.py,sha256=ZNx_85fGjgUkjJK_dEI2AZRB4qM3XMV6hgPlc3PPdFk,134691
6
8
  durabletask/entities/__init__.py,sha256=DbNd5riqWZaj3tG6gN82O8Q6wTmFpe6QaH0pQgDSPHs,721
7
9
  durabletask/entities/durable_entity.py,sha256=LQPWnUlRsHiFVRoTdpeSK--eXtjf2UGbVQwEEKf7QwI,3318
8
10
  durabletask/entities/entity_context.py,sha256=U-B3i9QP34N-6Fikx_tMp8zo0YLdmwhwpdxwjHd7z-M,5346
@@ -19,21 +21,22 @@ durabletask/internal/entity_state_shim.py,sha256=Z_LUy7wYKELg3vBvti6hKRthRpfXqwL
19
21
  durabletask/internal/exceptions.py,sha256=G1qnH5KReJk04_eewMIr2TTXvdeWDriq-SiAwSahMMs,298
20
22
  durabletask/internal/grpc_interceptor.py,sha256=ZWNwIBCy-TzVr4KjuPqzgKsWDHvGPUbwWlw4NF0T4A0,5820
21
23
  durabletask/internal/helpers.py,sha256=FVDJypvUmOKKcosRoh8N7rs-UL-ai15KN_IBC-XkU50,12772
24
+ durabletask/internal/history_helpers.py,sha256=myZQoN--CNaJqEr7jGyLJRBd0QvpVtZbxILArASgYow,2463
22
25
  durabletask/internal/json_encode_output_exception.py,sha256=y13uVALM5pGQKMHEKy1npKHJgKGAjhAKoUcU-wZGYSg,426
23
26
  durabletask/internal/orchestration_entity_context.py,sha256=zggVZ1L1YMm2eSzQy7m6jlag9XkP1DGgZVrZzJmr5yE,5410
24
27
  durabletask/internal/orchestrator_service_pb2.py,sha256=K5iG8oguyGhTGt0YmkDsK9ZQz5tt7fFxgaFJKRru0w4,53149
25
28
  durabletask/internal/orchestrator_service_pb2.pyi,sha256=TFa752MEoXPdK1fP2fDIIaqpGQJ0sbyXRoNg51hlegE,78590
26
29
  durabletask/internal/orchestrator_service_pb2_grpc.py,sha256=Rk0QpGOc8fTeIGULB6xww9CtX6GiLGlZ3JMoZyW9Wuo,60845
27
30
  durabletask/internal/proto_task_hub_sidecar_service_stub.py,sha256=pFlOCj1LhuhU5OKU4CZIuQCCsf9jilC9GXDqlEQYQ0U,1503
28
- durabletask/internal/shared.py,sha256=3GMYHWCFX9esgzlRwNr3J2AOeufAIypTZJQ9n2xI6g4,5589
31
+ durabletask/internal/shared.py,sha256=00NrfM7dmdBQznLt0S9rfWoYWBTOXHWnmg3nemhyWco,6819
29
32
  durabletask/internal/tracing.py,sha256=3cSanl0UcmGekItHcQ6tSl7wqSkS7_wOKzsDvJyS_ag,28821
30
33
  durabletask/payload/__init__.py,sha256=1h68pQvgk8JUp5LBJuBq9W4GUPYkdlhqmCCQEg6YBYI,784
31
34
  durabletask/payload/helpers.py,sha256=RYG5MEVAqHjm4zfFHs3Td91FVQHUoCcb5hbEJ4sYj5s,12350
32
35
  durabletask/payload/store.py,sha256=3qJMvKxRUkr6ScWUzxpKAVgzuhFLywRW8a2_5OOmNk4,3000
33
36
  durabletask/testing/__init__.py,sha256=rXbcSFtzuaRAbDNX-HmdgbxLTegvKJ1FRjZfSOIAMgA,323
34
- durabletask/testing/in_memory_backend.py,sha256=ELxyCDRDNOabygIvw9ZeRUpP3MzeM5Hdbu6QlRwKdno,79249
35
- durabletask-1.4.0.dev30.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
36
- durabletask-1.4.0.dev30.dist-info/METADATA,sha256=KW-KJvIH_gr4qSOVjEQqq7Il1uNRFMBuV37FMfTF7g0,4404
37
- durabletask-1.4.0.dev30.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
38
- durabletask-1.4.0.dev30.dist-info/top_level.txt,sha256=EBVyuKWnjOwq8bJI1Uvb9U3c4fzQxACWj9p83he6fik,12
39
- durabletask-1.4.0.dev30.dist-info/RECORD,,
37
+ durabletask/testing/in_memory_backend.py,sha256=mF0zCA3GMLUd0vqa7CvkgBhYXH4VCsqHReIWuw72wvA,82530
38
+ durabletask-1.4.0.dev32.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
39
+ durabletask-1.4.0.dev32.dist-info/METADATA,sha256=6jrrQkmgSTCTRRdoeoFUUKpP0OUVNjB4inn5V4Ua9-M,4404
40
+ durabletask-1.4.0.dev32.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
41
+ durabletask-1.4.0.dev32.dist-info/top_level.txt,sha256=EBVyuKWnjOwq8bJI1Uvb9U3c4fzQxACWj9p83he6fik,12
42
+ durabletask-1.4.0.dev32.dist-info/RECORD,,