durabletask 1.4.0.dev30__tar.gz → 1.4.0.dev31__tar.gz

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.
Files changed (46) hide show
  1. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/PKG-INFO +1 -1
  2. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/client.py +86 -1
  3. durabletask-1.4.0.dev31/durabletask/history.py +535 -0
  4. durabletask-1.4.0.dev31/durabletask/internal/history_helpers.py +68 -0
  5. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/testing/in_memory_backend.py +71 -2
  6. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask.egg-info/PKG-INFO +1 -1
  7. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask.egg-info/SOURCES.txt +2 -0
  8. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/pyproject.toml +1 -1
  9. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/LICENSE +0 -0
  10. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/README.md +0 -0
  11. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/__init__.py +0 -0
  12. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/entities/__init__.py +0 -0
  13. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/entities/durable_entity.py +0 -0
  14. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/entities/entity_context.py +0 -0
  15. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/entities/entity_instance_id.py +0 -0
  16. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/entities/entity_lock.py +0 -0
  17. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/entities/entity_metadata.py +0 -0
  18. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/entities/entity_operation_failed_exception.py +0 -0
  19. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/extensions/__init__.py +0 -0
  20. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/extensions/azure_blob_payloads/__init__.py +0 -0
  21. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/extensions/azure_blob_payloads/blob_payload_store.py +0 -0
  22. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/extensions/azure_blob_payloads/options.py +0 -0
  23. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/client_helpers.py +0 -0
  24. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/entity_state_shim.py +0 -0
  25. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/exceptions.py +0 -0
  26. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/grpc_interceptor.py +0 -0
  27. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/helpers.py +0 -0
  28. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/json_encode_output_exception.py +0 -0
  29. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/orchestration_entity_context.py +0 -0
  30. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/orchestrator_service_pb2.py +0 -0
  31. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/orchestrator_service_pb2.pyi +0 -0
  32. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/orchestrator_service_pb2_grpc.py +0 -0
  33. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/proto_task_hub_sidecar_service_stub.py +0 -0
  34. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/shared.py +0 -0
  35. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/internal/tracing.py +0 -0
  36. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/payload/__init__.py +0 -0
  37. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/payload/helpers.py +0 -0
  38. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/payload/store.py +0 -0
  39. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/py.typed +0 -0
  40. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/task.py +0 -0
  41. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/testing/__init__.py +0 -0
  42. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask/worker.py +0 -0
  43. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask.egg-info/dependency_links.txt +0 -0
  44. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask.egg-info/requires.txt +0 -0
  45. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/durabletask.egg-info/top_level.txt +0 -0
  46. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev31}/setup.cfg +0 -0
@@ -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.dev31
4
4
  Summary: A Durable Task Client SDK for Python
5
5
  License: MIT License
6
6
 
@@ -6,14 +6,16 @@ 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
16
17
  import durabletask.internal.helpers as helpers
18
+ import durabletask.internal.history_helpers as history_helpers
17
19
  import durabletask.internal.orchestrator_service_pb2 as pb
18
20
  import durabletask.internal.orchestrator_service_pb2_grpc as stubs
19
21
  import durabletask.internal.shared as shared
@@ -37,6 +39,7 @@ from durabletask.payload.store import PayloadStore
37
39
 
38
40
  TInput = TypeVar('TInput')
39
41
  TOutput = TypeVar('TOutput')
42
+ TItem = TypeVar('TItem')
40
43
 
41
44
 
42
45
  class OrchestrationStatus(Enum):
@@ -99,6 +102,12 @@ class PurgeInstancesResult:
99
102
  is_complete: bool
100
103
 
101
104
 
105
+ @dataclass
106
+ class Page(Generic[TItem]):
107
+ items: List[TItem]
108
+ continuation_token: Optional[str]
109
+
110
+
102
111
  @dataclass
103
112
  class CleanEntityStorageResult:
104
113
  empty_entities_removed: int
@@ -218,6 +227,44 @@ class TaskHubGrpcClient:
218
227
  payload_helpers.deexternalize_payloads(res, self._payload_store)
219
228
  return new_orchestration_state(req.instanceId, res)
220
229
 
230
+ def get_orchestration_history(self,
231
+ instance_id: str, *,
232
+ execution_id: Optional[str] = None,
233
+ for_work_item_processing: bool = False) -> List[history.HistoryEvent]:
234
+ req = pb.StreamInstanceHistoryRequest(
235
+ instanceId=instance_id,
236
+ executionId=helpers.get_string_value(execution_id),
237
+ forWorkItemProcessing=for_work_item_processing,
238
+ )
239
+ self._logger.info(f"Retrieving history for instance '{instance_id}'.")
240
+ stream = self._stub.StreamInstanceHistory(req)
241
+ return history_helpers.collect_history_events(stream, self._payload_store)
242
+
243
+ def list_instance_ids(self,
244
+ runtime_status: Optional[List[OrchestrationStatus]] = None,
245
+ completed_time_from: Optional[datetime] = None,
246
+ completed_time_to: Optional[datetime] = None,
247
+ page_size: Optional[int] = None,
248
+ continuation_token: Optional[str] = None) -> Page[str]:
249
+ req = pb.ListInstanceIdsRequest(
250
+ runtimeStatus=[status.value for status in runtime_status] if runtime_status else [],
251
+ completedTimeFrom=helpers.new_timestamp(completed_time_from) if completed_time_from else None,
252
+ completedTimeTo=helpers.new_timestamp(completed_time_to) if completed_time_to else None,
253
+ pageSize=page_size or 0,
254
+ lastInstanceKey=helpers.get_string_value(continuation_token),
255
+ )
256
+ self._logger.info(
257
+ "Listing terminal instance IDs with filters: "
258
+ f"runtime_status={[str(status) for status in runtime_status] if runtime_status else None}, "
259
+ f"completed_time_from={completed_time_from}, "
260
+ f"completed_time_to={completed_time_to}, "
261
+ f"page_size={page_size}, "
262
+ f"continuation_token={continuation_token}"
263
+ )
264
+ resp: pb.ListInstanceIdsResponse = self._stub.ListInstanceIds(req)
265
+ next_token = resp.lastInstanceKey.value if resp.HasField("lastInstanceKey") else None
266
+ return Page(items=list(resp.instanceIds), continuation_token=next_token)
267
+
221
268
  def get_all_orchestration_states(self,
222
269
  orchestration_query: Optional[OrchestrationQuery] = None
223
270
  ) -> List[OrchestrationState]:
@@ -502,6 +549,44 @@ class AsyncTaskHubGrpcClient:
502
549
  await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
503
550
  return new_orchestration_state(req.instanceId, res)
504
551
 
552
+ async def get_orchestration_history(self,
553
+ instance_id: str, *,
554
+ execution_id: Optional[str] = None,
555
+ for_work_item_processing: bool = False) -> List[history.HistoryEvent]:
556
+ req = pb.StreamInstanceHistoryRequest(
557
+ instanceId=instance_id,
558
+ executionId=helpers.get_string_value(execution_id),
559
+ forWorkItemProcessing=for_work_item_processing,
560
+ )
561
+ self._logger.info(f"Retrieving history for instance '{instance_id}'.")
562
+ stream = self._stub.StreamInstanceHistory(req)
563
+ return await history_helpers.collect_history_events_async(stream, self._payload_store)
564
+
565
+ async def list_instance_ids(self,
566
+ runtime_status: Optional[List[OrchestrationStatus]] = None,
567
+ completed_time_from: Optional[datetime] = None,
568
+ completed_time_to: Optional[datetime] = None,
569
+ page_size: Optional[int] = None,
570
+ continuation_token: Optional[str] = None) -> Page[str]:
571
+ req = pb.ListInstanceIdsRequest(
572
+ runtimeStatus=[status.value for status in runtime_status] if runtime_status else [],
573
+ completedTimeFrom=helpers.new_timestamp(completed_time_from) if completed_time_from else None,
574
+ completedTimeTo=helpers.new_timestamp(completed_time_to) if completed_time_to else None,
575
+ pageSize=page_size or 0,
576
+ lastInstanceKey=helpers.get_string_value(continuation_token),
577
+ )
578
+ self._logger.info(
579
+ "Listing terminal instance IDs with filters: "
580
+ f"runtime_status={[str(status) for status in runtime_status] if runtime_status else None}, "
581
+ f"completed_time_from={completed_time_from}, "
582
+ f"completed_time_to={completed_time_to}, "
583
+ f"page_size={page_size}, "
584
+ f"continuation_token={continuation_token}"
585
+ )
586
+ resp: pb.ListInstanceIdsResponse = await self._stub.ListInstanceIds(req)
587
+ next_token = resp.lastInstanceKey.value if resp.HasField("lastInstanceKey") else None
588
+ return Page(items=list(resp.instanceIds), continuation_token=next_token)
589
+
505
590
  async def get_all_orchestration_states(self,
506
591
  orchestration_query: Optional[OrchestrationQuery] = None
507
592
  ) -> List[OrchestrationState]:
@@ -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
@@ -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."""
@@ -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.dev31
4
4
  Summary: A Durable Task Client SDK for Python
5
5
  License: MIT License
6
6
 
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  durabletask/__init__.py
5
5
  durabletask/client.py
6
+ durabletask/history.py
6
7
  durabletask/py.typed
7
8
  durabletask/task.py
8
9
  durabletask/worker.py
@@ -27,6 +28,7 @@ durabletask/internal/entity_state_shim.py
27
28
  durabletask/internal/exceptions.py
28
29
  durabletask/internal/grpc_interceptor.py
29
30
  durabletask/internal/helpers.py
31
+ durabletask/internal/history_helpers.py
30
32
  durabletask/internal/json_encode_output_exception.py
31
33
  durabletask/internal/orchestration_entity_context.py
32
34
  durabletask/internal/orchestrator_service_pb2.py
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
9
9
 
10
10
  [project]
11
11
  name = "durabletask"
12
- version = "1.4.0.dev30"
12
+ version = "1.4.0.dev31"
13
13
  description = "A Durable Task Client SDK for Python"
14
14
  keywords = [
15
15
  "durable",