Research: s3-boto3 backend lane — PoC and disposition¶
Item ID: ID-202
Date: 2026-06-01
Method: Built a standalone boto3-direct backend (S3Boto3Backend) and ran the full Stage-1 conformance suite against it under in-process moto; measured maintenance cost from the landed diff; assessed interop by reading the extension call paths.
Status: PoC complete (landed test-only). Disposition below is advisory — the user decides whether to promote, park, or reject.
1. Question¶
Three of the S3 pains the s3 / s3-pyarrow lanes carry are s3fs-specific
and cannot be fixed from our side:
- aiobotocore dep-pin cascade.
s3pullss3fs→aiobotocore→ a pinnedbotocore, which can collide with a user's ownboto3. - >5 GB multipart-restart cliff (s3fs-fuse #1936).
- fsspec listing-cache staleness (fsspec/filesystem_spec #324; the subject of ID-201).
A boto3-direct backend has none of them. ID-202 asks: does a third S3 lane justify its maintenance cost? Decide on three axes — user value, maintenance cost, interop loss — and record one of Ship / Park / Reject.
2. What the PoC delivers¶
src/remote_store/backends/_s3_boto3.py—S3Boto3Backend, a standaloneBackendsubclass (not_S3Base; see § 4). boto3-only:get_objectRange reads for a seekable lazyread(),put_object/upload_fileobj+TransferConfigfor writes, paginatedlist_objects_v2for the control path,copy_object+delete_objectsfor move/copy/delete-folder.- Capability parity with
S3Backend— all capabilities exceptATOMIC_MOVE(move is a non-atomic copy+delete), includingSEEKABLE_READ(delivered by a Range-request reader modelled on_AzureRangeReader),LAZY_READ,WRITE_RESULT_NATIVE, andUSER_METADATA. - Typed-error mapping built from
ClientError.response['Error']['Code']directly (see research-s3-error-mapping-fidelity.md): 404 →NotFound; 403 and the credential codes (AccessDenied,InvalidAccessKeyId,SignatureDoesNotMatch,ExpiredToken) →PermissionDenied; 5xx / endpoint errors →BackendUnavailable. - Extra (proposed, not landed)
s3-boto3 = ["boto3>=1.34"]— a single dependency, noaiobotocore. Deliberately not declared inpyproject.tomlyet: declaring it would surfacepip install remote-store[s3-boto3]in the generated user-facingFEATURES.md/ tested-versions docs, advertising an install path for a backend that is not registered (a configtype = "s3-boto3"would not resolve). The lane's tests run on theboto3already present in the dev/bench environments; the extra lands with the Ship increment (§ 6). - Conformance fixtures
s3_boto3_moto(+s3_boto3_moto_strictfor the file-ancestor opt-in), wired through the declarativebackends.toml/fixtures.tomlregistry and instantiated directly (no public backend registration — the lane is test-only pending this disposition). - Targeted tests (
tests/backends/s3/test_boto3.py): a mid-stream-abort test (see § 3a), the error-code → typed-error mapping rows, a moto 404 round trip, and opt-in live tests (a real-403 mapping check and a >5 GB multipart smoke, both gated and never run inhatch run all).
Conformance result: the full Stage-1 surface passes against
s3_boto3_moto and s3_boto3_moto_strict under moto (189 parametrized
cases for the default fixture; the strict fixture adds the file-ancestor
error-fidelity set). No production-code divergence was needed elsewhere.
3. Findings by axis¶
(a) User value¶
All three retired pains are real, and the PoC adds a fourth win:
- aiobotocore cascade gone. The lane depends only on
boto3. A user who already pinsboto3for their own SDK use installsremote-store[s3-boto3]with no transitiveaiobotocore/botocorepin to reconcile. - >5 GB cliff gone.
boto3.s3.transfer.TransferConfighandles multipart without the s3fs-fuse restart bug. The opt-intest_multipart_above_5gb_has_no_cliffsmoke proves a 5 GiB + 1 byte object round-trips by size. - Listing-cache staleness gone. The lane keeps no listing cache; every
list_*is a freshlist_objects_v2. (This is the read-after-write correctness ID-201 is spiking for the s3fs lane — the boto3 lane sidesteps it structurally, at the cost of never serving a listing from cache.) - BUG-214 not inherited. The s3fs lane committed a truncated-but-complete
object when the content source failed mid-stream (BUG-214). The boto3 lane
cannot:
put_objectnever sends a partial body, andupload_fileobjaborts the multipart upload on a reader exception. The mid-stream-abort test asserts neither an object nor an orphaned multipart upload survives — the guarantee holds by construction, with nodiscard()-style fix.
Cost side: a third S3 lane is a third thing for a citizen developer to choose
between (s3 vs s3-pyarrow vs s3-boto3). Choice-paralysis is a genuine
DX tax for the project's primary audience, and the three lanes' differences
are subtle (control vs data path, fsspec-shape vs not).
(b) Maintenance cost¶
Measured from the landed diff (non-blank, non-comment lines):
| File | Lines (code) | Note |
|---|---|---|
_s3_boto3.py |
~625 | Standalone backend |
_s3.py (S3Backend) |
~280 | Leans on _s3_base.py |
_s3_base.py (shared) |
~409 | Reused by s3 + s3-pyarrow |
_s3_pyarrow.py |
~430 | Leans on _s3_base.py |
tests/.../test_boto3.py |
~272 | Targeted tests |
tests/.../fixtures/s3_boto3_moto.py |
~58 | Fixture factory |
The headline cost: _s3_boto3.py is standalone (~625 lines) because
_S3Base's listing and metadata methods are s3fs-coupled
(self._s3fs.ls(...)) and cannot be reused. It reuses only the three
SDK-agnostic free functions (_normalize_endpoint_url, _resolve_tls_ca_bundle,
_validate_tls_ca_bundle) and reimplements the HeadObject → FileInfo
parsing. So the lane roughly doubles the per-backend S3 code that
_S3Base was created to avoid duplicating.
Test-matrix expansion: one new fixture family runs the full conformance
surface under moto (the s3_boto3 slice is ~17 s wall-clock in-process,
serial). No new container or live dependency in the default gate.
A promotion to a first-class lane would add more: an AsyncS3Boto3Backend
(currently out of scope), public registry/_info/__all__/FEATURES
wiring, a setup guide, and ongoing boto3 API-drift maintenance for the
multipart and pagination edge cases _S3Base already shields the other
lanes from.
(c) Interop loss¶
Smaller than feared. The downstream extensions are built on the Store
API, not on a native fsspec handle:
ext.arrow/ext.parquet.StoreFileSystemHandlerprobesstore.unwrap(pyarrow.fs.FileSystem)for a Tier-1 native-PyArrow fast path and falls back gracefully to the Store-API read/write path when the backend does not expose one. The boto3 lane'sunwrapreturns only abotocore.client.BaseClient, so it takes the fallback — but so does the plains3(s3fs) lane, which also does not unwrap to a PyArrowFileSystem. The boto3 lane is therefore on par withs3for Arrow / Parquet; onlys3-pyarrowgets the Tier-1 zero-copy path.ext.dagster. Reads and writes through theStoreAPI; works unchanged.
The one genuine loss: a user who calls store.unwrap(s3fs.S3FileSystem) to
drive an fsspec-native workflow gets a botocore client instead. That is a
niche escape hatch, and the s3 / s3-pyarrow lanes remain available for it.
4. Design note: why standalone, not _S3Base¶
The backlog suggested sharing _S3Base "where sensible". In practice the
sensible-to-share surface is small: _S3Base's listing / metadata methods
(list_files, list_folders, iter_children, get_folder_info) all call
self._s3fs.ls(...) and assume an fsspec instance, and the abstract _s3fs
property has no boto3 analogue. The boto3 lane reuses the three module-level
free functions and reimplements the rest.
A cleaner factoring exists — split _S3Base into an SDK-agnostic base (path
helpers, HeadObject → FileInfo, error scaffolding) plus an s3fs-listing
layer, so all three lanes inherit the agnostic part. That refactor touches
the two shipped S3 backends and must keep their conformance green, so it is
deliberately not done in the PoC: it is work the Ship path would
absorb, not speculative churn a Reject would waste.
4a. Known cross-lane differences and caveats (record before Ship)¶
Surfaced in PR review. None block the Park PoC; each is a Ship-promotion item.
- Borrowed spec marks. The lane marks the s3fs-lane error IDs
(
S3-015/016/018) directly.S3-018's postcondition literally namesbackend == "s3", andS3-017covers connection errors only — so the 5xx →BackendUnavailablerows are intentionally left unmarked (no spec clause maps server 5xx responses). Borrowing is acceptable for a test-only Park; a Ship promotion needs a dedicatedS3B-*spec block (or generalizedS3-018postconditions), mirroring how the pyarrow lane createdS3PA-018/S3PA-019that reference theS3-*IDs. - Seekable-read spec classification. The
read_seekable()override below is the S3 analogue of SEEK-006 ("Azure Range Reader Override") — a bare unbuffered_ErrorMappingStream(_S3RangeReader(...)), matching that axiom's postconditions. But SEEK-004 ("Passthrough for Seekable Backends") still lists S3 among the backends that "return theread()stream directly", which is no longer true for this lane (read()returns aBufferedReader;read_seekable()returns a different, unbuffered instance). The spec is silent on the boto3 lane. A Ship promotion needs anS3B-*axiom referencing SEEK-006 (mirroringS3PA-018/019 → S3-*) and a SEEK-004 amendment that drops the boto3 lane from the passthrough list. The targeted test (test_read_seekable_is_unbuffered_and_seeks) is left unmarked for the same reason as the 5xx rows — its axiom does not yet exist. read()vsread_seekable()buffering. Each_S3RangeReader.readintois one rangedGetObject.read()wraps it in a 1 MiBBufferedReader, so a sequential consume issues ~1 GET per MiB rather than ~1 GET per 8 KiBreadall()chunk; bulk sequential reads should still preferread_bytes(single GET).read_seekable()— the path PyArrow's randomread_atuses (ext.arrowTier-3 →pa.PythonFile) — is deliberately not buffered: aBufferedReaderinvalidates its buffer on everyseek(), so each smallread_atwould otherwise pay a full 1 MiB GET and refetch overlapping ranges. The override returns the bare Range reader, keeping eachread_atto one GET of the requested range. This matches the Azure / S3PyArrow "noBufferedReaderon the seekable path" contract (_azure.pyread_seekable).- Exact-key overwrite / collision check. The
overwrite=Falseand dst-collision guards inwrite/open_atomic/move/copyuse an exact-key HEAD, not a prefix-exists probe. Sowrite("a/b")when onlya/b/cexists proceeds here, whereas the s3fs lane raisesAlreadyExists(itsexists()isTruefor a prefix). The boto3 behavior is arguably more correct for a flat namespace (a prefix is not an object), and no conformance test covers write-over-prefix — but it is a behavioral divergence a Ship promotion would expose to users migrating from thes3lane. copy/move> 5 GB.copy_objectis a single-part server-side copy, which S3 caps at 5 GB; larger objects need a multipart copy (UploadPartCopy), which the s3fs lane'scopyhandles internally. So the headline >5 GB upload win (viaTransferConfig) does not extend to the copy path — a Ship promotion needs multipart-copy support for parity.
5. Reproduction¶
# Conformance (Stage 1, moto, in-process):
hatch run test tests/backends -k s3_boto3
# Targeted error-mapping + BUG-214 abort tests:
hatch run test tests/backends/s3/test_boto3.py
# Opt-in live checks (real AWS; never in `hatch run all`):
RS_TEST_LIVE_S3=1 hatch run pytest -m live tests/backends/s3/test_boto3.py
RS_TEST_LIVE_S3=1 RS_TEST_S3_5GB=1 hatch run pytest -m live \
tests/backends/s3/test_boto3.py -k 5gb
6. Disposition (advisory)¶
Recommendation: Park as test-only. Keep the landed PoC in the tree behind
its conformance fixtures, but do not promote it to a first-class
user-facing lane yet (no registry / _info / __all__ / FEATURES wiring,
no async variant, no CHANGELOG entry). Revisit promotion when either trigger
fires:
- s3fs upstream stalls on the >5 GB (#1936) or listing-cache (#324)
issues such that the
s3lane's correctness gap becomes a shipped-user problem; or - the aiobotocore dep-pin cascade bites a real user (a reported install
conflict against their own
boto3).
Rationale: the lane is viable and correct (full conformance parity,
BUG-214 avoided by construction, minimal interop loss) and genuinely retires
all three pains — but for today's users the s3 and s3-pyarrow lanes cover
the same surface, so promoting a third install path now buys mostly
choice-paralysis and a doubled per-backend maintenance footprint for a
marginal gain. Landing it test-only preserves the work and keeps it
conformance-green against every future change, so the promotion cost is a
docs/wiring/async increment rather than a from-scratch rebuild whenever a
trigger fires.
If the user chooses Ship instead, the increment is: the _S3Base
refactor in § 4, declaring the s3-boto3 extra in pyproject.toml (with its
drift-lock baseline), public wiring (_registry.py, _info._BACKEND_EXTRAS,
backends/__init__.__all__, regenerated FEATURES.md), an
AsyncS3Boto3Backend, a guides/backends/ page positioning the three lanes
as peers, and a user-facing CHANGELOG entry — each a follow-up BK-NNN.
If the user chooses Reject, archive this note as the rationale for not
splitting the S3 surface and remove the test-only lane; the boto3 escape
hatch remains available via store.unwrap() on the existing lanes.