Skip to content

Commit 6588e1f

Browse files
authored
Cap merged-range size in coalesce_ranges to bound HTTP over-fetch (#2266) (#2270)
1 parent 3834466 commit 6588e1f

6 files changed

Lines changed: 258 additions & 14 deletions

File tree

xrspatial/geotiff/_cog_http.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
# so monkeypatches against the ``_reader`` namespace continue to
102102
# intercept the source. PR-J / #2258.
103103
_HTTPSource,
104+
_max_coalesced_range_bytes_from_env,
104105
_max_tile_bytes_from_env,
105106
)
106107
from ._validation import _validate_predictor_sample_format
@@ -901,6 +902,13 @@ def _fetch_decode_cog_http_tiles(
901902
# tolerates small interleaved metadata between tiles without dragging
902903
# in unrelated overview data. Set XRSPATIAL_COG_COALESCE_GAP=-1 to
903904
# disable merging (one GET per tile, the legacy behaviour).
905+
#
906+
# The merged-range size cap (issue #2266) is resolved here too so
907+
# the call below is self-documenting: a reader can see at the call
908+
# site that both ``gap_threshold`` and ``max_coalesced_range_bytes``
909+
# are governed by env vars. Without the explicit lookup the cap
910+
# would still apply -- ``coalesce_ranges`` resolves a ``None`` cap
911+
# against the same env var -- but the asymmetry would hide that.
904912
try:
905913
workers = max(1, int(_os_module.environ.get('XRSPATIAL_COG_HTTP_WORKERS', '8')))
906914
except ValueError:
@@ -911,8 +919,12 @@ def _fetch_decode_cog_http_tiles(
911919
str(COALESCE_GAP_THRESHOLD_DEFAULT)))
912920
except ValueError:
913921
gap = COALESCE_GAP_THRESHOLD_DEFAULT
922+
max_coalesced = _max_coalesced_range_bytes_from_env()
914923
tile_bytes_list = source.read_ranges_coalesced(
915-
fetch_ranges, max_workers=workers, gap_threshold=gap)
924+
fetch_ranges,
925+
max_workers=workers,
926+
gap_threshold=gap,
927+
max_coalesced_range_bytes=max_coalesced)
916928

917929
# Pass 3: decode each tile and place it (clipped to the window).
918930
#

xrspatial/geotiff/_reader.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
# Public module-level constants.
8282
COALESCE_GAP_THRESHOLD_DEFAULT,
8383
MAX_CLOUD_BYTES_DEFAULT,
84+
MAX_COALESCED_RANGE_BYTES_DEFAULT,
8485
MAX_TILE_BYTES_DEFAULT,
8586
# Private module-level constants and sentinels.
8687
_CLOUD_SCHEMES,
@@ -116,6 +117,7 @@
116117
_is_file_like,
117118
_is_fsspec_uri,
118119
_make_pinned_pool,
120+
_max_coalesced_range_bytes_from_env,
119121
_max_tile_bytes_from_env,
120122
_mmap_cache_size_from_env,
121123
_open_source,

xrspatial/geotiff/_sources.py

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -546,10 +546,40 @@ def _validate_http_url(url: str) -> str | None:
546546
#: O(num_tiles) bytes plus at most one threshold of slack between tiles.
547547
COALESCE_GAP_THRESHOLD_DEFAULT = 1 << 20 # 1 MB
548548

549+
#: Default upper bound (bytes) on any single coalesced range. The gap
550+
#: threshold alone does not bound the *total* over-fetch: a tile table
551+
#: with N entries whose offsets are spaced just under ``gap_threshold``
552+
#: apart will chain into one merged range of size ~N * gap_threshold,
553+
#: even when each individual tile is tiny and passes the per-tile cap.
554+
#: This cap seals the current merged range and starts a new one once
555+
#: extending it would exceed the limit. Override via the
556+
#: ``XRSPATIAL_COG_MAX_COALESCED_RANGE_BYTES`` environment variable.
557+
#: Issue #2266.
558+
MAX_COALESCED_RANGE_BYTES_DEFAULT = MAX_TILE_BYTES_DEFAULT # 256 MiB
559+
560+
561+
def _max_coalesced_range_bytes_from_env() -> int:
562+
"""Read the coalesced-range cap from the environment, or use the default.
563+
564+
Non-integer, empty, zero, or negative values all fall back to
565+
``MAX_COALESCED_RANGE_BYTES_DEFAULT``. Mirrors the policy used by
566+
:func:`_max_tile_bytes_from_env` so callers can not accidentally set
567+
an unreachable 1-byte cap.
568+
"""
569+
raw = _os_module.environ.get('XRSPATIAL_COG_MAX_COALESCED_RANGE_BYTES')
570+
if raw is None:
571+
return MAX_COALESCED_RANGE_BYTES_DEFAULT
572+
try:
573+
val = int(raw)
574+
except (TypeError, ValueError):
575+
return MAX_COALESCED_RANGE_BYTES_DEFAULT
576+
return val if val > 0 else MAX_COALESCED_RANGE_BYTES_DEFAULT
577+
549578

550579
def coalesce_ranges(
551580
ranges: list[tuple[int, int]],
552581
gap_threshold: int = COALESCE_GAP_THRESHOLD_DEFAULT,
582+
max_coalesced_range_bytes: int | None = None,
553583
) -> tuple[list[tuple[int, int]], list[tuple[int, int, int]]]:
554584
"""Merge nearby ``(offset, length)`` ranges into fewer larger ones.
555585
@@ -562,6 +592,16 @@ def coalesce_ranges(
562592
Maximum gap, in bytes, between two adjacent ranges before they
563593
are merged. A gap of zero means perfectly back-to-back; larger
564594
gaps trade some over-fetch for fewer round-trips.
595+
max_coalesced_range_bytes : int or None, optional
596+
Upper bound on any single merged range. When extending the
597+
current merged range would push its length above this cap, the
598+
current range is sealed and a new one is started instead. This
599+
bounds the *total* over-fetch even when many small ranges are
600+
spaced just under ``gap_threshold`` apart. ``None`` (the
601+
default) reads the cap from
602+
``XRSPATIAL_COG_MAX_COALESCED_RANGE_BYTES`` (falling back to
603+
:data:`MAX_COALESCED_RANGE_BYTES_DEFAULT`, 256 MiB). A
604+
non-positive value disables the cap. Issue #2266.
565605
566606
Returns
567607
-------
@@ -575,11 +615,19 @@ def coalesce_ranges(
575615
Notes
576616
-----
577617
Empty input returns ``([], [])``. Negative gap thresholds disable
578-
merging entirely (every input becomes its own merged range).
618+
merging entirely (every input becomes its own merged range). When a
619+
single input range already exceeds ``max_coalesced_range_bytes`` it
620+
is still emitted intact -- the per-tile cap in
621+
:func:`_max_tile_bytes_from_env` is the right place to reject
622+
oversized individual tiles; this cap only governs how greedily
623+
*separate* tiles are stitched together.
579624
"""
580625
if not ranges:
581626
return [], []
582627

628+
if max_coalesced_range_bytes is None:
629+
max_coalesced_range_bytes = _max_coalesced_range_bytes_from_env()
630+
583631
# Tag each input with its original index so we can rebuild mapping.
584632
indexed = sorted(
585633
((off, length, i) for i, (off, length) in enumerate(ranges)),
@@ -596,12 +644,21 @@ def coalesce_ranges(
596644

597645
for off, length, orig_idx in indexed[1:]:
598646
gap = off - cur_end
599-
if gap_threshold >= 0 and gap <= gap_threshold:
600-
# Extend current merged range. Gaps may be negative if a
601-
# later-listed range overlaps an earlier one; clamp so the
602-
# merged length covers both.
603-
new_end = max(cur_end, off + length)
604-
cur_length = new_end - cur_start
647+
# Gaps may be negative if a later-listed range overlaps an
648+
# earlier one; clamp ``new_end`` so the merged length covers
649+
# both. ``candidate_length`` is the length the merged range
650+
# would have if we extended it to include this input. We use
651+
# it both to decide whether the merge is allowed under the
652+
# size cap and (when it is) to update ``cur_length``.
653+
new_end = max(cur_end, off + length)
654+
candidate_length = new_end - cur_start
655+
size_ok = (
656+
max_coalesced_range_bytes <= 0
657+
or candidate_length <= max_coalesced_range_bytes
658+
)
659+
if gap_threshold >= 0 and gap <= gap_threshold and size_ok:
660+
# Extend current merged range.
661+
cur_length = candidate_length
605662
cur_end = new_end
606663
members.append((orig_idx, off, length))
607664
else:
@@ -1151,6 +1208,7 @@ def read_ranges_coalesced(
11511208
ranges: list[tuple[int, int]],
11521209
max_workers: int = 8,
11531210
gap_threshold: int = COALESCE_GAP_THRESHOLD_DEFAULT,
1211+
max_coalesced_range_bytes: int | None = None,
11541212
) -> list[bytes]:
11551213
"""Fetch *ranges* using merged GETs where adjacent ranges allow it.
11561214
@@ -1162,10 +1220,20 @@ def read_ranges_coalesced(
11621220
11631221
Setting *gap_threshold* to a negative number disables merging
11641222
and falls back to one GET per input range.
1223+
1224+
*max_coalesced_range_bytes* caps the size of any single merged
1225+
GET. ``None`` (the default) reads the cap from
1226+
``XRSPATIAL_COG_MAX_COALESCED_RANGE_BYTES`` and otherwise uses
1227+
:data:`MAX_COALESCED_RANGE_BYTES_DEFAULT`. See
1228+
:func:`coalesce_ranges` for details. Issue #2266.
11651229
"""
11661230
if not ranges:
11671231
return []
1168-
merged, mapping = coalesce_ranges(ranges, gap_threshold=gap_threshold)
1232+
merged, mapping = coalesce_ranges(
1233+
ranges,
1234+
gap_threshold=gap_threshold,
1235+
max_coalesced_range_bytes=max_coalesced_range_bytes,
1236+
)
11691237
merged_bytes = self.read_ranges(merged, max_workers=max_workers)
11701238
return split_coalesced_bytes(merged_bytes, mapping)
11711239

@@ -1423,16 +1491,23 @@ def read_ranges_coalesced(
14231491
ranges: list[tuple[int, int]],
14241492
max_workers: int = 8,
14251493
gap_threshold: int = COALESCE_GAP_THRESHOLD_DEFAULT,
1494+
max_coalesced_range_bytes: int | None = None,
14261495
) -> list[bytes]:
14271496
"""Fetch *ranges* using merged GETs where adjacent ranges allow it.
14281497
14291498
Mirrors :meth:`_HTTPSource.read_ranges_coalesced` so the tiled
14301499
COG decode path can coalesce neighbouring tiles when reading
1431-
from object storage.
1500+
from object storage. ``max_coalesced_range_bytes`` caps the
1501+
size of any single merged GET; see :func:`coalesce_ranges`.
1502+
Issue #2266.
14321503
"""
14331504
if not ranges:
14341505
return []
1435-
merged, mapping = coalesce_ranges(ranges, gap_threshold=gap_threshold)
1506+
merged, mapping = coalesce_ranges(
1507+
ranges,
1508+
gap_threshold=gap_threshold,
1509+
max_coalesced_range_bytes=max_coalesced_range_bytes,
1510+
)
14361511
merged_bytes = self.read_ranges(merged, max_workers=max_workers)
14371512
return split_coalesced_bytes(merged_bytes, mapping)
14381513

xrspatial/geotiff/tests/test_http_cog_coalesce.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,122 @@ def test_coalesce_split_recovers_per_tile_bytes():
112112
assert tile == payload[off:off + length]
113113

114114

115+
# ---------------------------------------------------------------------------
116+
# Issue #2266: coalesced-range size cap. Without this cap a tile table
117+
# with many small valid byte counts and sub-MiB gaps would chain into
118+
# one merged range whose length is roughly num_tiles * gap_threshold,
119+
# turning a safe per-tile fetch into a multi-GiB over-fetch.
120+
# ---------------------------------------------------------------------------
121+
122+
def test_coalesce_caps_merged_range_size_2266():
123+
# 8 tiny ranges spaced 1 MiB apart. Every gap is within the default
124+
# 1 MiB threshold so without the size cap they would all merge into
125+
# one ~7 MiB range. With a 4 MiB cap the coalescer must split. The
126+
# next test (``test_coalesce_cap_round_trips_bytes_2266``) covers
127+
# byte-level recovery after the split.
128+
one_mib = 1 << 20
129+
ranges = [(i * one_mib, 1024) for i in range(8)]
130+
merged, mapping = coalesce_ranges(
131+
ranges, max_coalesced_range_bytes=4 * one_mib)
132+
# No merged range exceeds the cap.
133+
for _start, length in merged:
134+
assert length <= 4 * one_mib, (
135+
f'merged range of {length} bytes exceeds 4 MiB cap')
136+
# Splitting still happened: more than one merged range.
137+
assert len(merged) > 1
138+
# Every input is still represented in the mapping.
139+
assert len(mapping) == len(ranges)
140+
141+
142+
def test_coalesce_cap_round_trips_bytes_2266():
143+
# When the cap forces a split, split_coalesced_bytes must still
144+
# recover every original byte range correctly.
145+
one_mib = 1 << 20
146+
payload_len = 8 * one_mib + 1024
147+
# Use a deterministic payload we can slice and compare against.
148+
payload = bytes((i * 17) & 0xFF for i in range(payload_len))
149+
ranges = [(i * one_mib, 1024) for i in range(8)]
150+
151+
merged, mapping = coalesce_ranges(
152+
ranges, max_coalesced_range_bytes=4 * one_mib)
153+
merged_bytes = [payload[s:s + le] for (s, le) in merged]
154+
out = split_coalesced_bytes(merged_bytes, mapping)
155+
156+
for (off, length), tile in zip(ranges, out):
157+
assert tile == payload[off:off + length]
158+
159+
160+
def test_coalesce_default_cap_bounds_adversarial_input_2266():
161+
# The motivating scenario from issue #2266: 4096 tiles, each 1 KB,
162+
# with offsets spaced 1 MiB apart. Without the cap this collapses
163+
# into one ~4 GiB merged range. With the default cap nothing
164+
# exceeds MAX_COALESCED_RANGE_BYTES_DEFAULT.
165+
from xrspatial.geotiff._sources import (
166+
MAX_COALESCED_RANGE_BYTES_DEFAULT,
167+
)
168+
169+
one_mib = 1 << 20
170+
ranges = [(i * one_mib, 1024) for i in range(4096)]
171+
merged, _ = coalesce_ranges(ranges)
172+
for _start, length in merged:
173+
assert length <= MAX_COALESCED_RANGE_BYTES_DEFAULT, (
174+
f'merged range {length} bytes exceeds default cap '
175+
f'{MAX_COALESCED_RANGE_BYTES_DEFAULT} bytes')
176+
177+
178+
def test_coalesce_cap_zero_disables_size_check_2266():
179+
# A non-positive cap means "no size limit" -- the gap threshold
180+
# alone governs merging. Useful as an escape hatch for callers
181+
# that have their own bookkeeping.
182+
one_mib = 1 << 20
183+
ranges = [(i * one_mib, 1024) for i in range(8)]
184+
merged, _ = coalesce_ranges(
185+
ranges, max_coalesced_range_bytes=0)
186+
# All eight merge into one ~7 MiB + 1 KB range.
187+
assert len(merged) == 1
188+
_, length = merged[0]
189+
assert length == 7 * one_mib + 1024
190+
191+
192+
def test_coalesce_cap_does_not_split_legitimate_back_to_back_2266():
193+
# The cap must not punish well-behaved COGs whose tiles really are
194+
# back-to-back. A real COG with 64 tiles of 64 KB each (total 4 MiB)
195+
# should still collapse into a single GET under the default cap.
196+
tile_bytes = 64 * 1024
197+
n_tiles = 64
198+
ranges = [(i * tile_bytes, tile_bytes) for i in range(n_tiles)]
199+
merged, _ = coalesce_ranges(ranges)
200+
assert len(merged) == 1
201+
assert merged[0] == (0, n_tiles * tile_bytes)
202+
203+
204+
def test_coalesce_cap_respects_env_override_2266(monkeypatch):
205+
# When max_coalesced_range_bytes is None (the default), the helper
206+
# reads XRSPATIAL_COG_MAX_COALESCED_RANGE_BYTES from the environment.
207+
one_mib = 1 << 20
208+
ranges = [(i * one_mib, 1024) for i in range(8)]
209+
# Force a 2 MiB cap via env. The 8 ranges spaced 1 MiB apart must
210+
# split into at least 4 merged ranges (2 MiB each + slack).
211+
monkeypatch.setenv(
212+
'XRSPATIAL_COG_MAX_COALESCED_RANGE_BYTES', str(2 * one_mib))
213+
merged, _ = coalesce_ranges(ranges)
214+
for _start, length in merged:
215+
assert length <= 2 * one_mib
216+
assert len(merged) >= 4
217+
218+
219+
def test_coalesce_cap_preserves_oversized_single_input_2266():
220+
# If a single input range already exceeds the cap, the function
221+
# still emits it intact. Rejecting oversized individual tiles is
222+
# the job of the per-tile cap, not the coalescer.
223+
big = 10 * (1 << 20) # 10 MiB
224+
ranges = [(0, big)]
225+
merged, mapping = coalesce_ranges(
226+
ranges, max_coalesced_range_bytes=1 << 20) # 1 MiB cap
227+
assert merged == [(0, big)]
228+
assert mapping == [(0, 0, big)]
229+
230+
115231
# ---------------------------------------------------------------------------
116232
# Mocked HTTP source for perf and call-count assertions
117233
# ---------------------------------------------------------------------------
@@ -148,6 +264,34 @@ def read_all(self) -> bytes:
148264
return self._buf
149265

150266

267+
def test_http_source_read_ranges_coalesced_respects_cap_2266():
268+
"""The HTTP wrapper must propagate the size cap to coalesce_ranges.
269+
270+
Builds a 16 MiB in-memory buffer, then asks the source to fetch
271+
eight 1 KB ranges spaced 1 MiB apart. Without the cap the wrapper
272+
would issue a single ~7 MiB merged GET; with a 4 MiB cap it issues
273+
at least two smaller GETs.
274+
"""
275+
one_mib = 1 << 20
276+
buf = bytes((i * 13) & 0xFF for i in range(16 * one_mib))
277+
src = _MockHTTPSource(buf)
278+
ranges = [(i * one_mib, 1024) for i in range(8)]
279+
280+
out = src.read_ranges_coalesced(
281+
ranges, max_workers=2,
282+
max_coalesced_range_bytes=4 * one_mib)
283+
# Bytes must match the original per-range slices.
284+
for (off, length), tile in zip(ranges, out):
285+
assert tile == buf[off:off + length]
286+
# The actual GETs the mock saw must all respect the cap.
287+
assert src.calls, 'no GETs were issued'
288+
for _start, length in src.calls:
289+
assert length <= 4 * one_mib, (
290+
f'merged GET of {length} bytes exceeds 4 MiB cap')
291+
# And the cap must have caused at least one split.
292+
assert len(src.calls) >= 2
293+
294+
151295
@pytest.fixture
152296
def small_cog_bytes(tmp_path):
153297
"""Build a small tiled COG and return its raw bytes."""

xrspatial/geotiff/tests/test_security.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -612,14 +612,24 @@ def read_all(self) -> bytes:
612612
def read_ranges(self, ranges, max_workers=8):
613613
return [self.read_range(s, le) for s, le in ranges]
614614

615-
def read_ranges_coalesced(self, ranges, max_workers=8, gap_threshold=None):
615+
def read_ranges_coalesced(
616+
self,
617+
ranges,
618+
max_workers=8,
619+
gap_threshold=None,
620+
max_coalesced_range_bytes=None,
621+
):
616622
from xrspatial.geotiff._reader import (
617623
coalesce_ranges, split_coalesced_bytes,
618624
COALESCE_GAP_THRESHOLD_DEFAULT,
619625
)
620626
if gap_threshold is None:
621627
gap_threshold = COALESCE_GAP_THRESHOLD_DEFAULT
622-
merged, mapping = coalesce_ranges(ranges, gap_threshold=gap_threshold)
628+
merged, mapping = coalesce_ranges(
629+
ranges,
630+
gap_threshold=gap_threshold,
631+
max_coalesced_range_bytes=max_coalesced_range_bytes,
632+
)
623633
merged_bytes = self.read_ranges(merged, max_workers=max_workers)
624634
return split_coalesced_bytes(merged_bytes, mapping)
625635

0 commit comments

Comments
 (0)