durabletask 1.3.0.dev25__tar.gz → 1.3.0.dev27__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.
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/PKG-INFO +17 -1
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/README.md +14 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/__init__.py +7 -1
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/client.py +67 -4
- durabletask-1.3.0.dev27/durabletask/extensions/__init__.py +4 -0
- durabletask-1.3.0.dev27/durabletask/extensions/azure_blob_payloads/__init__.py +38 -0
- durabletask-1.3.0.dev27/durabletask/extensions/azure_blob_payloads/blob_payload_store.py +225 -0
- durabletask-1.3.0.dev27/durabletask/extensions/azure_blob_payloads/options.py +40 -0
- durabletask-1.3.0.dev27/durabletask/payload/__init__.py +29 -0
- durabletask-1.3.0.dev27/durabletask/payload/helpers.py +349 -0
- durabletask-1.3.0.dev27/durabletask/payload/store.py +91 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/worker.py +35 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask.egg-info/PKG-INFO +17 -1
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask.egg-info/SOURCES.txt +7 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask.egg-info/requires.txt +3 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/pyproject.toml +7 -1
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/LICENSE +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/__init__.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/durable_entity.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/entity_context.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/entity_instance_id.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/entity_lock.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/entity_metadata.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/entity_operation_failed_exception.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/client_helpers.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/entity_state_shim.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/exceptions.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/grpc_interceptor.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/helpers.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/json_encode_output_exception.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/orchestration_entity_context.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/orchestrator_service_pb2.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/orchestrator_service_pb2.pyi +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/orchestrator_service_pb2_grpc.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/proto_task_hub_sidecar_service_stub.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/shared.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/tracing.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/py.typed +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/task.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/testing/__init__.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/testing/in_memory_backend.py +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask.egg-info/dependency_links.txt +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask.egg-info/top_level.txt +0 -0
- {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: durabletask
|
|
3
|
-
Version: 1.3.0.
|
|
3
|
+
Version: 1.3.0.dev27
|
|
4
4
|
Summary: A Durable Task Client SDK for Python
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -40,6 +40,8 @@ Requires-Dist: packaging
|
|
|
40
40
|
Provides-Extra: opentelemetry
|
|
41
41
|
Requires-Dist: opentelemetry-api>=1.0.0; extra == "opentelemetry"
|
|
42
42
|
Requires-Dist: opentelemetry-sdk>=1.0.0; extra == "opentelemetry"
|
|
43
|
+
Provides-Extra: azure-blob-payloads
|
|
44
|
+
Requires-Dist: azure-storage-blob[aio]>=12.0.0; extra == "azure-blob-payloads"
|
|
43
45
|
Dynamic: license-file
|
|
44
46
|
|
|
45
47
|
# Durable Task SDK for Python
|
|
@@ -59,6 +61,20 @@ This repo contains a Python SDK for use with the [Azure Durable Task Scheduler](
|
|
|
59
61
|
- [Development Guide](./docs/development.md)
|
|
60
62
|
- [Contributing Guide](./CONTRIBUTING.md)
|
|
61
63
|
|
|
64
|
+
## Optional Features
|
|
65
|
+
|
|
66
|
+
### Large Payload Externalization
|
|
67
|
+
|
|
68
|
+
Install the `azure-blob-payloads` extra to automatically offload
|
|
69
|
+
oversized orchestration payloads to Azure Blob Storage:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install durabletask[azure-blob-payloads]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
See the [feature documentation](./docs/features.md#large-payload-externalization)
|
|
76
|
+
and the [example](./examples/large_payload/) for usage details.
|
|
77
|
+
|
|
62
78
|
## Trademarks
|
|
63
79
|
This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
|
|
64
80
|
trademarks or logos is subject to and must follow
|
|
@@ -15,6 +15,20 @@ This repo contains a Python SDK for use with the [Azure Durable Task Scheduler](
|
|
|
15
15
|
- [Development Guide](./docs/development.md)
|
|
16
16
|
- [Contributing Guide](./CONTRIBUTING.md)
|
|
17
17
|
|
|
18
|
+
## Optional Features
|
|
19
|
+
|
|
20
|
+
### Large Payload Externalization
|
|
21
|
+
|
|
22
|
+
Install the `azure-blob-payloads` extra to automatically offload
|
|
23
|
+
oversized orchestration payloads to Azure Blob Storage:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install durabletask[azure-blob-payloads]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
See the [feature documentation](./docs/features.md#large-payload-externalization)
|
|
30
|
+
and the [example](./examples/large_payload/) for usage details.
|
|
31
|
+
|
|
18
32
|
## Trademarks
|
|
19
33
|
This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
|
|
20
34
|
trademarks or logos is subject to and must follow
|
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
"""Durable Task SDK for Python"""
|
|
5
5
|
|
|
6
|
+
from durabletask.payload.store import LargePayloadStorageOptions, PayloadStore
|
|
6
7
|
from durabletask.worker import ConcurrencyOptions, VersioningOptions
|
|
7
8
|
|
|
8
|
-
__all__ = [
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ConcurrencyOptions",
|
|
11
|
+
"LargePayloadStorageOptions",
|
|
12
|
+
"PayloadStore",
|
|
13
|
+
"VersioningOptions",
|
|
14
|
+
]
|
|
9
15
|
|
|
10
16
|
PACKAGE_NAME = "durabletask"
|
|
@@ -32,6 +32,8 @@ from durabletask.internal.client_helpers import (
|
|
|
32
32
|
prepare_async_interceptors,
|
|
33
33
|
prepare_sync_interceptors,
|
|
34
34
|
)
|
|
35
|
+
from durabletask.payload import helpers as payload_helpers
|
|
36
|
+
from durabletask.payload.store import PayloadStore
|
|
35
37
|
|
|
36
38
|
TInput = TypeVar('TInput')
|
|
37
39
|
TOutput = TypeVar('TOutput')
|
|
@@ -152,7 +154,8 @@ class TaskHubGrpcClient:
|
|
|
152
154
|
log_formatter: Optional[logging.Formatter] = None,
|
|
153
155
|
secure_channel: bool = False,
|
|
154
156
|
interceptors: Optional[Sequence[shared.ClientInterceptor]] = None,
|
|
155
|
-
default_version: Optional[str] = None
|
|
157
|
+
default_version: Optional[str] = None,
|
|
158
|
+
payload_store: Optional[PayloadStore] = None):
|
|
156
159
|
|
|
157
160
|
interceptors = prepare_sync_interceptors(metadata, interceptors)
|
|
158
161
|
|
|
@@ -165,6 +168,7 @@ class TaskHubGrpcClient:
|
|
|
165
168
|
self._stub = stubs.TaskHubSidecarServiceStub(channel)
|
|
166
169
|
self._logger = shared.get_logger("client", log_handler, log_formatter)
|
|
167
170
|
self.default_version = default_version
|
|
171
|
+
self._payload_store = payload_store
|
|
168
172
|
|
|
169
173
|
def close(self) -> None:
|
|
170
174
|
"""Closes the underlying gRPC channel."""
|
|
@@ -198,12 +202,20 @@ class TaskHubGrpcClient:
|
|
|
198
202
|
req.parentTraceContext.CopyFrom(parent_trace_ctx)
|
|
199
203
|
|
|
200
204
|
self._logger.info(f"Starting new '{req.name}' instance with ID = '{req.instanceId}'.")
|
|
205
|
+
# Externalize any large payloads in the request
|
|
206
|
+
if self._payload_store is not None:
|
|
207
|
+
payload_helpers.externalize_payloads(
|
|
208
|
+
req, self._payload_store, instance_id=req.instanceId,
|
|
209
|
+
)
|
|
201
210
|
res: pb.CreateInstanceResponse = self._stub.StartInstance(req)
|
|
202
211
|
return res.instanceId
|
|
203
212
|
|
|
204
213
|
def get_orchestration_state(self, instance_id: str, *, fetch_payloads: bool = True) -> Optional[OrchestrationState]:
|
|
205
214
|
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
|
|
206
215
|
res: pb.GetInstanceResponse = self._stub.GetInstance(req)
|
|
216
|
+
# De-externalize any large-payload tokens in the response
|
|
217
|
+
if self._payload_store is not None and res.exists:
|
|
218
|
+
payload_helpers.deexternalize_payloads(res, self._payload_store)
|
|
207
219
|
return new_orchestration_state(req.instanceId, res)
|
|
208
220
|
|
|
209
221
|
def get_all_orchestration_states(self,
|
|
@@ -220,6 +232,8 @@ class TaskHubGrpcClient:
|
|
|
220
232
|
while True:
|
|
221
233
|
req = build_query_instances_req(orchestration_query, _continuation_token)
|
|
222
234
|
resp: pb.QueryInstancesResponse = self._stub.QueryInstances(req)
|
|
235
|
+
if self._payload_store is not None:
|
|
236
|
+
payload_helpers.deexternalize_payloads(resp, self._payload_store)
|
|
223
237
|
states += [parse_orchestration_state(res) for res in resp.orchestrationState]
|
|
224
238
|
if check_continuation_token(resp.continuationToken, _continuation_token, self._logger):
|
|
225
239
|
_continuation_token = resp.continuationToken
|
|
@@ -235,6 +249,8 @@ class TaskHubGrpcClient:
|
|
|
235
249
|
try:
|
|
236
250
|
self._logger.info(f"Waiting up to {timeout}s for instance '{instance_id}' to start.")
|
|
237
251
|
res: pb.GetInstanceResponse = self._stub.WaitForInstanceStart(req, timeout=timeout)
|
|
252
|
+
if self._payload_store is not None and res.exists:
|
|
253
|
+
payload_helpers.deexternalize_payloads(res, self._payload_store)
|
|
238
254
|
return new_orchestration_state(req.instanceId, res)
|
|
239
255
|
except grpc.RpcError as rpc_error:
|
|
240
256
|
if rpc_error.code() == grpc.StatusCode.DEADLINE_EXCEEDED: # type: ignore
|
|
@@ -250,6 +266,8 @@ class TaskHubGrpcClient:
|
|
|
250
266
|
try:
|
|
251
267
|
self._logger.info(f"Waiting {timeout}s for instance '{instance_id}' to complete.")
|
|
252
268
|
res: pb.GetInstanceResponse = self._stub.WaitForInstanceCompletion(req, timeout=timeout)
|
|
269
|
+
if self._payload_store is not None and res.exists:
|
|
270
|
+
payload_helpers.deexternalize_payloads(res, self._payload_store)
|
|
253
271
|
state = new_orchestration_state(req.instanceId, res)
|
|
254
272
|
log_completion_state(self._logger, instance_id, state)
|
|
255
273
|
return state
|
|
@@ -264,6 +282,10 @@ class TaskHubGrpcClient:
|
|
|
264
282
|
with tracing.start_raise_event_span(event_name, instance_id):
|
|
265
283
|
req = build_raise_event_req(instance_id, event_name, data)
|
|
266
284
|
self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
|
|
285
|
+
if self._payload_store is not None:
|
|
286
|
+
payload_helpers.externalize_payloads(
|
|
287
|
+
req, self._payload_store, instance_id=instance_id,
|
|
288
|
+
)
|
|
267
289
|
self._stub.RaiseEvent(req)
|
|
268
290
|
|
|
269
291
|
def terminate_orchestration(self, instance_id: str, *,
|
|
@@ -272,6 +294,10 @@ class TaskHubGrpcClient:
|
|
|
272
294
|
req = build_terminate_req(instance_id, output, recursive)
|
|
273
295
|
|
|
274
296
|
self._logger.info(f"Terminating instance '{instance_id}'.")
|
|
297
|
+
if self._payload_store is not None:
|
|
298
|
+
payload_helpers.externalize_payloads(
|
|
299
|
+
req, self._payload_store, instance_id=instance_id,
|
|
300
|
+
)
|
|
275
301
|
self._stub.TerminateInstance(req)
|
|
276
302
|
|
|
277
303
|
def suspend_orchestration(self, instance_id: str) -> None:
|
|
@@ -330,6 +356,10 @@ class TaskHubGrpcClient:
|
|
|
330
356
|
input: Optional[Any] = None) -> None:
|
|
331
357
|
req = build_signal_entity_req(entity_instance_id, operation_name, input)
|
|
332
358
|
self._logger.info(f"Signaling entity '{entity_instance_id}' operation '{operation_name}'.")
|
|
359
|
+
if self._payload_store is not None:
|
|
360
|
+
payload_helpers.externalize_payloads(
|
|
361
|
+
req, self._payload_store, instance_id=str(entity_instance_id),
|
|
362
|
+
)
|
|
333
363
|
self._stub.SignalEntity(req, None) # TODO: Cancellation timeout?
|
|
334
364
|
|
|
335
365
|
def get_entity(self,
|
|
@@ -341,7 +371,8 @@ class TaskHubGrpcClient:
|
|
|
341
371
|
res: pb.GetEntityResponse = self._stub.GetEntity(req)
|
|
342
372
|
if not res.exists:
|
|
343
373
|
return None
|
|
344
|
-
|
|
374
|
+
if self._payload_store is not None:
|
|
375
|
+
payload_helpers.deexternalize_payloads(res, self._payload_store)
|
|
345
376
|
return EntityMetadata.from_entity_metadata(res.entity, include_state)
|
|
346
377
|
|
|
347
378
|
def get_all_entities(self,
|
|
@@ -357,6 +388,8 @@ class TaskHubGrpcClient:
|
|
|
357
388
|
while True:
|
|
358
389
|
query_request = build_query_entities_req(entity_query, _continuation_token)
|
|
359
390
|
resp: pb.QueryEntitiesResponse = self._stub.QueryEntities(query_request)
|
|
391
|
+
if self._payload_store is not None:
|
|
392
|
+
payload_helpers.deexternalize_payloads(resp, self._payload_store)
|
|
360
393
|
entities += [EntityMetadata.from_entity_metadata(entity, query_request.query.includeState) for entity in resp.entities]
|
|
361
394
|
if check_continuation_token(resp.continuationToken, _continuation_token, self._logger):
|
|
362
395
|
_continuation_token = resp.continuationToken
|
|
@@ -402,7 +435,8 @@ class AsyncTaskHubGrpcClient:
|
|
|
402
435
|
log_formatter: Optional[logging.Formatter] = None,
|
|
403
436
|
secure_channel: bool = False,
|
|
404
437
|
interceptors: Optional[Sequence[shared.AsyncClientInterceptor]] = None,
|
|
405
|
-
default_version: Optional[str] = None
|
|
438
|
+
default_version: Optional[str] = None,
|
|
439
|
+
payload_store: Optional[PayloadStore] = None):
|
|
406
440
|
|
|
407
441
|
interceptors = prepare_async_interceptors(metadata, interceptors)
|
|
408
442
|
|
|
@@ -415,6 +449,7 @@ class AsyncTaskHubGrpcClient:
|
|
|
415
449
|
self._stub = stubs.TaskHubSidecarServiceStub(channel)
|
|
416
450
|
self._logger = shared.get_logger("async_client", log_handler, log_formatter)
|
|
417
451
|
self.default_version = default_version
|
|
452
|
+
self._payload_store = payload_store
|
|
418
453
|
|
|
419
454
|
async def close(self) -> None:
|
|
420
455
|
"""Closes the underlying gRPC channel."""
|
|
@@ -451,6 +486,11 @@ class AsyncTaskHubGrpcClient:
|
|
|
451
486
|
req.parentTraceContext.CopyFrom(parent_trace_ctx)
|
|
452
487
|
|
|
453
488
|
self._logger.info(f"Starting new '{req.name}' instance with ID = '{req.instanceId}'.")
|
|
489
|
+
# Externalize any large payloads in the request
|
|
490
|
+
if self._payload_store is not None:
|
|
491
|
+
await payload_helpers.externalize_payloads_async(
|
|
492
|
+
req, self._payload_store, instance_id=req.instanceId,
|
|
493
|
+
)
|
|
454
494
|
res: pb.CreateInstanceResponse = await self._stub.StartInstance(req)
|
|
455
495
|
return res.instanceId
|
|
456
496
|
|
|
@@ -458,6 +498,8 @@ class AsyncTaskHubGrpcClient:
|
|
|
458
498
|
fetch_payloads: bool = True) -> Optional[OrchestrationState]:
|
|
459
499
|
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
|
|
460
500
|
res: pb.GetInstanceResponse = await self._stub.GetInstance(req)
|
|
501
|
+
if self._payload_store is not None and res.exists:
|
|
502
|
+
await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
|
|
461
503
|
return new_orchestration_state(req.instanceId, res)
|
|
462
504
|
|
|
463
505
|
async def get_all_orchestration_states(self,
|
|
@@ -474,6 +516,8 @@ class AsyncTaskHubGrpcClient:
|
|
|
474
516
|
while True:
|
|
475
517
|
req = build_query_instances_req(orchestration_query, _continuation_token)
|
|
476
518
|
resp: pb.QueryInstancesResponse = await self._stub.QueryInstances(req)
|
|
519
|
+
if self._payload_store is not None:
|
|
520
|
+
await payload_helpers.deexternalize_payloads_async(resp, self._payload_store)
|
|
477
521
|
states += [parse_orchestration_state(res) for res in resp.orchestrationState]
|
|
478
522
|
if check_continuation_token(resp.continuationToken, _continuation_token, self._logger):
|
|
479
523
|
_continuation_token = resp.continuationToken
|
|
@@ -489,6 +533,8 @@ class AsyncTaskHubGrpcClient:
|
|
|
489
533
|
try:
|
|
490
534
|
self._logger.info(f"Waiting up to {timeout}s for instance '{instance_id}' to start.")
|
|
491
535
|
res: pb.GetInstanceResponse = await self._stub.WaitForInstanceStart(req, timeout=timeout)
|
|
536
|
+
if self._payload_store is not None and res.exists:
|
|
537
|
+
await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
|
|
492
538
|
return new_orchestration_state(req.instanceId, res)
|
|
493
539
|
except grpc.aio.AioRpcError as rpc_error:
|
|
494
540
|
if rpc_error.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
|
|
@@ -503,6 +549,8 @@ class AsyncTaskHubGrpcClient:
|
|
|
503
549
|
try:
|
|
504
550
|
self._logger.info(f"Waiting {timeout}s for instance '{instance_id}' to complete.")
|
|
505
551
|
res: pb.GetInstanceResponse = await self._stub.WaitForInstanceCompletion(req, timeout=timeout)
|
|
552
|
+
if self._payload_store is not None and res.exists:
|
|
553
|
+
await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
|
|
506
554
|
state = new_orchestration_state(req.instanceId, res)
|
|
507
555
|
log_completion_state(self._logger, instance_id, state)
|
|
508
556
|
return state
|
|
@@ -517,6 +565,10 @@ class AsyncTaskHubGrpcClient:
|
|
|
517
565
|
with tracing.start_raise_event_span(event_name, instance_id):
|
|
518
566
|
req = build_raise_event_req(instance_id, event_name, data)
|
|
519
567
|
self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
|
|
568
|
+
if self._payload_store is not None:
|
|
569
|
+
await payload_helpers.externalize_payloads_async(
|
|
570
|
+
req, self._payload_store, instance_id=instance_id,
|
|
571
|
+
)
|
|
520
572
|
await self._stub.RaiseEvent(req)
|
|
521
573
|
|
|
522
574
|
async def terminate_orchestration(self, instance_id: str, *,
|
|
@@ -525,6 +577,10 @@ class AsyncTaskHubGrpcClient:
|
|
|
525
577
|
req = build_terminate_req(instance_id, output, recursive)
|
|
526
578
|
|
|
527
579
|
self._logger.info(f"Terminating instance '{instance_id}'.")
|
|
580
|
+
if self._payload_store is not None:
|
|
581
|
+
await payload_helpers.externalize_payloads_async(
|
|
582
|
+
req, self._payload_store, instance_id=instance_id,
|
|
583
|
+
)
|
|
528
584
|
await self._stub.TerminateInstance(req)
|
|
529
585
|
|
|
530
586
|
async def suspend_orchestration(self, instance_id: str) -> None:
|
|
@@ -583,6 +639,10 @@ class AsyncTaskHubGrpcClient:
|
|
|
583
639
|
input: Optional[Any] = None) -> None:
|
|
584
640
|
req = build_signal_entity_req(entity_instance_id, operation_name, input)
|
|
585
641
|
self._logger.info(f"Signaling entity '{entity_instance_id}' operation '{operation_name}'.")
|
|
642
|
+
if self._payload_store is not None:
|
|
643
|
+
await payload_helpers.externalize_payloads_async(
|
|
644
|
+
req, self._payload_store, instance_id=str(entity_instance_id),
|
|
645
|
+
)
|
|
586
646
|
await self._stub.SignalEntity(req, None)
|
|
587
647
|
|
|
588
648
|
async def get_entity(self,
|
|
@@ -594,7 +654,8 @@ class AsyncTaskHubGrpcClient:
|
|
|
594
654
|
res: pb.GetEntityResponse = await self._stub.GetEntity(req)
|
|
595
655
|
if not res.exists:
|
|
596
656
|
return None
|
|
597
|
-
|
|
657
|
+
if self._payload_store is not None:
|
|
658
|
+
await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
|
|
598
659
|
return EntityMetadata.from_entity_metadata(res.entity, include_state)
|
|
599
660
|
|
|
600
661
|
async def get_all_entities(self,
|
|
@@ -610,6 +671,8 @@ class AsyncTaskHubGrpcClient:
|
|
|
610
671
|
while True:
|
|
611
672
|
query_request = build_query_entities_req(entity_query, _continuation_token)
|
|
612
673
|
resp: pb.QueryEntitiesResponse = await self._stub.QueryEntities(query_request)
|
|
674
|
+
if self._payload_store is not None:
|
|
675
|
+
await payload_helpers.deexternalize_payloads_async(resp, self._payload_store)
|
|
613
676
|
entities += [EntityMetadata.from_entity_metadata(entity, query_request.query.includeState) for entity in resp.entities]
|
|
614
677
|
if check_continuation_token(resp.continuationToken, _continuation_token, self._logger):
|
|
615
678
|
_continuation_token = resp.continuationToken
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
"""Azure Blob Storage payload externalization for Durable Task.
|
|
5
|
+
|
|
6
|
+
This optional extension package provides a :class:`BlobPayloadStore`
|
|
7
|
+
that stores large orchestration / activity payloads in Azure Blob
|
|
8
|
+
Storage, keeping gRPC message sizes within safe limits.
|
|
9
|
+
|
|
10
|
+
Install the required dependency with::
|
|
11
|
+
|
|
12
|
+
pip install durabletask[azure-blob-payloads]
|
|
13
|
+
|
|
14
|
+
Usage::
|
|
15
|
+
|
|
16
|
+
from durabletask.extensions.azure_blob_payloads import (
|
|
17
|
+
BlobPayloadStore,
|
|
18
|
+
BlobPayloadStoreOptions,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
store = BlobPayloadStore(BlobPayloadStoreOptions(
|
|
22
|
+
connection_string="DefaultEndpointsProtocol=https;...",
|
|
23
|
+
))
|
|
24
|
+
worker = TaskHubGrpcWorker(payload_store=store)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from azure.storage.blob import BlobServiceClient # noqa: F401
|
|
29
|
+
except ImportError as exc:
|
|
30
|
+
raise ImportError(
|
|
31
|
+
"The 'azure-storage-blob' package is required for blob payload "
|
|
32
|
+
"support. Install it with: pip install durabletask[azure-blob-payloads]"
|
|
33
|
+
) from exc
|
|
34
|
+
|
|
35
|
+
from durabletask.extensions.azure_blob_payloads.blob_payload_store import BlobPayloadStore
|
|
36
|
+
from durabletask.extensions.azure_blob_payloads.options import BlobPayloadStoreOptions
|
|
37
|
+
|
|
38
|
+
__all__ = ["BlobPayloadStore", "BlobPayloadStoreOptions"]
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
"""Azure Blob Storage implementation of :class:`PayloadStore`."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import gzip
|
|
9
|
+
import logging
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from azure.core.exceptions import ResourceExistsError
|
|
14
|
+
from azure.storage.blob import BlobServiceClient
|
|
15
|
+
from azure.storage.blob.aio import BlobServiceClient as AsyncBlobServiceClient
|
|
16
|
+
|
|
17
|
+
from durabletask.extensions.azure_blob_payloads.options import BlobPayloadStoreOptions
|
|
18
|
+
from durabletask.payload.store import PayloadStore
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("durabletask-blobpayloads")
|
|
21
|
+
|
|
22
|
+
# Token format matching the .NET SDK: blob:v1:<container>:<blobName>
|
|
23
|
+
_TOKEN_PREFIX = "blob:v1:"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BlobPayloadStore(PayloadStore):
|
|
27
|
+
"""Stores and retrieves large payloads in Azure Blob Storage.
|
|
28
|
+
|
|
29
|
+
This implementation is compatible with the .NET SDK's
|
|
30
|
+
``AzureBlobPayloadsSideCarInterceptor`` – both SDKs use the same
|
|
31
|
+
token format (``blob:v1:<container>:<blobName>``) and the same
|
|
32
|
+
storage layout, allowing cross-language interoperability.
|
|
33
|
+
|
|
34
|
+
Example::
|
|
35
|
+
|
|
36
|
+
store = BlobPayloadStore(BlobPayloadStoreOptions(
|
|
37
|
+
connection_string="...",
|
|
38
|
+
))
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
options: A :class:`BlobPayloadStoreOptions` with all settings.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, options: BlobPayloadStoreOptions):
|
|
45
|
+
if not options.connection_string and not options.account_url:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"Either 'connection_string' or 'account_url' (with 'credential') must be provided."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
self._options = options
|
|
51
|
+
self._container_name = options.container_name
|
|
52
|
+
|
|
53
|
+
# Optional kwargs shared by both sync and async clients.
|
|
54
|
+
extra_kwargs: dict = {}
|
|
55
|
+
if options.api_version:
|
|
56
|
+
extra_kwargs["api_version"] = options.api_version
|
|
57
|
+
|
|
58
|
+
# Build sync client
|
|
59
|
+
if options.connection_string:
|
|
60
|
+
self._blob_service_client = BlobServiceClient.from_connection_string(
|
|
61
|
+
options.connection_string, **extra_kwargs,
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
assert options.account_url is not None # guaranteed by validation above
|
|
65
|
+
self._blob_service_client = BlobServiceClient(
|
|
66
|
+
account_url=options.account_url,
|
|
67
|
+
credential=options.credential,
|
|
68
|
+
**extra_kwargs,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Build async client
|
|
72
|
+
if options.connection_string:
|
|
73
|
+
self._async_blob_service_client = AsyncBlobServiceClient.from_connection_string(
|
|
74
|
+
options.connection_string, **extra_kwargs,
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
assert options.account_url is not None # guaranteed by validation above
|
|
78
|
+
self._async_blob_service_client = AsyncBlobServiceClient(
|
|
79
|
+
account_url=options.account_url,
|
|
80
|
+
credential=options.credential,
|
|
81
|
+
**extra_kwargs,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self._ensure_container_created = False
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Lifecycle / resource management
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def close(self) -> None:
|
|
91
|
+
"""Close the underlying sync blob service client."""
|
|
92
|
+
self._blob_service_client.close()
|
|
93
|
+
|
|
94
|
+
async def close_async(self) -> None:
|
|
95
|
+
"""Close the underlying async blob service client."""
|
|
96
|
+
await self._async_blob_service_client.close()
|
|
97
|
+
|
|
98
|
+
def __enter__(self) -> BlobPayloadStore:
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def __exit__(self, *args: object) -> None:
|
|
102
|
+
self.close()
|
|
103
|
+
|
|
104
|
+
async def __aenter__(self) -> BlobPayloadStore:
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
async def __aexit__(self, *args: object) -> None:
|
|
108
|
+
await self.close_async()
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def options(self) -> BlobPayloadStoreOptions:
|
|
112
|
+
return self._options
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
# Sync operations
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def upload(self, data: bytes, *, instance_id: Optional[str] = None) -> str:
|
|
119
|
+
self._ensure_container_sync()
|
|
120
|
+
|
|
121
|
+
if self._options.enable_compression:
|
|
122
|
+
data = gzip.compress(data)
|
|
123
|
+
|
|
124
|
+
blob_name = self._make_blob_name(instance_id)
|
|
125
|
+
container_client = self._blob_service_client.get_container_client(self._container_name)
|
|
126
|
+
container_client.upload_blob(name=blob_name, data=data, overwrite=True)
|
|
127
|
+
|
|
128
|
+
token = f"{_TOKEN_PREFIX}{self._container_name}:{blob_name}"
|
|
129
|
+
logger.debug("Uploaded %d bytes -> %s", len(data), token)
|
|
130
|
+
return token
|
|
131
|
+
|
|
132
|
+
def download(self, token: str) -> bytes:
|
|
133
|
+
container, blob_name = self._parse_token(token)
|
|
134
|
+
container_client = self._blob_service_client.get_container_client(container)
|
|
135
|
+
blob_data = container_client.download_blob(blob_name).readall()
|
|
136
|
+
|
|
137
|
+
if self._options.enable_compression:
|
|
138
|
+
blob_data = gzip.decompress(blob_data)
|
|
139
|
+
|
|
140
|
+
logger.debug("Downloaded %d bytes <- %s", len(blob_data), token)
|
|
141
|
+
return blob_data
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
# Async operations
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
async def upload_async(self, data: bytes, *, instance_id: Optional[str] = None) -> str:
|
|
148
|
+
await self._ensure_container_async()
|
|
149
|
+
|
|
150
|
+
if self._options.enable_compression:
|
|
151
|
+
data = gzip.compress(data)
|
|
152
|
+
|
|
153
|
+
blob_name = self._make_blob_name(instance_id)
|
|
154
|
+
container_client = self._async_blob_service_client.get_container_client(self._container_name)
|
|
155
|
+
await container_client.upload_blob(name=blob_name, data=data, overwrite=True)
|
|
156
|
+
|
|
157
|
+
token = f"{_TOKEN_PREFIX}{self._container_name}:{blob_name}"
|
|
158
|
+
logger.debug("Uploaded %d bytes -> %s", len(data), token)
|
|
159
|
+
return token
|
|
160
|
+
|
|
161
|
+
async def download_async(self, token: str) -> bytes:
|
|
162
|
+
container, blob_name = self._parse_token(token)
|
|
163
|
+
container_client = self._async_blob_service_client.get_container_client(container)
|
|
164
|
+
stream = await container_client.download_blob(blob_name)
|
|
165
|
+
blob_data = await stream.readall()
|
|
166
|
+
|
|
167
|
+
if self._options.enable_compression:
|
|
168
|
+
blob_data = gzip.decompress(blob_data)
|
|
169
|
+
|
|
170
|
+
logger.debug("Downloaded %d bytes <- %s", len(blob_data), token)
|
|
171
|
+
return blob_data
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
# Token helpers
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def is_known_token(self, value: str) -> bool:
|
|
178
|
+
try:
|
|
179
|
+
self._parse_token(value)
|
|
180
|
+
return True
|
|
181
|
+
except ValueError:
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _parse_token(token: str) -> tuple[str, str]:
|
|
186
|
+
"""Parse ``blob:v1:<container>:<blobName>`` into (container, blobName)."""
|
|
187
|
+
if not token.startswith(_TOKEN_PREFIX):
|
|
188
|
+
raise ValueError(f"Invalid blob payload token: {token!r}")
|
|
189
|
+
rest = token[len(_TOKEN_PREFIX):]
|
|
190
|
+
parts = rest.split(":", 1)
|
|
191
|
+
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
192
|
+
raise ValueError(f"Invalid blob payload token: {token!r}")
|
|
193
|
+
return parts[0], parts[1]
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _make_blob_name(instance_id: Optional[str] = None) -> str:
|
|
197
|
+
"""Generate a blob name, optionally scoped under an instance ID folder."""
|
|
198
|
+
unique = uuid.uuid4().hex
|
|
199
|
+
if instance_id:
|
|
200
|
+
return f"{instance_id}/{unique}"
|
|
201
|
+
return unique
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
# Container lifecycle
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def _ensure_container_sync(self) -> None:
|
|
208
|
+
if self._ensure_container_created:
|
|
209
|
+
return
|
|
210
|
+
container_client = self._blob_service_client.get_container_client(self._container_name)
|
|
211
|
+
try:
|
|
212
|
+
container_client.create_container()
|
|
213
|
+
except ResourceExistsError:
|
|
214
|
+
pass
|
|
215
|
+
self._ensure_container_created = True
|
|
216
|
+
|
|
217
|
+
async def _ensure_container_async(self) -> None:
|
|
218
|
+
if self._ensure_container_created:
|
|
219
|
+
return
|
|
220
|
+
container_client = self._async_blob_service_client.get_container_client(self._container_name)
|
|
221
|
+
try:
|
|
222
|
+
await container_client.create_container()
|
|
223
|
+
except ResourceExistsError:
|
|
224
|
+
pass
|
|
225
|
+
self._ensure_container_created = True
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
"""Configuration options for the Azure Blob payload store."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from durabletask.payload.store import LargePayloadStorageOptions
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class BlobPayloadStoreOptions(LargePayloadStorageOptions):
|
|
16
|
+
"""Configuration specific to the Azure Blob payload store.
|
|
17
|
+
|
|
18
|
+
Inherits general threshold / compression settings from
|
|
19
|
+
:class:`~durabletask.payload.store.LargePayloadStorageOptions`
|
|
20
|
+
and adds Azure Blob-specific fields.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
container_name: Azure Blob container used to store externalized
|
|
24
|
+
payloads. Defaults to ``"durabletask-payloads"``.
|
|
25
|
+
connection_string: Azure Storage connection string. Mutually
|
|
26
|
+
exclusive with *account_url*.
|
|
27
|
+
account_url: Azure Storage account URL (e.g.
|
|
28
|
+
``"https://<account>.blob.core.windows.net"``). Use
|
|
29
|
+
together with *credential* for token-based auth.
|
|
30
|
+
credential: A ``TokenCredential`` instance (e.g.
|
|
31
|
+
``DefaultAzureCredential``) for authenticating to the
|
|
32
|
+
storage account when using *account_url*.
|
|
33
|
+
api_version: Azure Storage API version override (useful for
|
|
34
|
+
Azurite compatibility).
|
|
35
|
+
"""
|
|
36
|
+
container_name: str = "durabletask-payloads"
|
|
37
|
+
connection_string: Optional[str] = None
|
|
38
|
+
account_url: Optional[str] = None
|
|
39
|
+
credential: Any = field(default=None, repr=False)
|
|
40
|
+
api_version: Optional[str] = None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
"""Public payload externalization API for the Durable Task SDK.
|
|
5
|
+
|
|
6
|
+
This package exposes the abstract :class:`PayloadStore` interface,
|
|
7
|
+
configuration options, and helper functions for externalizing and
|
|
8
|
+
de-externalizing large payloads in protobuf messages.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from durabletask.payload.helpers import (
|
|
12
|
+
deexternalize_payloads,
|
|
13
|
+
deexternalize_payloads_async,
|
|
14
|
+
externalize_payloads,
|
|
15
|
+
externalize_payloads_async,
|
|
16
|
+
)
|
|
17
|
+
from durabletask.payload.store import (
|
|
18
|
+
LargePayloadStorageOptions,
|
|
19
|
+
PayloadStore,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"LargePayloadStorageOptions",
|
|
24
|
+
"PayloadStore",
|
|
25
|
+
"deexternalize_payloads",
|
|
26
|
+
"deexternalize_payloads_async",
|
|
27
|
+
"externalize_payloads",
|
|
28
|
+
"externalize_payloads_async",
|
|
29
|
+
]
|