Skip to content

Commit eaaff4a

Browse files
committed
Offload gzip work in asynchronous Blob payload transfers
1 parent b44c5f3 commit eaaff4a

5 files changed

Lines changed: 86 additions & 3 deletions

File tree

‎CHANGELOG.md‎

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

88
## Unreleased
99

10+
FIXED
11+
12+
- Asynchronous Azure Blob payload uploads and downloads no longer run gzip
13+
compression or decompression on the calling event loop, keeping concurrent
14+
async operations responsive during large transfers.
15+
1016
## v1.10.1
1117

1218
FIXED

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ Activity wrappers preserve the application's source directory for
2626
`context.function_directory`, including when payload storage is not configured.
2727
Direct activity calls retain their Python return values and do not access payload
2828
storage; externalization applies only to host binding invocations.
29+
With the corresponding core SDK update, asynchronous Blob payload transfers
30+
also keep compression and decompression off the Functions invocation event loop.
2931

3032
## v2.0.0rc1
3133

‎durabletask-azuremanaged/CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## Unreleased
99

10+
FIXED
11+
12+
- With the corresponding core SDK update, asynchronous Azure Blob payload
13+
transfers no longer block the event loop during compression or decompression.
14+
1015
## v1.10.1
1116

1217
CHANGED

‎durabletask/extensions/azure_blob_payloads/blob_payload_store.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import asyncio
89
import gzip
910
import logging
1011
import uuid
@@ -173,7 +174,7 @@ async def upload_async(self, data: bytes, *, instance_id: str | None = None) ->
173174
await self._ensure_container_async()
174175

175176
if self._options.enable_compression:
176-
data = gzip.compress(data)
177+
data = await asyncio.to_thread(gzip.compress, data)
177178

178179
blob_name = self._make_blob_name(instance_id)
179180
container_client: AsyncContainerClient = self._async_blob_service_client.get_container_client(
@@ -194,7 +195,7 @@ async def download_async(self, token: str) -> bytes:
194195
blob_data = await stream.readall()
195196

196197
if self._options.enable_compression:
197-
blob_data = gzip.decompress(blob_data)
198+
blob_data = await asyncio.to_thread(gzip.decompress, blob_data)
198199

199200
logger.debug("Downloaded %d bytes <- %s", len(blob_data), token)
200201
return blob_data

‎tests/durabletask/test_large_payload.py‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33

44
"""Tests for large-payload externalization and de-externalization."""
55

6-
from unittest.mock import MagicMock
6+
import asyncio
7+
import gzip
8+
import threading
9+
from unittest.mock import AsyncMock, MagicMock
710

811
import pytest
912
from google.protobuf import wrappers_pb2
@@ -433,6 +436,72 @@ def test_is_known_token(self):
433436
# ------------------------------------------------------------------
434437

435438

439+
@pytest.mark.asyncio
440+
@pytest.mark.parametrize("operation", ["upload", "download"])
441+
@pytest.mark.parametrize("enable_compression", [False, True])
442+
async def test_async_blob_compression_yields(monkeypatch, operation, enable_compression):
443+
pytest.importorskip("azure.storage.blob")
444+
from durabletask.extensions.azure_blob_payloads import blob_payload_store as module
445+
from durabletask.extensions.azure_blob_payloads import BlobPayloadStoreOptions
446+
447+
data = b"payload" * 1000
448+
stored_data = gzip.compress(data) if enable_compression else data
449+
loop = asyncio.get_running_loop()
450+
loop_thread = threading.get_ident()
451+
entered = asyncio.Event()
452+
release = threading.Event()
453+
gzip_method = "compress" if operation == "upload" else "decompress"
454+
original = getattr(gzip, gzip_method)
455+
456+
def gated_gzip(value):
457+
assert enable_compression
458+
assert threading.get_ident() != loop_thread
459+
loop.call_soon_threadsafe(entered.set)
460+
assert release.wait(5), "Event loop did not release compression"
461+
return original(value)
462+
463+
async def upload_blob(**kwargs):
464+
assert threading.get_ident() == loop_thread
465+
466+
async def readall():
467+
assert threading.get_ident() == loop_thread
468+
return stored_data
469+
470+
stream = MagicMock()
471+
stream.readall = AsyncMock(side_effect=readall)
472+
container = MagicMock()
473+
container.create_container = AsyncMock()
474+
container.upload_blob = AsyncMock(side_effect=upload_blob)
475+
container.download_blob = AsyncMock(return_value=stream)
476+
service = MagicMock()
477+
service.get_container_client.return_value = container
478+
service.close = AsyncMock()
479+
monkeypatch.setattr(module, "BlobServiceClient", MagicMock())
480+
monkeypatch.setattr(module, "AsyncBlobServiceClient", MagicMock(return_value=service))
481+
monkeypatch.setattr(gzip, gzip_method, gated_gzip)
482+
store = module.BlobPayloadStore(BlobPayloadStoreOptions(
483+
account_url="https://example.blob.core.windows.net", enable_compression=enable_compression))
484+
transfer = asyncio.create_task(store.upload_async(data) if operation == "upload"
485+
else store.download_async("blob:v1:durabletask-payloads:example"))
486+
try:
487+
if enable_compression:
488+
await asyncio.wait_for(entered.wait(), timeout=2)
489+
assert not transfer.done()
490+
finally:
491+
release.set()
492+
try:
493+
result = await transfer
494+
finally:
495+
store.close()
496+
await store.close_async()
497+
if operation == "upload":
498+
uploaded = container.upload_blob.call_args.kwargs["data"]
499+
assert (gzip.decompress(uploaded) if enable_compression else uploaded) == data
500+
assert store.is_known_token(result)
501+
else:
502+
assert result == data
503+
504+
436505
class TestBlobPayloadStoreDefaults:
437506
def test_default_options(self):
438507
"""Constructing with connection_string should use .NET SDK defaults."""

0 commit comments

Comments
 (0)