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.
Files changed (44) hide show
  1. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/PKG-INFO +17 -1
  2. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/README.md +14 -0
  3. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/__init__.py +7 -1
  4. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/client.py +67 -4
  5. durabletask-1.3.0.dev27/durabletask/extensions/__init__.py +4 -0
  6. durabletask-1.3.0.dev27/durabletask/extensions/azure_blob_payloads/__init__.py +38 -0
  7. durabletask-1.3.0.dev27/durabletask/extensions/azure_blob_payloads/blob_payload_store.py +225 -0
  8. durabletask-1.3.0.dev27/durabletask/extensions/azure_blob_payloads/options.py +40 -0
  9. durabletask-1.3.0.dev27/durabletask/payload/__init__.py +29 -0
  10. durabletask-1.3.0.dev27/durabletask/payload/helpers.py +349 -0
  11. durabletask-1.3.0.dev27/durabletask/payload/store.py +91 -0
  12. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/worker.py +35 -0
  13. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask.egg-info/PKG-INFO +17 -1
  14. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask.egg-info/SOURCES.txt +7 -0
  15. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask.egg-info/requires.txt +3 -0
  16. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/pyproject.toml +7 -1
  17. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/LICENSE +0 -0
  18. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/__init__.py +0 -0
  19. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/durable_entity.py +0 -0
  20. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/entity_context.py +0 -0
  21. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/entity_instance_id.py +0 -0
  22. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/entity_lock.py +0 -0
  23. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/entity_metadata.py +0 -0
  24. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/entities/entity_operation_failed_exception.py +0 -0
  25. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/client_helpers.py +0 -0
  26. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/entity_state_shim.py +0 -0
  27. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/exceptions.py +0 -0
  28. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/grpc_interceptor.py +0 -0
  29. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/helpers.py +0 -0
  30. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/json_encode_output_exception.py +0 -0
  31. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/orchestration_entity_context.py +0 -0
  32. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/orchestrator_service_pb2.py +0 -0
  33. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/orchestrator_service_pb2.pyi +0 -0
  34. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/orchestrator_service_pb2_grpc.py +0 -0
  35. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/proto_task_hub_sidecar_service_stub.py +0 -0
  36. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/shared.py +0 -0
  37. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/internal/tracing.py +0 -0
  38. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/py.typed +0 -0
  39. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/task.py +0 -0
  40. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/testing/__init__.py +0 -0
  41. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask/testing/in_memory_backend.py +0 -0
  42. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask.egg-info/dependency_links.txt +0 -0
  43. {durabletask-1.3.0.dev25 → durabletask-1.3.0.dev27}/durabletask.egg-info/top_level.txt +0 -0
  44. {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.dev25
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__ = ["ConcurrencyOptions", "VersioningOptions"]
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,4 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ """Durable Task SDK extension packages."""
@@ -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
+ ]