@@ -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.
547547COALESCE_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
550579def 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
0 commit comments