Remote Range-Read Design¶
This document describes how osml-imagery-io reads from remote object stores
(e.g. fsspec/s3fs) without downloading the entire file first. It covers the
internal machinery a maintainer needs to reason about the feature: the Remote
OwnedBuffer backing, the slice-before-view rule, the per-codec I/O-hook
requirement, the fetch/prefetch seam, the concurrent cat_ranges batch path,
and which layers know a source is remote.
The Problem¶
Reading imagery from cloud storage should not require pulling the whole file into
RAM. Metadata reads (iminfo), tile access (tiles, DatasetReader.get_block),
and VirtualiZarr index construction all need only a small fraction of a multi-GB
file — headers, offset tables, and the specific tiles requested. The library
already routes remote imagery through fsspec, which turns seek+read into HTTP
range requests. The goal is to preserve that (offset, len) -> bytes shape all
the way down to the decoders rather than collapsing it into a single whole-file
.read().
The abstraction boundary is (filesystem, path) — an fsspec AbstractFileSystem
plus a path string. This is the canonical remote source: IO.open accepts an
explicit filesystem= (and the convenience functions forward it), and a bare
remote URL string (s3://bucket/key) is resolved internally via
fsspec.core.url_to_fs to the same (filesystem, path) pair. A raw fsspec
file-like handle still works and is probed for its .fs/.path
back-references as a fallback — kept deliberately so a caller who passes a handle
instead of filesystem=+path still gets the concurrent path where possible,
rather than silently degrading to serial reads. The library never learns what
backend it is reading from beyond the filesystem instance; choosing a
protocol/backend is entirely an fsspec concern.
(filesystem, path) is what makes the concurrent batch path (below) possible:
fsspec exposes multi-range concurrency on the filesystem (cat_ranges), not on
the file handle, so recovering the filesystem is what lets one logical tile’s
scattered byte ranges be fetched in parallel.
OwnedBuffer Gains a Remote Backing¶
OwnedBuffer wraps every byte source the readers consume. Its private
BackingStore enum has three variants:
Variant |
Bytes |
|
|
|---|---|---|---|
|
OS demand-paged from disk |
zero-copy (refcount + range adjust) |
zero-copy view of the range |
|
resident in RAM |
zero-copy (refcount + range adjust) |
zero-copy view of the range |
|
none resident — fetched on demand |
fetch + cache |
hard error (see below) |
A Remote buffer logically spans the whole source — len() reports the total
size — but holds no bytes. It is constructed with OwnedBuffer::from_remote(fetcher).
try_slice is the fetch¶
The decisive contract: try_slice(a..b) is the fetch point. For Mapped/Heap
it stays zero-copy; for Remote it locks the fetcher, pulls (and caches) exactly
[a..b], and returns a resident Heap-backed sub-buffer whose as_bytes() /
as_ptr() yield a stable, contiguous pointer. A network error surfaces as
Err(CodecError::Remote(..)).
Because try_slice already returned Result<OwnedBuffer, CodecError> before this
feature (from the zero-copy buffer refactor), remote fetch failures ride the
existing fallible seam. There is no out-of-band error channel, no poison state,
no check_health() — a fetch is just another Result threaded through code that
already returns Result.
Three sibling seams round out the buffer API:
materialize()— obtain the whole logical range as a resident buffer. Thin wrapper overtry_slice(0..len): zero-copy for resident backings, one bounded whole-file fetch forRemote. This is the seam for monolithic formats (PNG, standalone JPEG, DTED’s full-grid decode) that legitimately need every byte. Crucially, a fetch failure propagates through?rather than becoming an infallible-as_bytes()panic.subview(range)— narrow the logical range without fetching, for every backing. ARemotebuffer staysRemote(bytes still fetched on demand by a latertry_slice/callback);Mapped/Heapstay zero-copy. This is how a reader isolates a codestream/segment window of a remote source while keeping it remote (used by the embedded-J2K path so aget_blockfetches only the target tile).read_range(offset, len)/resident_bytes()— the universal fetch seam and its zero-copy fast path.resident_bytes()returnsSome(&[u8])for resident backings andNoneforRemote, so a parser can borrow when possible and fall back toread_range(a copy for resident, a fetch-and-cache forRemote) otherwise. The C-library I/O callbacks and the pure-Rust header/IFD parsers use these so they never depend on the whole file being resident.
The Slice-Before-View Rule and the Full-Buffer Guard¶
A bare full-buffer as_bytes() on a Remote backing panics. This is a
deliberate correctness guard, not a normal control-flow path: a full-buffer view
of a remote source would silently force a whole-file download (or, worse, hide a
network error behind an infallible signature).
The rule for readers is therefore slice before you view:
// Wrong for a Remote backing — would materialize the whole file:
let image_data = &self.source_data.as_bytes()[data_start..data_end];
// Right — try_slice is the fetch; a fetch error returns via `?`:
let chunk = self.source_data.try_slice(data_start..data_end)?;
let image_data = chunk.as_bytes(); // as_bytes() on the resident sub-buffer is fine
Monolithic vs block-capable¶
A bare full-buffer view is not always wrong — it depends on the format’s access pattern:
Block-capable formats (NITF/JBP, TIFF, J2K) must read only bounded ranges. A full-buffer view here would defeat range reads, so it is a bug the guard catches. These readers parse headers/offset tables from bounded
try_slice/read_rangecalls and materialize per-block on demand.Monolithic formats (PNG, standalone JPEG) have no sub-file structure to exploit — a whole-file read is the correct and only decode strategy. These readers call
materialize()?and view the result. That is still a single bounded fetch on aRemotebacking, and the failure is fallible.
The guard is thus an unreachable-in-practice backstop: after the reader
conversions, no real call site hits it. It is not #[deprecated] (that would fire
on the hundreds of legitimate resident-buffer call sites the type cannot distinguish
at compile time). If a future change reintroduces a bare full-buffer view on a
possibly-Remote buffer, the fetch-log remote tests — which assert “no single fetch
covered the whole file” — fail loudly.
Per-Codec I/O Hook Requirement¶
The feature works only because no codec assumes it holds a single addressable buffer covering the entire image file. Every codec either navigates file structure through an I/O callback the library owns, or is handed pre-isolated per-block bytes. The codecs split into two categories that must be wired differently:
Category B — codec handed a contiguous buffer¶
Standalone JPEG (libjpeg-turbo). A single frame decoded in one shot. It
materialize()s its codestream — a bounded whole-file fetch on a Remote backing.
A standalone .jpg is not the multi-GB tiled case, so this is acceptable.
NITF-embedded J2K / JPEG / NC (JBP). Already per-block isolated: each block
decoder receives an OwnedBuffer sliced (via subview, keeping it Remote) to one
block’s codestream/segment. This is the target shape the Zarr codec path already uses
in production. For embedded J2K, the decoder resolves the target tile’s (offset, length) parts from the TLM table and fetches them as one batched read_ranges
(concurrent cat_ranges for an fsspec source — see the batch path below), so a
get_block over a Remote source fetches only header + the target tile’s parts,
and those parts are fetched in parallel rather than serially.
Fetch and Prefetch Seam¶
The range machinery lives in src/remote/, deliberately independent of
OwnedBuffer:
RangeReader (trait) — the (offset, len) -> Result<Vec<u8>> byte-source seam,
│ plus a batched read_many(&[(offset, len)]) -> Vec<Vec<u8>>;
│ both are &self so N threads read concurrently with no lock.
│ implemented by the Python-backed stream reader AND an
│ in-memory test fake (no Py<PyAny> needed for tests)
▼
StreamFetcher — owns a RangeReader + RangeCache + PrefetchPolicy;
│ read_range()/read_ranges() are the fetch entry points:
│ serve-from-cache → plan → coalesce → fetch misses → cache
├── RangeCache resident, non-overlapping ranges (extensible to eviction)
└── PrefetchPolicy given a requested range, may return extra ranges
RemoteBacking holds the StreamFetcher directly (no outer Mutex); the
fetcher is Sync via its own internal split: a lock-free reader
(&self methods) plus a short-held Mutex<CacheState> guarding only the cache
and policy. The reader is deliberately outside the lock so the network fetch
happens with the mutex released — concurrent get_block callers overlap their
S3 round-trips instead of serializing on the cache lock. See the concurrent
batch path below.
The concurrent cat_ranges batch path¶
One logical J2K tile’s tile-parts can live at widely separated file offsets. The
RangeReader::read_many seam fetches them concurrently rather than one at a
time. For an fsspec-backed source, PyReadStream::read_many recovers the
filesystem’s cat_ranges(paths, starts, ends) — fsspec’s synchronous
multi-range fetch — which fans the ranges out as concurrent GETs on its
background event loop and blocks the caller on a threading.Event (releasing the
GIL while blocked). The caller manages no event loop. OwnedBuffer::read_ranges
funnels a batch to this path for a Remote backing (cheap resident slices
otherwise), and the J2K decoder issues one read_ranges for a tile’s parts
instead of a serial per-part loop.
Two levels of concurrency compose over the same shared s3fs connection pool:
within a block (a tile’s parts via one cat_ranges) and across blocks
(multiple get_block callers on their own threads, overlapping because the
reader is lock-free). Non-fsspec handles (io.BytesIO, plain files) fall back to
a serial read_at loop — correct, just not concurrent, which is fine for
sources that are not latency-bound.
The containment invariant that makes concurrent &self reads safe: all
concurrent fetches route through the stateless cat_ranges path (no cursor),
while the stateful seek+read cursor path stays single-threaded (a debug
re-entry guard on PyReadStream::read_at fires if two threads ever race the
cursor). The duplicate-fetch race — two callers missing the same span and both
fetching — is harmless: RangeCache::insert merges/dedups overlapping bytes, so
the cache stays coherent and only a little bandwidth is wasted.
PrefetchPolicy¶
PrefetchPolicy::plan(requested, total_size) -> Vec<Range<u64>> decides which ranges
the fetcher actually pulls — the requested range plus any speculative extras. Because
the fetcher self-heals (an under-sized prefetch simply triggers another fetch on the
next access), a policy is a pure optimization with no correctness cliff.
v1 ships HeaderAwarePolicy: on an access that starts within the file’s head it
eagerly fetches a header region ([0, header_len)), coalescing the header +
offset-table bootstrap reads into a single fetch; a random access deep in the file
does not drag the header along. The header-region size is supplied per format by the
IO dispatch layer (which knows the format); the fetcher stays format-agnostic. A
future spatial policy (Z-order / Hilbert / 2D adjacency) is just another
implementation of the trait and needs no reader or buffer-API change.
Layer Awareness¶
Only Layer 2 (the PyO3 IO binding) knows a source is remote. Everything above and below sees the ordinary abstractions.
Layer |
Component |
Knows about remote fetch? |
|---|---|---|
1 |
Python API ( |
No |
2 |
PyO3 binding ( |
Yes — probes the stream, constructs the |
3 |
|
No — thin delegation |
4 |
|
No |
5 |
Format readers |
Only that byte access is via |
6 |
Asset providers |
No |
7 |
C FFI (libtiff, OpenJPEG) |
No — callbacks call the fetcher |
Layer 2 wiring¶
create_reader_from_stream (and open_multi_stream_with_roles, per source) probes
the stream and either builds a Remote buffer or falls back to the full-read path:
remote_header_hint(format)— returns a header-prefetch size for the formats whose readers are proven remote-safe (TIFF, J2K, NITF/JBP, DTED) andNoneotherwise. This is the single gate. Of these, TIFF/J2K/NITF are block-capable and fetch per block; DTED is remote-safe for headers but reads its single full-grid block in one bounded fetch. PNG and standalone JPEG stay on the full-read fallback by routing choice, not necessity: their readers are remote-safe viamaterialize(), but chunking a mandatory whole-file read through range GETs has no benefit.probe_seekable_size(stream)— returnsSome(total_size)iff the stream hasread,seekable()is true, and a size is known (prefers a.sizeattribute — fsspec exposes it — elseseek(0, SEEK_END)); it restores the read position to 0 and treats size 0 as unknown.If both succeed, wrap a
PyReadStream(theRangeReaderadapter that turnsseek+readinto range reads underPython::attach) in aStreamFetcherwith aHeaderAwarePolicy(hint)and build theRemoteOwnedBuffer. Otherwise fall back toread_stream_bytes→from_vec, unchanged.
Non-seekable or unknown-size streams therefore behave exactly as before (full read). The multi-source path probes each source independently, so a mixed seekable/non-seekable list degrades per source.
Testing Approach¶
The whole point of the byte-range foundation (over the rejected page-fault approach)
is testability with no OS machinery, no signal handlers, and no threads. An in-memory
fake RangeReader — a Vec<u8> plus a call log — backs the fetcher, so tests assert
which ranges were fetched and that the whole file is never pulled. Coverage:
Unit —
try_sliceonRemotefetches exactly the requested range (plus policy prefetch); repeated access hits cache; a fetch error propagates asCodecError; the full-buffer guard fires onRemote.Per-format remote decode — for TIFF, J2K, JBP (NC/embedded), PNG, DTED: metadata read +
get_blockover aRemotebuffer yield correct pixels. “Never fetch the whole file” is asserted for the block-capable formats; the monolithic formats (PNG, standalone JPEG) and DTED’s full-grid block assert byte-identity only.Property — decoding via a
Remotebuffer is byte-identical to decoding the same file viaHeap, under randomized block-visit order.Python integration —
IO.openwith a range-logging fake file-like object (and a real fsspecLocalFileSystemhandle) drivesiminfo,tiles,get_block, and VirtualiZarr index construction without a full download.Fallback — non-seekable / unknown-size streams still work via the full-read path.
See Also¶
Synthetic Codestream Codec Pattern — the already range-based VirtualiZarr consumer path.
Native Library FFI Design — the libtiff/OpenJPEG FFI callback patterns this feature reuses.
API Design — the public
DatasetReader/IO.opensurface, which this feature leaves unchanged.Datasets and the IO Interface — user-facing guidance on reading from remote handles.