Skip to content

feat(py-client): Implement "many" api for batch requests - #546

Open
matt-codecov wants to merge 1 commit into
mainfrom
matt/py-client-many
Open

feat(py-client): Implement "many" api for batch requests#546
matt-codecov wants to merge 1 commit into
mainfrom
matt/py-client-many

Conversation

@matt-codecov

@matt-codecov matt-codecov commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

#277 / #478 implemented the many API in the Rust client which uses Objectstore's batch endpoint. This PR ports it over to Python.

Incorporates direction from #419.

Closes FS-330

Some notes:

  • Hand-rolls the max concurrency limit rather than rely on ThreadPoolExecutor's max_workers to allow for results to be streamed from individual batch requests without having to buffer the whole response. Otherwise a batch of 1000 5MB GET results would eat 5GB RAM and delay yielding anything to the caller.
  • Kind of aggressive about raising errors. Being flexible is the server's job, not the client's.
  • There's duplicated code. The Get / Put operation type classes copy the arg list of the get() and put() methods on session, the decompression code in get() is copied... but I didn't want to touch existing code much to reorganize in this PR.
  • Default concurrency is 1 because that's the default urllib3 connection pool size. You can still send concurrent requests with a connection pool size of 1, it just opens/closes a connection per request and logs a warning about it instead of actually pooling.
  • Actually reads the "part number" header from the batch endpoint response. Each operation's response is tagged with the operation's index in the input list so you can figure out which keyless PUT was assigned which key.
  • Robot generated the tests, haven't reviewed them yet.

@linear-code

linear-code Bot commented Jul 8, 2026

Copy link
Copy Markdown

FS-361

FS-330

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.53925% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.96%. Comparing base (cce8d39) to head (ff03dcd).

Files with missing lines Patch % Lines
clients/python/src/objectstore_client/many.py 93.20% 31 Missing ⚠️
clients/python/src/objectstore_client/client.py 90.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #546      +/-   ##
==========================================
+ Coverage   88.77%   88.96%   +0.19%     
==========================================
  Files         108      110       +2     
  Lines       18116    18692     +576     
==========================================
+ Hits        16082    16630     +548     
- Misses       2034     2062      +28     
Components Coverage Δ
Rust Backend 92.59% <ø> (ø)
Rust Client 81.97% <ø> (ø)
Python Client 94.17% <94.53%> (+0.60%) ⬆️

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jan-auer

jan-auer commented Jul 9, 2026

Copy link
Copy Markdown
Member

Please see also the now closed #419. It's gone out of sync with main, so it's better to carry on here.

The prior PR focused on streaming as much as possible and had gone through a round of feedback with the Python SDK maintainers for the public API. Also, there's configuration for concurrency that uses an optional thread pool executor. I hope there are some parts we could leverage from that.

@matt-codecov

Copy link
Copy Markdown
Contributor Author

bugbot run
@sentry review

Comment thread clients/python/src/objectstore_client/many.py Outdated
Comment thread clients/python/src/objectstore_client/many.py Outdated
Comment on lines +525 to +527
if key is None:
if is_error:
key = "<unknown>"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: A successful keyless insert is incorrectly reported as an ErrorResult if the server returns a success status but omits the x-sn-batch-operation-key header.
Severity: MEDIUM

Suggested Fix

Make the client code more robust to server contract violations. For a successful response (2xx status) that is missing the x-sn-batch-operation-key header, consider returning a PutResult with a sentinel or None key instead of an ErrorResult. This would correctly represent the operation's success while indicating the key is unknown, preventing a successful write from being reported as a failure.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: clients/python/src/objectstore_client/many.py#L525-L527

Potential issue: The client-side parsing logic for batch operations assumes that a
successful response for a keyless insert will always contain the
`x-sn-batch-operation-key` header with the server-generated key. If the server returns a
success status (e.g., 2xx) but omits this header due to a bug, proxy issue, or future
change, the client code incorrectly interprets this as a failure. Instead of returning a
`PutResult`, it constructs and returns an `ErrorResult` with a "missing header" message.
This misrepresents a successful data insertion as an error to the caller, creating a
discrepancy between the client's state and the actual state on the server.

Did we get this right? 👍 / 👎 to inform future reviews.

@matt-codecov
matt-codecov marked this pull request as ready for review August 25, 2026 03:00
@matt-codecov
matt-codecov requested a review from a team as a code owner August 25, 2026 03:00
Comment thread clients/python/src/objectstore_client/client.py Outdated
Comment thread clients/python/src/objectstore_client/many.py Outdated
Comment thread clients/python/README.md Outdated
@lcian lcian changed the title feat(py-client): implement "many" api for batch requests feat(py-client): Implement "many" api for batch requests Aug 25, 2026
Comment thread clients/python/src/objectstore_client/client.py Outdated
Comment thread clients/python/src/objectstore_client/many.py Outdated
Comment thread clients/python/src/objectstore_client/many.py Outdated

A port of the ``ZSTD_COMPRESSBOUND`` macro definition in ``zstd.h``.
"""
margin = ((128 << 10) - size) >> 11 if size < (128 << 10) else 0

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 assume this is correct.

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.

https://github.com/facebook/zstd/blob/82d322c4973d9e2968d94047a40892bc6d9a9bdf/lib/zstd.h#L249 is the source. it is a bit of a mess to parse but here it is with some extra indentation for clarity:

#define ZSTD_COMPRESSBOUND(srcSize) \
  ( \
    ((size_t)(srcSize) >= ZSTD_MAX_INPUT_SIZE) \
      ? 0 \
      : (srcSize) + ((srcSize)>>8) + (         /* size + (size >> 8) +             */ \
        ((srcSize) < (128<<10))                /* if size < (128 << 10)            */ \
          ? (((128<<10) - (srcSize)) >> 11)    /* then ((128 << 10) - size) >> 11  */ \
          : 0                                  /* else 0                           */ \
      ) \
  )

once upon a time writing convoluted preprocessor macros was my job

Comment thread clients/python/src/objectstore_client/many.py Outdated
Comment on lines +466 to +470
if concurrency == 1 and executor is None:
for item in work:
yield from _run_work(session, item)
else:
yield from _execute_concurrent(session, work, concurrency, executor)

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.

Do we really need the special case for concurrency=1 or could we just send the ops into _execute_concurrent with a special/single-threaded executor and that's it?

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.

the special case here skips any executor/threading and just runs it on the caller thread. i don't think there is a "same thread executor" ready-made we could use here. but it probably wouldn't be hard to write

Comment thread clients/python/src/objectstore_client/many.py Outdated
# part-way through the batch response.
work_results.close()
except Exception as error:
put(ErrorResult(None, error))

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.

My clanker says:

  - [P2] Preserve known indexes for malformed status headers — /Users/lorenzotmp/repos/objectstore/clients/python/src/objectstore_client/many.py:876-880
    When a response part has a valid operation-index header but a missing or malformed status header, this returns position=None and ErrorResult(index=None) even though
    the operation is known. The position is therefore not added to seen, and _unanswered later emits a second failure for the same operation, violating the one-result-per-
    operation contract and discarding useful failure context.

This seems legit. If you get a 500 for an intermediate proxy for instance, this would happen.
We can probably avoid this by doing a better check on the response headers.

Comment thread clients/python/src/objectstore_client/many.py
Comment thread clients/python/src/objectstore_client/many.py
Comment thread clients/python/src/objectstore_client/formdata.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ff03dcd. Configure here.

)
completed = False
seen: set[int] = set()
try:

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 request errors drop operations

High Severity

session._pool.request sits outside the try that fans failures through _unanswered. A connection error, timeout, or other exception from request therefore never produces per-operation results. With concurrency=1 that exception escapes and aborts the rest of many(). Concurrently, run records a single ErrorResult with index=None, so every operation in that batch is dropped. Callers comparing result count to the input, or using raise_for_failures(), miss those failures.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ff03dcd. Configure here.

return None, ErrorResult(
None,
_malformed(f"missing or invalid {HEADER_BATCH_OPERATION_STATUS} header"),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Malformed status double-reports operations

Medium Severity

When a response part has a valid x-sn-batch-operation-index but a missing or malformed status header, _result_from_part returns position=None and ErrorResult(index=None) even though the operation is already known. That position is not added to seen, so _unanswered later emits a second failure for the same operation. Result count no longer matches the input, and raise_for_failures() surfaces the same failure twice.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ff03dcd. Configure here.

self._reader: ZstdCompressionReader | None = compressor.stream_reader(
source,
closefd=False, # Not our stream to close
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Compressors allocated for every part

Medium Severity

_ZstdBody constructs a ZstdCompressor and stream_reader in __init__, and _insert_part builds every part before the request is written. A batch of zstd puts therefore holds one compression context per part at once. The class later drops the reader to avoid that cost, but the contexts already exist. A full 1000-operation batch can retain on the order of a gigabyte of compressor state until send starts.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ff03dcd. Configure here.

@lcian
lcian self-requested a review September 4, 2026 11:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants