Skip to content

Commit 34447a0

Browse files
committed
fixing ValueError bug
1 parent 0d065a8 commit 34447a0

7 files changed

Lines changed: 822 additions & 15 deletions

File tree

sdk/cosmos/azure-cosmos/azure/cosmos/_routing/_routing_map_provider_common.py

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,16 @@
2828
"""
2929

3030
import logging
31+
import random
3132
from typing import Any, Dict, List, Optional, Tuple
3233

3334
from .. import _base, http_constants
34-
from .collection_routing_map import CollectionRoutingMap, _build_routing_map_from_ranges
35+
from ..exceptions import CosmosHttpResponseError
36+
from .collection_routing_map import (
37+
CollectionRoutingMap,
38+
_build_routing_map_from_ranges,
39+
_OverlapDetected, # noqa: F401 # re-exported for sync/async provider modules and tests
40+
)
3541
from . import routing_range
3642
from .routing_range import (
3743
PKRange,
@@ -44,6 +50,114 @@
4450

4551
PAGE_SIZE_CHANGE_FEED = "-1" # Return all available changes
4652

53+
# Number of times the full-load path will re-fetch ``/pkranges`` when the
54+
# builder reports an overlap (``_OverlapDetected``). Overlap on the full-load
55+
# path is treated as a transient gateway inconsistency, so a small fixed
56+
# retry budget with backoff is preferred over surfacing immediately. After
57+
# this many attempts the caller surfaces a transient HTTP 503 so the
58+
# upstream retry policy can take over.
59+
#
60+
# Defined here (rather than in each provider module) so the sync and async
61+
# providers cannot drift on the retry budget — both import the same constant.
62+
_OVERLAP_RETRY_MAX_ATTEMPTS = 3
63+
# Initial backoff between overlap retries; doubles each attempt. Worst-case
64+
# total sleep under the budget above is ~3.5s (0.5 + 1.0 + 2.0).
65+
_OVERLAP_RETRY_INITIAL_BACKOFF_SECONDS = 0.5
66+
67+
68+
def _jittered_backoff(backoff_seconds: float) -> float:
69+
"""Return a uniformly-jittered backoff in the range ``[0, backoff_seconds]``.
70+
71+
Implements the "full jitter" strategy: the actual sleep is drawn uniformly
72+
from zero to the full deterministic backoff. This decorrelates concurrent
73+
retriers (for example, multiple Cosmos clients running inside a single
74+
PySpark process that all hit the same gateway node on the same bad
75+
``/pkranges`` snapshot at the same instant) so they do not retry in
76+
lockstep and re-collide on the same gateway node.
77+
78+
The worst-case sleep per attempt is unchanged (still bounded by the
79+
deterministic backoff), so the documented retry-budget contract still
80+
holds; the expected per-attempt sleep is half of it.
81+
"""
82+
return random.uniform(0, backoff_seconds)
83+
84+
85+
def _handle_overlap_retry_decision(
86+
*,
87+
overlap_attempt_count: int,
88+
collection_link: str,
89+
logger: logging.Logger, # pylint: disable=redefined-outer-name
90+
) -> float:
91+
"""Decide what to do after the full-load builder reported an overlap.
92+
93+
Centralises the sync/async-identical retry policy. Returns the number of
94+
seconds the caller should sleep before the next attempt. Raises
95+
:class:`CosmosHttpResponseError` (HTTP 503) when the attempt budget has
96+
been exhausted; the caller's existing retry policy then handles it as
97+
a transient error.
98+
99+
The returned sleep duration is jittered (see :func:`_jittered_backoff`)
100+
so concurrent retriers do not retry in lockstep. The deterministic
101+
backoff schedule (0.5s -> 1.0s -> 2.0s, doubling) defines the *upper
102+
bound* of each attempt's sleep; the actual sleep is drawn uniformly
103+
from ``[0, that upper bound]``.
104+
105+
The caller is responsible for the actual sleep (sync ``time.sleep`` or
106+
``await asyncio.sleep``). Keeping the sleep at the call site is what
107+
lets this helper stay free of concurrency-runtime assumptions — the
108+
only line that has to differ between the sync and async providers.
109+
110+
:param int overlap_attempt_count: Number of overlap attempts made so far,
111+
including the one that just failed. Pass ``1`` after the first failure,
112+
``2`` after the second, etc.
113+
:param str collection_link: Used in log messages and the 503 error body
114+
so the caller knows which collection ran out of budget.
115+
:param logging.Logger logger: Caller's module-level logger, so messages
116+
appear under the right ``azure.cosmos._routing.*`` namespace.
117+
:return: Jittered backoff seconds to sleep before retrying. Guaranteed
118+
to be in ``[0, deterministic_backoff_for_attempt]``.
119+
:rtype: float
120+
:raises CosmosHttpResponseError: When ``overlap_attempt_count`` has reached
121+
``_OVERLAP_RETRY_MAX_ATTEMPTS``. Status code is 503 so the upstream
122+
retry policy classifies it as transient.
123+
"""
124+
if overlap_attempt_count >= _OVERLAP_RETRY_MAX_ATTEMPTS:
125+
logger.error(
126+
"Full-load routing-map fetch for collection '%s' detected "
127+
"overlapping partition key ranges on every one of %d attempt(s). "
128+
"Surfacing as transient HTTP 503 so the caller's retry policy "
129+
"can take over.",
130+
collection_link,
131+
overlap_attempt_count,
132+
)
133+
raise CosmosHttpResponseError(
134+
status_code=http_constants.StatusCodes.SERVICE_UNAVAILABLE,
135+
message=(
136+
"Failed to build routing map for collection '{}': "
137+
"overlapping partition key ranges persisted across {} "
138+
"full-load attempt(s). Surfaced as a retryable transient "
139+
"error so the upstream retry policy can take over, rather "
140+
"than allowing the underlying ValueError to escape as a "
141+
"fatal crash."
142+
).format(collection_link, overlap_attempt_count),
143+
)
144+
145+
deterministic_backoff = (
146+
_OVERLAP_RETRY_INITIAL_BACKOFF_SECONDS * (2 ** (overlap_attempt_count - 1))
147+
)
148+
jittered_backoff = _jittered_backoff(deterministic_backoff)
149+
logger.warning(
150+
"Full-load routing-map fetch for collection '%s' detected overlapping "
151+
"partition key ranges (attempt %d/%d). Sleeping %.2fs (jittered from "
152+
"upper bound %.2fs) and retrying.",
153+
collection_link,
154+
overlap_attempt_count,
155+
_OVERLAP_RETRY_MAX_ATTEMPTS,
156+
jittered_backoff,
157+
deterministic_backoff,
158+
)
159+
return jittered_backoff
160+
47161

48162
def is_cache_unchanged_since_previous(
49163
collection_routing_map_by_item: Dict[str, CollectionRoutingMap],
@@ -257,7 +371,32 @@ def process_fetched_ranges(
257371

258372
unresolved = next_unresolved
259373

260-
result = previous_routing_map.try_combine(range_tuples, effective_etag)
374+
try:
375+
result = previous_routing_map.try_combine(range_tuples, effective_etag)
376+
except ValueError as overlap_error:
377+
# ``try_combine`` validates the merged map via
378+
# ``CollectionRoutingMap.is_complete_set_of_range`` and raises
379+
# ``ValueError("Ranges overlap: ...")`` if the merge produces a
380+
# self-contradictory tiling. This can happen during the incremental
381+
# path when the delta contains a range whose key span overlaps an
382+
# existing cached range without either side declaring the other a
383+
# parent.
384+
#
385+
# We must NOT let this ``ValueError`` escape: the cache layer above
386+
# treats a ``None`` routing map as "no ranges" and would convert
387+
# the bare exception into a silent empty-result return at
388+
# ``get_overlapping_ranges``. Convert to ``_IncrementalMergeFailed``
389+
# so the caller's existing retry loop retries the incremental fetch
390+
# once and then falls back to the full-load path, which has its own
391+
# ``_OverlapDetected`` handler with retry+backoff and surfaces a
392+
# transient HTTP 503 if the inconsistency persists.
393+
logger.warning(
394+
"Incremental merge for collection '%s' produced overlapping ranges: %s. "
395+
"Converting to _IncrementalMergeFailed so the caller retries / "
396+
"falls back to a full refresh.",
397+
collection_link, str(overlap_error),
398+
)
399+
raise _IncrementalMergeFailed() from overlap_error
261400
if not result:
262401
logger.warning(
263402
"Incremental merge resulted in incomplete routing map for "

sdk/cosmos/azure-cosmos/azure/cosmos/_routing/aio/routing_map_provider.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
determine_refresh_action,
3939
get_smart_overlapping_ranges,
4040
_IncrementalMergeFailed,
41+
_OverlapDetected,
42+
_OVERLAP_RETRY_MAX_ATTEMPTS, # noqa: F401 # re-exported for tests
43+
_handle_overlap_retry_decision,
4144
)
4245

4346

@@ -103,6 +106,10 @@
103106
# Number of extra incremental attempts after an incomplete incremental merge
104107
# before falling back to a full routing-map refresh.
105108
_INCOMPLETE_ROUTING_MAP_MAX_RETRIES = 1
109+
110+
# Overlap-retry budget and backoff live in ``_routing_map_provider_common`` so
111+
# the sync and async providers cannot drift on them. ``_OVERLAP_RETRY_MAX_ATTEMPTS``
112+
# is re-exported through this module for test imports.
106113
class PartitionKeyRangeCache(object):
107114
"""
108115
PartitionKeyRangeCache provides list of effective partition key ranges for a
@@ -343,6 +350,7 @@ async def _fetch_routing_map(
343350
"""
344351
current_previous_map = previous_routing_map
345352
incomplete_attempt_count = 0
353+
overlap_attempt_count = 0
346354

347355
while True:
348356
request_kwargs = dict(kwargs)
@@ -398,6 +406,24 @@ async def _fetch_routing_map(
398406
continue
399407

400408
raise
409+
except _OverlapDetected:
410+
# The full-load builder reported overlapping ranges. Apply
411+
# the retry-on-overlap policy: ``_handle_overlap_retry_decision``
412+
# either returns a backoff to sleep or raises ``CosmosHttpResponseError``
413+
# (503) when the attempt budget is exhausted. Reset
414+
# ``current_previous_map`` to ``None`` so the next iteration
415+
# runs the full-load path regardless of which path tripped
416+
# the overlap — we do not want to keep retrying an incremental
417+
# fetch against the same inconsistent base.
418+
overlap_attempt_count += 1
419+
backoff = _handle_overlap_retry_decision(
420+
overlap_attempt_count=overlap_attempt_count,
421+
collection_link=collection_link,
422+
logger=logger,
423+
)
424+
await asyncio.sleep(backoff)
425+
current_previous_map = None
426+
continue
401427

402428
async def get_range_by_partition_key_range_id(
403429
self,

sdk/cosmos/azure-cosmos/azure/cosmos/_routing/collection_routing_map.py

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,30 @@
2929
from azure.cosmos._routing import routing_range
3030
from azure.cosmos._routing.routing_range import PartitionKeyRange, PKRange
3131

32+
33+
class _OverlapDetected(Exception):
34+
"""Sentinel raised by :func:`_build_routing_map_from_ranges` when the
35+
``/pkranges`` response contains overlapping ranges that the SDK cannot
36+
reconcile from a single snapshot.
37+
38+
Distinct from a plain ``ValueError`` so the caller can identify the
39+
overlap case explicitly and apply the retry-on-overlap policy instead
40+
of treating it as a fatal programmer error. Overlap on this path is
41+
expected to be transient (a paginated ``/pkranges`` response that is
42+
not snapshot-isolated across gateway nodes), so a short retry budget
43+
with backoff is sufficient.
44+
45+
The caller (``_fetch_routing_map`` on both sync and async cache
46+
providers) catches this sentinel, sleeps briefly, and retries the
47+
fetch. After a small number of failed retries it surfaces a typed
48+
transient HTTP error so the upstream retry policy can take over. The
49+
sentinel is intentionally not allowed to escape to the query layer
50+
as a bare ``ValueError`` — that path would otherwise convert into a
51+
silent empty-result return at ``get_overlapping_ranges`` (which
52+
treats a ``None`` routing map as "no ranges"), masking the failure
53+
as a correctness bug.
54+
"""
55+
3256
# pylint: disable=line-too-long
3357
class CollectionRoutingMap(object):
3458
"""Stores partition key ranges in an efficient way with some additional
@@ -196,7 +220,23 @@ def is_complete_set_of_range(ordered_partition_key_range_list):
196220

197221
if not isComplete:
198222
if previousRange[PartitionKeyRange.MaxExclusive] > currentRange[PartitionKeyRange.MinInclusive]:
199-
raise ValueError("Ranges overlap")
223+
# Include the offending pair in the message so whoever
224+
# investigates the next occurrence has actionable
225+
# diagnostics without having to reproduce the failure
226+
# under a debugger. Keep the literal substring
227+
# "Ranges overlap" for backwards compatibility with
228+
# any caller that pattern-matches on it.
229+
raise ValueError(
230+
"Ranges overlap: previous range id={!r} ({!r} -> {!r}) "
231+
"overlaps current range id={!r} ({!r} -> {!r})".format(
232+
previousRange.get(PartitionKeyRange.Id),
233+
previousRange[PartitionKeyRange.MinInclusive],
234+
previousRange[PartitionKeyRange.MaxExclusive],
235+
currentRange.get(PartitionKeyRange.Id),
236+
currentRange[PartitionKeyRange.MinInclusive],
237+
currentRange[PartitionKeyRange.MaxExclusive],
238+
)
239+
)
200240
break
201241

202242
return isComplete
@@ -270,7 +310,10 @@ def _build_routing_map_from_ranges(
270310
271311
Filters out parent (gone) ranges and validates that the remaining ranges
272312
form a complete, gap-free partition key space. Returns None if the ranges
273-
are incomplete.
313+
are incomplete (gap), and raises ``_OverlapDetected`` if the ranges
314+
overlap — the caller is expected to retry the fetch on the overlap case,
315+
since overlap on the full-load path is treated as a transient gateway
316+
inconsistency rather than a permanent input error.
274317
275318
This is shared between the sync and async PartitionKeyRangeCache to avoid
276319
code duplication — the logic is purely synchronous.
@@ -280,9 +323,27 @@ def _build_routing_map_from_ranges(
280323
:param str new_etag: The ETag from the change feed response.
281324
:param str collection_link: The collection link, used for log messages.
282325
:param logging.Logger _logger: Logger instance for error reporting.
283-
:return: A complete CollectionRoutingMap, or None if the ranges are incomplete.
326+
:return: A complete CollectionRoutingMap, or None if the ranges are incomplete (gap).
284327
:rtype: Optional[CollectionRoutingMap]
328+
:raises _OverlapDetected: If the ranges contain an overlap that could not
329+
be resolved from this single snapshot. The caller should retry the
330+
fetch; see :class:`_OverlapDetected` for the rationale.
285331
"""
332+
# Dedup the input by id BEFORE parent filtering and validation. At high
333+
# partition counts the /pkranges response is paginated, and pagination
334+
# is not snapshot-isolated across gateway nodes — consecutive pages can
335+
# legitimately return the same range id when the page boundary falls
336+
# between two nodes with one-tick-out-of-sync caches. Without this dedup
337+
# the duplicate would survive into the sortedRanges list inside
338+
# CompleteRoutingMap and trip the overlap check on two identical entries.
339+
# Last-write-wins is safe: duplicates describe the same logical range
340+
# and any later occurrence carries the same id, min/max, and (when
341+
# present) the more-complete metadata such as ``parents``.
342+
deduped_by_id: dict = {}
343+
for r in ranges:
344+
deduped_by_id[r[PartitionKeyRange.Id]] = r
345+
ranges = list(deduped_by_id.values())
346+
286347
gone_range_ids = set()
287348
for r in ranges:
288349
if PartitionKeyRange.Parents in r and r[PartitionKeyRange.Parents]:
@@ -294,11 +355,27 @@ def _build_routing_map_from_ranges(
294355
]
295356
range_tuples = [(r, True) for r in filtered_ranges]
296357

297-
routing_map = CollectionRoutingMap.CompleteRoutingMap(
298-
range_tuples,
299-
collection_id,
300-
new_etag
301-
)
358+
try:
359+
routing_map = CollectionRoutingMap.CompleteRoutingMap(
360+
range_tuples,
361+
collection_id,
362+
new_etag
363+
)
364+
except ValueError as overlap_error:
365+
# ``is_complete_set_of_range`` raises ``ValueError("Ranges overlap: ...")``
366+
# when the post-filter range list still contains an overlap. Convert
367+
# it to ``_OverlapDetected`` so the caller can apply the retry-on-
368+
# overlap policy. The bare ``ValueError`` must NOT escape to the
369+
# cache layer: that path converts into a silent empty-result return
370+
# at ``get_overlapping_ranges`` (which treats ``None`` from the
371+
# cache as "no ranges"), masking the failure as a correctness bug.
372+
_logger.warning(
373+
"Full load of routing map for collection '%s' detected overlapping "
374+
"partition key ranges: %s. Signalling caller to retry the "
375+
"/pkranges fetch.",
376+
collection_link, str(overlap_error),
377+
)
378+
raise _OverlapDetected() from overlap_error
302379

303380
if not routing_map:
304381
_logger.error(

0 commit comments

Comments
 (0)