Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,62 @@ jobs:
run: |
uv run pytest tests/examples/

validate-binary-store:
runs-on: ubuntu-latest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate-complete only needs: [validate, validate-dapr-head], so a failing validate-binary-store job won't block merges through the required check. Could you add it to validate-complete.needs, along with a matching result check?

Also, the other jobs pass commit: to setup-dapr-runtime, but this one passes version: 1.19.0-rc.4. Does that action accept a version input? If it doesn't, the job may quietly run against the wrong runtime.

env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_REF: ${{ github.ref }}
DAPR_TEST_REQUIRE_BINARY_STORE: "1"
strategy:
fail-fast: false
matrix:
python_ver: ["3.10", "3.14"]
steps:
- name: Parse repository_dispatch payload
if: github.event_name == 'repository_dispatch'
env:
COMMAND: ${{ github.event.client_payload.command }}
PULL_HEAD_REPO: ${{ github.event.client_payload.pull_head_repo }}
PULL_HEAD_REF: ${{ github.event.client_payload.pull_head_ref }}
run: |
if [ "$COMMAND" = "ok-to-test" ]; then
echo "CHECKOUT_REPO=$PULL_HEAD_REPO" >> "$GITHUB_ENV"
echo "CHECKOUT_REF=$PULL_HEAD_REF" >> "$GITHUB_ENV"
fi
- name: Check out code
uses: actions/checkout@v7
with:
repository: ${{ env.CHECKOUT_REPO }}
ref: ${{ env.CHECKOUT_REF }}
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python_ver }}
- name: Install uv
uses: astral-sh/setup-uv@v10.3.0
- name: Install dependencies
run: uv sync --frozen --all-packages --group tests
- name: Set up Dapr CLI
uses: dapr/.github/.github/actions/setup-dapr-cli@main
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Dapr runtime with binary-store support
uses: dapr/.github/.github/actions/setup-dapr-runtime@main
with:
version: 1.19.0-rc.4
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Verify Dapr runtime version
run: |
runtime_version=$("$HOME/.dapr/bin/daprd" --version)
echo "Dapr runtime: $runtime_version"
test "$runtime_version" = "1.19.0-rc.4"
- name: Validate binary-store integration and examples
run: >-
uv run pytest -m binary_store
tests/integration/test_binarystore.py
tests/integration/test_binarystore_async.py
tests/examples/test_binary_store.py

# The stateful-history delta path (dapr/durabletask-go#110, reaching dapr via
# dapr/dapr#10142) is not in any release yet, so the matrix above cannot
# exercise it. This job builds daprd from master and runs the workflow suites
Expand Down Expand Up @@ -235,7 +291,7 @@ jobs:
# changes. Skipped required checks count as passing, hence `if: always()`
# plus an explicit result check instead of relying on `needs` alone.
validate-complete:
needs: [validate, validate-dapr-head]
needs: [validate, validate-dapr-head, validate-binary-store]
if: always()
runs-on: ubuntu-latest
steps:
Expand All @@ -251,3 +307,9 @@ jobs:
echo "validate-dapr-head result: ${{ needs.validate-dapr-head.result }}"
exit 1
fi
- name: Fail unless the binary-store matrix succeeded
run: |
if [ "${{ needs.validate-binary-store.result }}" != "success" ]; then
echo "validate-binary-store result: ${{ needs.validate-binary-store.result }}"
exit 1
fi
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ And the following extensions, installed as extras on the core `dapr` package:
* [dapr.ext.strands](./dapr/ext/strands): Strands agent session management — `pip install "dapr[strands]"`
* [dapr.ext.workflow](./dapr/ext/workflow): Durable workflow authoring — `pip install "dapr[workflow]"`

Binary-store file uploads, streaming downloads, and deletion (Dapr 1.19 alpha) are
available in both clients. See the [sync and async binary-store examples](./examples/binary-store).

## Getting started

### Prerequisites
Expand Down
198 changes: 198 additions & 0 deletions dapr/aio/clients/grpc/_binarystore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-

"""
Copyright 2026 The Dapr Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""

import asyncio
import operator
from typing import Any, AsyncIterator, Optional

from grpc import StatusCode # type: ignore
from grpc.aio import AioRpcError, Metadata # type: ignore

from dapr.clients.exceptions import DaprGrpcError
from dapr.clients.grpc._binarystore import _BinaryFileUpload
from dapr.proto import common_v1
from dapr.proto.runtime.v1 import binarystore_pb2 as binary_v1


class _AsyncBinaryFileUpload:
"""Pull-based request production with explicit ownership of pending file reads."""

def __init__(self, source: _BinaryFileUpload) -> None:
self._source = source
self._initial_sent = False
self._seq = 0
self._closed = False
self._read_task: Optional[asyncio.Task[bytes]] = None

def __aiter__(self) -> '_AsyncBinaryFileUpload':
return self

async def __anext__(self) -> binary_v1.SetBinaryFileRequestAlpha1:
if self._closed:
raise StopAsyncIteration
if not self._initial_sent:
self._initial_sent = True
return self._source.initial
try:
if isinstance(self._source.data, bytes):
chunk = self._source.read()
else:
self._read_task = asyncio.create_task(asyncio.to_thread(self._source.read))
try:
chunk = await self._read_task
finally:
self._read_task = None
except Exception as error:
# grpc.aio cancels the RPC when a request iterator raises. Retain
# the actual local error so the client can report it to the caller.
self._source.error = error
raise
if self._closed or not chunk:
raise StopAsyncIteration
request = binary_v1.SetBinaryFileRequestAlpha1(
payload=common_v1.StreamPayload(data=chunk, seq=self._seq)
)
self._seq += 1
return request

async def aclose(self) -> None:
self._closed = True
self._source.stop()
if self._read_task is not None:
# This ends the iterator's wait, including after an early server
# rejection. An already-running thread read cannot be interrupted.
task = self._read_task
task.cancel()
await asyncio.gather(task, return_exceptions=True)


class AsyncBinaryFileResponse:
"""An asynchronous binary download with one cursor for iteration and reads.

Use ``async with`` or :meth:`aclose` when stopping early. An unbounded ``read()``
buffers the remaining file; bounded reads and iteration preserve streaming.
Concurrent reads on the same response are unsupported.
"""

def __init__(self, stream: Any) -> None:
self._stream = stream
self._iterator = stream.__aiter__()
self._buffer = b''
self._expected_seq = 0
self._closed = False
self._eof = False
self._read_task: Optional[asyncio.Future[Any]] = None

def _check_open(self) -> None:
if self._closed:
raise ValueError('I/O operation on closed binary file response')

def __aiter__(self) -> AsyncIterator[bytes]:
return self

async def _next_message(self) -> binary_v1.GetBinaryFileResponseAlpha1:
task = asyncio.ensure_future(self._iterator.__anext__())
self._read_task = task
try:
# wait() raises CancelledError only for cancellation of this reader's
# task. Inspecting the result separately also works on Python 3.10,
# where Task.cancelling() is unavailable.
await asyncio.wait((task,))
try:
return task.result()
except asyncio.CancelledError as error:
raise AioRpcError(
StatusCode.CANCELLED,
Metadata(),
Metadata(),
'Binary file download was cancelled',
) from error
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
self._read_task = None

async def __anext__(self) -> bytes:
self._check_open()
if self._buffer:
chunk, self._buffer = self._buffer, b''
return chunk
if self._eof:
raise StopAsyncIteration
try:
while True:
message = await self._next_message()
if not message.HasField('payload'):
raise ValueError('binary store response is missing its payload')
if message.payload.seq != self._expected_seq:
raise ValueError('invalid sequence number in binary store response')
self._expected_seq += 1
if message.payload.data:
return message.payload.data
except StopAsyncIteration:
self._eof = True
raise
except AioRpcError as error:
await self.aclose()
raise DaprGrpcError(error) from error
except BaseException:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the underlying call is cancelled by something other than the caller's task (client.close(), another aclose(), and so on), grpc.aio raises asyncio.CancelledError from __anext__, and this branch re-raises it unchanged. The problem is that a CancelledError coming out of a task that wasn't itself cancelled gets treated by asyncio and TaskGroup as task cancellation, so it can quietly tear down unrelated work. The sync client raises DaprGrpcError(CANCELLED) in the same situation.

I think we could check whether the caller's own task is being cancelled, for example with asyncio.current_task().cancelling() or call.cancelled(). If it isn't, translate the error into a DaprGrpcError with status CANCELLED to match the sync client. If it is, re-raise CancelledError as you do now. test_client_shutdown_cancels_download would need updating to expect the new error.

await self.aclose()
raise

async def read(self, size: int = -1) -> bytes:
"""Read up to size bytes; a negative size reads the remaining file into memory."""
self._check_open()
size = operator.index(size)
if size < 0:
return b''.join([chunk async for chunk in self])
pieces = []
remaining = size
while remaining:
try:
chunk = await self.__anext__()
except StopAsyncIteration:
break
pieces.append(chunk[:remaining])
self._buffer = chunk[remaining:]
remaining -= min(remaining, len(chunk))
return b''.join(pieces)

async def aclose(self) -> None:
"""Cancel an unfinished download without closing the client."""
if not self._closed:
self._closed = True
self._buffer = b''
if not self._eof:
cancel = getattr(self._stream, 'cancel', None)
if cancel is not None:
cancel()
else:
# A tracing interceptor may expose only an async iterator.
# Stop an active read before closing its generator.
task = self._read_task
if task is not None:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
close = getattr(self._iterator, 'aclose', None)
if close is not None:
await close()

async def __aenter__(self) -> 'AsyncBinaryFileResponse':
self._check_open()
return self

async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
await self.aclose()
Loading