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
115 changes: 115 additions & 0 deletions clients/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,121 @@ existing_parts = resumed.list_parts()
key = resumed.complete(new_parts + existing_parts)
```

### Many API (batch operations)

`session.many()` executes a number of operations with as few requests as
possible. Those within the batch protocol's per-part limit of 1 MB are grouped
into requests to Objectstore's batch endpoint which cuts network overhead
considerably. Inserts too large for that, or of unknown size, are sent as
individual requests instead.

Pass any iterable of `Get`, `Put`, `Delete`, and `Head` operations, which live in
the `many` module. Results come back as `GetResult`, `PutResult`, `DeleteResult`,
and `HeadResult` objects, each carrying the object's `key` and an `error` that is
`None` when the operation succeeded:

```python
from objectstore_client import Client, Usecase, many

client = Client("http://localhost:8888")
session = client.session(Usecase("attachments"), org=42, project=1337)

results = session.many(
[
many.Put(b"file1 contents", key="file1"),
many.Put(b"file2 contents", key="file2"),
many.Get("file3"),
many.Delete("file4"),
many.Head("file5"),
]
)

for result in results:
if result.error is not None:
... # this operation failed
elif isinstance(result, many.GetResult):
# `response` is None if the object does not exist.
payload = result.response.payload if result.response else None
```

`session.many()` returns an `OperationResults` object which is a lazy iterator.
As the iterator is consumed, it assembles batch requests and sends them to
Objectstore. As responses come in, the operation results are yielded. Abandoning
the iterator without fully consuming it will cancel whatever has not been
dispatched yet.

If successful results don't need to be processed or inspected, callers can call
`raise_for_failures()` to drain the results and raise an `ExceptionGroup` with
all per-operation errors, or `failures()` which returns the failed results as a
list:

```python
session.many([many.Delete("file1"), many.Delete("file2")]).raise_for_failures()

for failure in session.many([many.Delete("file3")]).failures():
print(failure.key, failure.error)
```

#### Concurrency

`concurrency` caps how many requests are in flight, and defaults to `1`. When
`concurrency` is set to `1` and no `executor` is provided, requests are run
serially on the caller thread. Otherwise, requests will run on a passed-in
executor or, if one isn't provided, a thread pool created by `session.many()`.
Note that, while passing `concurrency=3` (or some other value) is sufficient to
enable concurrent requests, each request opens and closes new connections;
consider using the `connection_kwargs` client parameter to enable proper
connection pooling in `urllib3` as shown below.

```python
from concurrent.futures import ThreadPoolExecutor

client = Client("http://localhost:8888", connection_kwargs={"maxsize": 8})
session = client.session(Usecase("attachments"), org=42, project=1337)

with ThreadPoolExecutor(max_workers=8) as executor:
for result in session.many(operations, concurrency=8, executor=executor):
...
```

Results are yielded as responses are received, and the order isn't necessarily
the same order that operations were given in. Each result carries an `index`
field that corresponds to the index of the `Get` / `Put` / `Delete` / `Head`
operation in the operation iterable passed into `session.many()`. This `index`
allows a keyless `Put` operation to be linked with its result to learn the key
that was assigned.

```python
uploads = [many.Put(b"first"), many.Put(b"second")]

for result in session.many(uploads):
print(f"{uploads[result.index].contents!r} was stored as {result.key}")
```

An `ErrorResult` carries `index=None` when the response part it came from could
not be attributed to any operation at all.

Within a single batch, the Objectstore server processes individual operations
concurrently and each operation's relative order is undefined. To minimize the
likelihood of racing operations on the same key, the client will separate
same-key operations into different batches if at least one of the operations is
a write or delete. However, with a `concurrency` value larger than `1`, it is
Comment on lines +253 to +256

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think there's a need to separate operations on the same key into different batches, as the result will be non-deterministic anyways when concurrency > 1.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this idea was taken from #419, except that PR only applied it when concurrency=1. this PR applies it for all values of concurrency. if the splitting logic is written anyway, why not apply it on all paths to at least make the race less likely?

as for whether it's necessary: tbh i'm ambivalent. if i were using the objectstore client, i'd probably take care to keep only the last PUT/DELETE operation (last write wins) and drop any post-write GET/HEAD operations because we already know what they'll return (unless clients expect an out-of-band racing write operation lol). but there is a case one may want to express with a single .many() today that would need this fix: a GET /foo/bar to read the current value before a PUT /foo/bar to replace the value. a non-atomic swap.

this fix and concurrency=1 are needed for that case to work right with a single session.many() call. but as a workaround you can do a session.many() with all of your GETs and then a second session.many() with all the PUTs. that eliminates any racing (as far as this client goes) and doesn't even require concurrency=1. it might be better to do it that way anyway.

so i think i've talked myself into agreeing with you. @jan-auer is there anything else to consider about this or shall i remove this behavior?

still possible for same-key operations to race if they are part of separate
batches that are being processed concurrently. If this is a big concern, pass
`concurrency=1` to ensure same-key operations never race.

#### Metrics

When a metrics backend is configured, `session.many()` emits some metrics:
- `storage.batch.latency`: a timer recording a batch request's execution time,
tagged with a (bucketed) number of operations included in the batch
- `stoarge.batch.operations`: a simple counter of individual operations, tagged
with each operation's kind (i.e. `PUT`/`GET`/`DELETE`).

An operation that doesn't qualify for batching will be sent through the
`session`'s regular single-operation API for that operation and will emit
single-object metrics on that path rather than batch metrics here.

### Authentication

If your Objectstore instance enforces authorization, you must configure authentication
Expand Down
8 changes: 8 additions & 0 deletions clients/python/docs/objectstore_client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ objectstore\_client.errors module
:show-inheritance:
:undoc-members:

objectstore\_client.many module
-------------------------------

.. automodule:: objectstore_client.many
:members:
:show-inheritance:
:undoc-members:

objectstore\_client.metadata module
-----------------------------------

Expand Down
2 changes: 2 additions & 0 deletions clients/python/src/objectstore_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from objectstore_client import many
from objectstore_client.auth import Permission, SecretKey, TokenGenerator, TokenProvider
from objectstore_client.client import (
Client,
Expand All @@ -22,6 +23,7 @@
"Session",
"GetResponse",
"RequestError",
"many",
"Compression",
"ExpirationPolicy",
"Metadata",
Expand Down
77 changes: 75 additions & 2 deletions clients/python/src/objectstore_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

import math
import warnings
from collections.abc import Mapping, Sequence
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import asdict, dataclass
from datetime import UTC, datetime, timedelta
from io import BytesIO
from typing import IO, Any, Literal, NamedTuple, cast
from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, cast
from urllib.parse import urlparse

import sentry_sdk
Expand Down Expand Up @@ -39,6 +39,12 @@
PARAM_AUTH = "os_auth"


if TYPE_CHECKING:
from concurrent.futures import Executor

from objectstore_client.many import Operation, OperationResults


class GetResponse(NamedTuple):
metadata: Metadata
payload: IO[bytes]
Expand Down Expand Up @@ -288,6 +294,73 @@ def _make_url(self, key: str | None, full: bool = False) -> str:
return f"{self._base_url()}{path}"
return path

def _make_batch_url(self) -> str:
relative_path = f"/v1/objects:batch/{self._usecase.name}/{self._scope}/"
return self._base_path.rstrip("/") + relative_path

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Batch URL skips path encoding

Medium Severity

_make_batch_url builds the path without utils.encode_path, unlike _make_url and _make_multipart_url. Usecase names or base paths with spaces or other non-safe characters produce an invalid batch URL while single-object calls still work, so session.many() fails for scopes that the rest of the client already supports.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c481164. Configure here.


def many(
self,
operations: Iterable[Operation],
*,
concurrency: int | None = None,
executor: Executor | None = None,
) -> OperationResults:
"""
Executes multiple operations, batching them where possible.

Operations that satisfy the batch protocol's per-part size limit of 1MB
are grouped into batch requests to reduce network overhead. Inserts with
larger sizes (or unknown sizes) are sent as individual requests instead.

Args:
operations: The operations to execute, as
:class:`~objectstore_client.many.Get`,
:class:`~objectstore_client.many.Put`,
:class:`~objectstore_client.many.Delete`, and
:class:`~objectstore_client.many.Head` instances. Any iterable
works.
concurrency: The maximum number of requests in flight. Defaults to
``1``, which runs everything sequentially on the calling thread,
without a thread pool. Raising it is sufficient to send requests
concurrently, but each request opens/closes its own connection
without additional configuration on the :class:`Client` (a
``maxsize`` key in the ``connection_kwargs`` dict to control
``urllib3`` connection pool size).
Comment on lines +322 to +328

@lcian lcian Aug 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In the Rust client, by default, we send the requests with some degree of concurrency.
I would probably do that here too (so, default to the internal threadpool and concurrency > 1), probably with the same defaults as Rust.
Unless you have strong reasons to avoid that, it seems better UX to just handle this internally in the client rather than having the user necessarily think about this.

executor: An executor to run requests on, instead of a thread pool
owned by this call. ``concurrency`` still caps how much work is
submitted to it at a time, and passing an executor overrides the
sequential behavior of ``concurrency=1``.

Returns:
An :class:`~objectstore_client.many.OperationResults` iterator over
the results. Results are yielded as responses come in, in no
particular order; each carries the ``index`` of the operation it
belongs to, which is the only handle on a keyless
:class:`~objectstore_client.many.Put`. This iterator is lazy, and if
it's abandoned without being fully consumed then operations that
haven't been dispatched are cancelled.

Raises:
ValueError: If ``concurrency`` is less than ``1``.

Example::

from objectstore_client import many

results = session.many([many.Put(b"hello", key="k1"), many.Get("k2")])
for result in results:
if result.error is not None:
... # this operation failed
elif isinstance(result, many.GetResult):
... # `result.response` is None if the object does not exist
"""
# Imported lazily to avoid a circular import at module load time.
from objectstore_client.many import execute_many

return execute_many(
self, operations, concurrency=concurrency, executor=executor
)

def _make_multipart_url(
self,
action: str | None,
Expand Down
Loading
Loading