Skip to content

Azure-sas-presigned#5

Merged
h-a-graham merged 25 commits into
mainfrom
azure-sas-presigned
Jun 30, 2026
Merged

Azure-sas-presigned#5
h-a-graham merged 25 commits into
mainfrom
azure-sas-presigned

Conversation

@h-a-graham

Copy link
Copy Markdown
Contributor

Title: Pre-signed remote reads + pooled stacking/batch API + connection reuse


Summary

This branch extends cptkirk from a single-raster remote warper into a saturating multi-source engine, and makes the whole API share one fast, connection-reusing fetch path. Three arcs:

  1. Read more remote sources — pre-signed Azure (Planetary Computer SAS) and OBS/S3/GCS signed URLs.
  2. New pooled stacking/batch API — ck_stack, ck_stack_read, ck_batch for fanning many sources through one connection pool with structure-preserving output.
  3. Make it fast and coherent — open-once streaming fetch, one connection pool per host, grid-signature plan caching, all propagated across ck_warp/ck_read/warp_remote/ck_stack/cog_info.

All changes are additive; no public function was removed or had its signature broken.

What's new

Pre-signed remote readers

  • Azure SAS blobs reive Azure store (SASre-homed from the URL query into azure_storage_sas_key).
  • OBS / S3 (v2 & v4) new query-preservingraw-HTTP range reader (SignedHttpReader) with multipart/byteranges coalescing and a bouect_store's HttpStoredrops the query (and the credential), so these need the dedicated reader.
    Stacking & batch API
    - ck_stack / ck_stacm N sources andwrite/return them as separate bands of one output (the gdalbuildvrt-separate analogue,
  • ck_batch — reproject/resample a whole list of groups (e.g. a STACitem list, one group connection pool andONE streaming fetch; writes one file per band or one stacked file pgroup, mirroring theresampling (r aslist), num_threads=1 default (per-warp threads hurt small windows).
    Performance
  • Connection reuse — one object_store store / HTTP client pool per across a batch (Open TLS handshake perhost instead of one per source. cog_sources_open on 204 MPC assets:s → 1.3 s. Propagate point viaopen_all_cached. - Streaming fetch — warp each window themoment it lands (overlapping in-flight fetches); bounded-channel backpressure caps pe
  • Grid-signature plan cache — the per-source PROJ transform_bounds once per unique grid ck_batch.
  • Auto parallel warp — the per-output warp/write is dispatched acroan ambient mirai dae(window buffers shared zero-copy via mori); helps when the warp backs up behind the fetch slow links / heavy wmons(n) enables,daemons(0) disables. Fetch is always the single pool.
    Hardening (from an internal review)
    A three-agent review (Rust soundness / R API / build) ran before mefindings fixed:
  • FFI safety — validate parallel-vector lengths and idx/band rangesmalformed input retuicking across theboundary (which aborts the session). - Error visibility —failures rather thansilently returning NA (distinct from non-overlap). - Robustness — HTTP d streaming channel;fetch task aborted on session drop; multi-range fallback on server rejection; empty-ran-source nodata onplan-cache hits; sanitise=FALSE aborts on mixed grids; dst-template collision is an erro
  • Packaging — dropped an undeclared fs dependency; clippy 0 warnings.

parse_src handed Azure blob URLs straight to object_store::parse_url_opts,
which drops the SAS query string and falls through to the managed-identity
credential chain, hanging on the IMDS endpoint.

Detect a pre-signed Azure blob/dfs https URL (host under *.blob/dfs.core.
windows.net carrying a sig= query) and inject the query as an
azure_storage_sas_key option so the store signs requests with the token and
skips the credential chain. The token goes last so a URL-embedded SAS wins
over an ambient AZURE_STORAGE_SAS_KEY env var.

Add unit tests for the detection and an ignored network test that reads an
MPC-signed HLS S30 COG window.
Add a "When it helps" section: cptkirk's lever is I/O concurrency via
band-subset reads, which benefits multi-band/embedding rasters. Single-band
assets see little gain over GDAL's /vsicurl.
object_store routes a plain https URL to its HttpStore and drops the query
string, discarding the credential of a pre-signed URL (Huawei OBS / S3 V2
AccessKeyId+Signature, S3 V4 X-Amz-Signature, GCS X-Goog-Signature). Detect
these in open_reader and read them with SignedHttpReader, which keeps the full
URL and issues only ranged GETs (never HEAD, which OBS rejects).

SignedHttpReader overrides get_byte_ranges to coalesce a planar tile's scattered
band-plane ranges into one multipart/byteranges request: N round-trips collapse
to one over a single connection. This is decisive on high-latency HTTP/1.1
stores (OBS): ck_warp full read 82s->24s, AOI 12s->7s. Falls back to
bounded-concurrent single-range requests when the server ignores multi-range,
detecting a 200 (whole object) from the status before reading the body so a
non-cooperating server never triggers a whole-file download.

Built on reqwest 0.12 + ring/rustls (reuses object_store's stack) rather than
async-tiff's ReqwestReader (reqwest 0.13 pulls aws-lc-rs + cmake). Reader unified
to Arc<dyn AsyncFileReader>. Adds bench_warp_vs_curl.R.
ck_stack fetches a window from N sources through one global io pool and writes
them stacked as separate bands (the gdalbuildvrt -separate analogue), versus
ck_warp's mosaic of multiple sources. ck_stack_read returns the array.

ck_batch lifts this to a list of groups: one pooled fetch across every asset in
every group (maximum network saturation), with structure-preserving output --
stack=FALSE writes one file per band (return mirrors the nested input),
stack=TRUE writes one band-separated file per group. dst takes an explicit path
structure or a template expanded from group/band names. Single AOI across the
batch; non-overlapping sources return NA.

Refactors .stack_assemble out of the stack engine so all three share the
fetch/stage/stack/materialise path. Offline tests via local fixtures.
`r` may now be a single method (applied to all) or, matching the input
structure, one method per source -- e.g. "near" for a mask band and "bilinear"
for reflectance. ck_stack/ck_stack_read take a length-1 or length(src) vector;
ck_batch takes length-1, a flat per-asset vector, or a list mirroring src.

The per-source method is threaded through the shared .stack_assemble warp branch
(it only applies on the warp path; a native-window copy ignores resampling).
ck_batch warps many small windows one at a time, where per-warp GDAL thread
spin-up costs more than it saves. Benchmarked ~8x slower at "ALL_CPUS" than at
1 for this many-small-files pattern (47.8s vs 6.1s for 204 reprojected assets),
so default to 1. ck_warp/ck_stack keep ALL_CPUS (larger single warps benefit).
…ori)

The fetch already runs in one saturating pool, but the per-output warp+write ran
serially -- the bottleneck on large many-files batches. ck_batch can now warp and
write the outputs across ambient mirai daemons: each fetched window buffer is
shared into OS shared memory with mori (serialised to daemons as a tiny
reference, not a per-worker copy), and each daemon warps single-threaded.

Controlled by `parallel`: NULL (default) auto-enables when mirai + mori are
installed and daemons are running; TRUE requires them; FALSE forces serial.
cptkirk uses the ambient mirai::daemons() pool and never spawns/tears down
daemons. Falls back to the serial path otherwise; output is identical either way.

mirai + mori added to Suggests. Gated test verifies the parallel path matches
the serial output bit-for-bit.
Adds a streaming fetch session in Rust (cog_sources_open / cog_fetch_stream_begin
/ cog_fetch_take): open every source once concurrently, then push each fetched
window back to R as it completes through one buffer_unordered pool.

ck_batch now drives it instead of meta-then-fetch: it opens once (no double
header read), and assembles each output as soon as its window(s) arrive --
serially or dispatched to a daemon -- so the warp/write overlaps the fetch and
peak memory is bounded (windows are dispatched and freed on arrival rather than
all held).

Measured: open-once streaming fetch 18.1s vs 29.1s double-open (204 windows,
-38%); end-to-end ck_batch ~22s, now fetch-bound with the warp hidden behind the
fetch (serial ~= parallel). Output identical to the prior path; all tests pass.
Every acquisition of one MGRS tile shares the same grid, but .plan_from_meta ran
a PROJ transform_bounds per source -- the dominant prep cost (2.7s of 3.0s
planning for 204 same-tile assets). Compute the plan once per unique grid
signature (geotransform + crs + level dims) and reuse it, attaching each
source's URL. Prep drops ~2.7s on a single-tile batch; planning is now
negligible next to the open phase.
sanitise = TRUE (default) still opens every source to plan/verify its grid.
sanitise = FALSE trusts that all sources share one grid: it plans the window
from a SINGLE source, then folds every other open into the fetch stream
(cog_stream_fetch_urls_begin opens each source inside its own fetch task) rather
than opening all up front. The per-source header read no longer forms a barrier
-- opens overlap the fetch. Off-grid sources fail their fetch (-> NA) rather
than open-all overhead, but identical-size off-grid tiles are the caller's
responsibility under sanitise = FALSE.

~2.4s faster on a 204-asset single-tile batch (opens overlapped, not
eliminated). Output identical to the default; test confirms.
The open was connect-bound: cptkirk built a separate object_store client per
source, so opening N same-host COGs paid N TLS handshakes with no keep-alive
reuse (curl/GDAL share connections). Measured: 50 same-host header reads were
5.5s with separate connections vs 1.1s reused.

Add OpenCache (source.rs): object_store stores cached by store identity
(scheme/host/container/credential) and one shared reqwest client for
SignedHttpReader, so same-host sources share a connection pool. open_reader_cached
builds readers from it; open_tiff_from_reader reads metadata over the shared
pool. cog_sources_open uses it -> cog_sources_open on 204 MPC assets dropped from
6.6s to 1.3s. (MPC's SAS is container-scoped, so one store serves every asset.)

Because the open is now cheap for both paths, the trusted fast path no longer
needs to fold opens into the fetch stream: ck_batch routes both sanitise modes
through cog_sources_open + cog_fetch_stream_begin, with sanitise = FALSE only
changing planning (one grid, from a single source). Removes
cog_stream_fetch_urls_begin. Also fixes stack = TRUE band order under streaming
(windows arrive in completion order; bands are now reordered by source position).

Verified on the live MPC path: trusted output matches default bit-for-bit.
The connection-reuse and grid-signature plan-cache wins built for ck_batch lived
only in its open path; ck_warp / ck_read / warp_remote / ck_stack / cog_info
still used the old per-source-client opens (one TLS handshake per source) and an
uncached per-source PROJ transform.

Rust: add `open_all_cached` -- open every source once over one OpenCache
connection pool per host, then read headers concurrently. Route cog_meta_many,
cog_fetch_windows_raw and cog_sources_open through it. So every multi-source
entry point pays ~one handshake per host, not one per source.

R: extract `.plan_sources` (plan cached by grid signature) and use it in both
.warp_collect (ck_warp/ck_read/warp_remote/ck_stack) and ck_batch, so the PROJ
transform_bounds runs once per unique grid, not per source.

Live MPC: cog_info x156 now 0.8s (5 ms/src), matching the reused rate. All 239
tests pass.
ck_batch is fetch-bound: the streaming drain loop already warps each window the
moment it lands, overlapping the fetches still in flight, so the per-output warp
hides behind the network. Measured serial vs parallel (8 daemons + mori) on 204
assets at 1024x1024: 20.9s vs 21.7s (NONE), 21.6s vs 20.1s (DEFLATE) -- within
network noise. Parallelising the warp has nothing to bite on when the warp isn't
the bottleneck, which is structural for a remote-COG warper.

Drop the `parallel` arg, `.batch_use_parallel`, the mirai::mirai/mori::share
dispatch branch and the everywhere() daemon-load; `.batch_stream_run` now always
assembles inline. Remove mirai/mori from Suggests and the gated parallel test.
The streamed warp-on-arrival (cog_fetch_take drain loop) is retained -- that is
the overlap that delivers the measured time.
ck_stack / ck_stack_read / ck_batch were exported but absent from the package
?cptkirk overview and the pkgdown reference. Add a 'Stacking and batch' section
to both, and note the shared per-host connection pool + warp-on-arrival in the
'How it works' overview. pkgdown::check_pkgdown() now finds no missing topics.
- H1: validate parallel window-vector lengths and idx range in
  cog_fetch_windows_raw / cog_fetch_stream_begin. Out-of-range/mismatched input
  indexed a raw Vec -> panic across the FFI boundary -> R session abort (extendr
  cannot catch panics). Now returns a KirkError.
- H2: plan_tile now rejects out-of-range bands (as fetch_decoded already did);
  the chunky blit slices by band index and would otherwise panic out of bounds.
- H3: ck_batch no longer silently turns fetch FAILURES into NA. Failed fetches
  are collected and surfaced via cli_warn (non-overlap stays silent, as
  documented); @return updated. stack=TRUE still NA-s the whole group on a band
  failure, now with a warning.
- H4: drop the undeclared `fs` dependency (R CMD check WARNING); the ck_batch
  default dst uses base tempfile(fileext = ".tif").
- M1: .plan_sources refreshes per-source nodata on a grid-signature cache hit,
  so same-grid sources with different nodata no longer inherit the first's
  (drove wrong staging INIT_DEST/masking).
- M2: sanitise=FALSE now verifies all sources share the first's grid signature
  (headers are already in hand from cog_sources_open) and aborts on a mismatch,
  removing the silent-wrong-pixels risk for same-size off-grid tiles; it also
  applies per-source nodata. Grid signature extracted to .grid_sig.
- M3: a dst template that expands to duplicate paths (duplicate group/band
  names) now errors instead of silently overwriting.
- M4: pre-signed HTTP clients get connect/read timeouts (build_http_client),
  so a stalled source fails instead of hanging the blocking R consumer forever.
- M5: streaming fetch uses a BOUNDED tokio channel (awaited sends) -> real
  backpressure, peak memory ~io windows instead of the whole job.
- M6: the detached fetch task is aborted when the FetchSession is dropped, so
  abandoning a stream stops fetching instead of draining into a closed channel.
- M7: a rejected multi-range request falls back to per-range fetches instead of
  failing the whole read.
- M8: empty byte ranges (sparse COG tiles) short-circuit, avoiding range.end-1
  underflow on the pre-signed path.
- Clippy: 0 warnings (split_once; documented allow on the two load-bearing/clear
  cases).
Cover the previously-untested paths: non-overlapping source -> NA; stack=TRUE
band order preserved when a middle band is missing; sanitise=FALSE aborts on
mixed grids; dst template collision on duplicate names errors; .plan_sources
assigns each same-grid source its own nodata (grid-cache hit).
Reinstated the per-output warp/write dispatch across an ambient mirai daemon
pool (window buffers shared zero-copy via mori). The earlier removal was based on
a fetch-bound benchmark; on slow links or heavy/large warps the inline warp in
the drain loop backs up behind the fetch, where offloading to daemons is a real
win.

Control is auto-detect only -- no `parallel` argument. .batch_use_parallel()
returns TRUE iff mirai + mori are installed and daemons are running; start them
(mirai::daemons(n)) to enable, stop them (daemons(0)) to disable. The streamed
fetch is always cptkirk's single pool. mirai/mori back in Suggests; gated test
restored (now asserts the auto-selected daemon path matches serial).
The daemon warp dispatch named the internal helper as cptkirk:::.stack_assemble,
tripping the "package uses ::: for its own objects" NOTE that CI rejects. Pass
.stack_assemble to mirai::mirai() as a value (`asm`) instead: its environment is
cptkirk's namespace, restored on the daemon (which loads the package via
everywhere()), so the call needs no namespace qualifier. Verified the daemon
path still matches serial.
R CMD check --as-cran flagged an undeclared pkgload import in tests (the gated
daemon test uses pkgload for dev-mode loading) and non-standard top-level files.
Add pkgload to Suggests; .Rbuildignore the untracked benchmark scratch.
pkgload was only used by the gated daemon test to push the load_all build into
mirai daemons -- not by the package itself. Remove it: the daemons load cptkirk
via ck_batch's own everywhere(library(cptkirk)), so the test needs only an
INSTALLED package; skip it under devtools::load_all (detected via DEVTOOLS_LOAD,
no dependency). Clears the --as-cran 'unstated pkgload import in tests' WARNING.
Tests assume the package is installed (as R CMD check / CI run them); the
DEVTOOLS_LOAD check was a non-standard wart. Plain skip_on_cran +
skip_if_not_installed guards only.
@h-a-graham
h-a-graham merged commit 4811ae2 into main Jun 30, 2026
8 checks passed
@h-a-graham
h-a-graham deleted the azure-sas-presigned branch June 30, 2026 15:36
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.

1 participant