Skip to content

Commit 05b716d

Browse files
authored
[Cosmos] Hash v1 Key Error Hotfix (#41639)
* Fixes issue related to hash v1 containers not always having version number in partition key definition. Some legacy hash v1 containers don't always provide version number for partition key defintion which results in some key error exceptions. This fixes that by allowing a dictionary or PartitionKey instanced to be passed into the constructor of PartitionKeys, when done that way if no version number is available, we default the value to 1. * Update CHANGELOG.md * Remove extra overload constructor, add internal method for creating pk from defintion * Update partition_key.py * update tests * Update _cosmos_client_connection.py * Update _cosmos_client_connection_async.py * update pylint errors * update version for hotfix release * Update CHANGELOG.md * update versioning for hotfix
1 parent 15b975b commit 05b716d

10 files changed

+194
-45
lines changed

sdk/cosmos/azure-cosmos/CHANGELOG.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
## Release History
22

3-
### 4.13.0b2 (Unreleased)
4-
5-
#### Features Added
6-
7-
#### Breaking Changes
3+
### 4.13.0b2 (2025-06-18)
84

95
#### Bugs Fixed
10-
11-
#### Other Changes
6+
- Fixed issue where key error would occur when getting properties from a container using legacy hash v1 as they may not always contain version property in the partition key definition. See [PR 41639](https://github.com/Azure/azure-sdk-for-python/pull/41639)
127

138
### 4.13.0b1 (2025-06-05)
149

sdk/cosmos/azure-cosmos/azure/cosmos/_cosmos_client_connection.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@
7373
_Empty,
7474
PartitionKey,
7575
_return_undefined_or_empty_partition_key,
76-
NonePartitionKeyValue
76+
NonePartitionKeyValue,
77+
_get_partition_key_from_partition_key_definition
7778
)
7879

7980
PartitionKeyType = Union[str, int, float, bool, Sequence[Union[str, int, float, bool, None]], Type[NonePartitionKeyValue]] # pylint: disable=line-too-long
@@ -3166,11 +3167,12 @@ def __GetBodiesFromQueryResult(result: Dict[str, Any]) -> List[Dict[str, Any]]:
31663167
# here get the over lapping ranges
31673168
# Default to empty Dictionary, but unlikely to be empty as we first check if we have it in kwargs
31683169
pk_properties: Union[PartitionKey, Dict] = kwargs.pop("partitionKeyDefinition", {})
3169-
partition_key_definition = PartitionKey(
3170-
path=pk_properties["paths"],
3171-
kind=pk_properties["kind"],
3172-
version=pk_properties["version"])
3173-
partition_key_value = pk_properties["partition_key"]
3170+
partition_key_definition = _get_partition_key_from_partition_key_definition(pk_properties)
3171+
partition_key_value: Sequence[
3172+
Union[None, bool, int, float, str, _Undefined, Type[NonePartitionKeyValue]]] = cast(
3173+
Sequence[Union[None, bool, int, float, str, _Undefined, Type[NonePartitionKeyValue]]],
3174+
pk_properties.get("partition_key")
3175+
)
31743176
feedrangeEPK = partition_key_definition._get_epk_range_for_prefix_partition_key(
31753177
partition_key_value
31763178
) # cspell:disable-line

sdk/cosmos/azure-cosmos/azure/cosmos/_global_partition_endpoint_manager_circuit_breaker.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"""
2424
from typing import TYPE_CHECKING, Optional
2525

26-
from azure.cosmos.partition_key import PartitionKey
26+
from azure.cosmos.partition_key import _get_partition_key_from_partition_key_definition
2727
from azure.cosmos._global_partition_endpoint_manager_circuit_breaker_core import \
2828
_GlobalPartitionEndpointManagerForCircuitBreakerCore
2929

@@ -62,9 +62,7 @@ def create_pk_range_wrapper(self, request: RequestObject) -> Optional[PartitionK
6262
# get relevant information from container cache to get the overlapping ranges
6363
container_link = properties["container_link"]
6464
partition_key_definition = properties["partitionKey"]
65-
partition_key = PartitionKey(path=partition_key_definition["paths"],
66-
kind=partition_key_definition["kind"],
67-
version=partition_key_definition["version"])
65+
partition_key = _get_partition_key_from_partition_key_definition(partition_key_definition)
6866

6967
if HttpHeaders.PartitionKey in request.headers:
7068
partition_key_value = request.headers[HttpHeaders.PartitionKey]

sdk/cosmos/azure-cosmos/azure/cosmos/aio/_container.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
NonePartitionKeyValue,
5252
_return_undefined_or_empty_partition_key,
5353
_Empty,
54-
_Undefined, PartitionKey
54+
_Undefined,
55+
_get_partition_key_from_partition_key_definition
5556
)
5657

5758
__all__ = ("ContainerProxy",)
@@ -153,10 +154,7 @@ async def _get_epk_range_for_partition_key(
153154
feed_options: Optional[Dict[str, Any]] = None) -> Range:
154155
container_properties = await self._get_properties_with_options(feed_options)
155156
partition_key_definition = container_properties["partitionKey"]
156-
partition_key = PartitionKey(
157-
path=partition_key_definition["paths"],
158-
kind=partition_key_definition["kind"],
159-
version=partition_key_definition["version"])
157+
partition_key = _get_partition_key_from_partition_key_definition(partition_key_definition)
160158

161159
return partition_key._get_epk_range_for_partition_key(partition_key_value)
162160

sdk/cosmos/azure-cosmos/azure/cosmos/aio/_cosmos_client_connection_async.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,9 @@
7171
from .. import _utils
7272
from ..partition_key import (
7373
_Undefined,
74-
PartitionKey,
7574
_return_undefined_or_empty_partition_key,
76-
NonePartitionKeyValue, _Empty
75+
NonePartitionKeyValue, _Empty,
76+
_get_partition_key_from_partition_key_definition
7777
)
7878
from ._auth_policy_async import AsyncCosmosBearerTokenCredentialPolicy
7979
from .._cosmos_http_logging_policy import CosmosHttpLoggingPolicy
@@ -2956,9 +2956,7 @@ def __GetBodiesFromQueryResult(result: Dict[str, Any]) -> List[Dict[str, Any]]:
29562956
partition_key_obj = None
29572957
if cont_prop and partition_key_value is not None:
29582958
partition_key_definition = cont_prop["partitionKey"]
2959-
partition_key_obj = PartitionKey(path=partition_key_definition["paths"],
2960-
kind=partition_key_definition["kind"],
2961-
version=partition_key_definition["version"])
2959+
partition_key_obj = _get_partition_key_from_partition_key_definition(partition_key_definition)
29622960
is_prefix_partition_query = partition_key_obj._is_prefix_partition_key(partition_key_value)
29632961

29642962
if is_prefix_partition_query and partition_key_obj:

sdk/cosmos/azure-cosmos/azure/cosmos/aio/_global_partition_endpoint_manager_circuit_breaker_async.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"""
2424
from typing import TYPE_CHECKING, Optional
2525

26-
from azure.cosmos import PartitionKey
26+
from azure.cosmos.partition_key import _get_partition_key_from_partition_key_definition
2727
from azure.cosmos._global_partition_endpoint_manager_circuit_breaker_core import \
2828
_GlobalPartitionEndpointManagerForCircuitBreakerCore
2929
from azure.cosmos._routing.routing_range import PartitionKeyRangeWrapper, Range
@@ -60,9 +60,7 @@ async def create_pk_range_wrapper(self, request: RequestObject) -> Optional[Part
6060
# get relevant information from container cache to get the overlapping ranges
6161
container_link = properties["container_link"]
6262
partition_key_definition = properties["partitionKey"]
63-
partition_key = PartitionKey(path=partition_key_definition["paths"],
64-
kind=partition_key_definition["kind"],
65-
version=partition_key_definition["version"])
63+
partition_key = _get_partition_key_from_partition_key_definition(partition_key_definition)
6664

6765
if HttpHeaders.PartitionKey in request.headers:
6866
partition_key_value = request.headers[HttpHeaders.PartitionKey]

sdk/cosmos/azure-cosmos/azure/cosmos/container.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@
5050
PartitionKey,
5151
_Empty,
5252
_Undefined,
53-
_return_undefined_or_empty_partition_key
53+
_return_undefined_or_empty_partition_key,
54+
_get_partition_key_from_partition_key_definition
5455
)
5556
from .scripts import ScriptsProxy
5657

@@ -64,10 +65,7 @@
6465

6566
def get_partition_key_from_properties(container_properties: Dict[str, Any]) -> PartitionKey:
6667
partition_key_definition = container_properties["partitionKey"]
67-
return PartitionKey(
68-
path=partition_key_definition["paths"],
69-
kind=partition_key_definition["kind"],
70-
version=partition_key_definition["version"])
68+
return _get_partition_key_from_partition_key_definition(partition_key_definition)
7169

7270
def is_prefix_partition_key(container_properties: Dict[str, Any], partition_key: PartitionKeyType) -> bool:
7371
partition_key_obj: PartitionKey = get_partition_key_from_properties(container_properties)

sdk/cosmos/azure-cosmos/azure/cosmos/partition_key.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
from io import BytesIO
2424
import binascii
2525
import struct
26-
from typing import IO, Sequence, Type, Union, overload, List, cast
26+
from typing import IO, Sequence, Type, Union, overload, List, cast, Dict, Any
27+
2728
from typing_extensions import Literal
2829

2930
from ._cosmos_integers import _UInt32, _UInt64, _UInt128
@@ -96,9 +97,31 @@ class PartitionKey(dict):
9697
See https://learn.microsoft.com/azure/cosmos-db/partitioning-overview#choose-partitionkey
9798
for information on how to choose partition keys.
9899
99-
:ivar str path: The path of the partition key
100-
:ivar str kind: What kind of partition key is being defined (default: "Hash")
101-
:ivar int version: The version of the partition key (default: 2)
100+
This constructor supports multiple overloads:
101+
102+
1. **Single Partition Key**:
103+
104+
**Parameters**:
105+
- `path` (str): The path of the partition key.
106+
- `kind` (Literal["Hash"], optional): The kind of partition key. Defaults to "Hash".
107+
- `version` (int, optional): The version of the partition key. Defaults to 2.
108+
109+
**Example**:
110+
>>> pk = PartitionKey(path="/id")
111+
112+
2. **Hierarchical Partition Key**:
113+
114+
**Parameters**:
115+
- `path` (List[str]): A list of paths representing the partition key, supports up to three hierarchical levels.
116+
- `kind` (Literal["MultiHash"], optional): The kind of partition key. Defaults to "MultiHash".
117+
- `version` (int, optional): The version of the partition key. Defaults to 2.
118+
119+
**Example**:
120+
>>> pk = PartitionKey(path=["/id", "/category"], kind="MultiHash")
121+
122+
:ivar str path: The path(s) of the partition key.
123+
:ivar str kind: The kind of partition key ("Hash" or "MultiHash") (default: "Hash").
124+
:ivar int version: The version of the partition key (default: 2).
102125
"""
103126

104127
@overload
@@ -472,3 +495,19 @@ def _write_for_binary_encoding(
472495

473496
elif isinstance(value, _Undefined):
474497
binary_writer.write(bytes([_PartitionKeyComponentType.Undefined]))
498+
499+
500+
def _get_partition_key_from_partition_key_definition(
501+
partition_key_definition: Union[Dict[str, Any], "PartitionKey"]
502+
) -> "PartitionKey":
503+
"""Internal method to create a PartitionKey instance from a dictionary or PartitionKey object.
504+
505+
:param partition_key_definition: A dictionary or PartitionKey object containing the partition key definition.
506+
:type partition_key_definition: Union[Dict[str, Any], PartitionKey]
507+
:return: A PartitionKey instance created from the provided definition.
508+
:rtype: PartitionKey
509+
"""
510+
path = partition_key_definition.get("paths", "")
511+
kind = partition_key_definition.get("kind", "Hash")
512+
version: int = partition_key_definition.get("version", 1) # Default to version 1 if not provided
513+
return PartitionKey(path=path, kind=kind, version=version)

sdk/cosmos/azure-cosmos/tests/test_changefeed_partition_key_variation.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@
77

88
import azure.cosmos.cosmos_client as cosmos_client
99
import test_config
10-
from azure.cosmos.partition_key import PartitionKey
11-
10+
from azure.cosmos.partition_key import PartitionKey, _get_partition_key_from_partition_key_definition
11+
from azure.cosmos.container import get_epk_range_for_partition_key
1212

1313
@pytest.mark.cosmosEmulator
14-
@pytest.mark.cosmosQuery
1514
class TestChangeFeedPKVariation(unittest.TestCase):
1615
"""Test change feed with different partition key variations."""
1716

@@ -224,5 +223,67 @@ def test_multiple_physical_partitions(self):
224223
self.validate_changefeed_hpk(container_hpk)
225224
self.db.delete_container(container_hpk.id)
226225

226+
def test_partition_key_version_1_properties(self):
227+
"""Test container with version 1 partition key definition and validate properties."""
228+
container_id = f"container_test_pk_version_1_properties_{uuid.uuid4()}"
229+
pk = PartitionKey(path="/pk", kind="Hash", version=1)
230+
container = self.db.create_container(id=container_id, partition_key=pk)
231+
original_get_properties = container._get_properties
232+
233+
# Simulate the version key not being in the definition
234+
235+
def _get_properties_override():
236+
properties = original_get_properties()
237+
partition_key = properties["partitionKey"]
238+
partition_key.pop("version", None) # Remove version key for validation
239+
return {**properties, "partitionKey": partition_key}
240+
241+
container._get_properties = _get_properties_override
242+
243+
try:
244+
# Get container properties and validate partition key definition
245+
container_properties = container._get_properties()
246+
partition_key_definition = container_properties["partitionKey"]
247+
# Ensure the version key is not included in the definition
248+
assert "version" not in partition_key_definition, ("Version key should not be included "
249+
"in the partition key definition.")
250+
251+
# Create a PartitionKey instance from the definition and validate
252+
partition_key_instance = _get_partition_key_from_partition_key_definition(partition_key_definition)
253+
assert partition_key_instance.kind == "Hash", "Partition key kind mismatch."
254+
assert partition_key_instance.version == 1, "Partition key version mismatch."
255+
256+
# Upsert items and validate _get_epk_range_for_partition_key
257+
items = [
258+
{"id": "1", "pk": "value1"},
259+
{"id": "2", "pk": "value2"},
260+
{"id": "3", "pk": "value3"}
261+
]
262+
self.insert_items(container, items)
263+
264+
for item in items:
265+
try:
266+
epk_range = get_epk_range_for_partition_key(container_properties, item["pk"])
267+
assert epk_range is not None, f"EPK range should not be None for partition key {item['pk']}."
268+
except Exception as e:
269+
assert False, f"Failed to get EPK range for partition key {item['pk']}: {str(e)}"
270+
# Query the change feed and validate the results
271+
change_feed = container.query_items_change_feed(is_start_from_beginning=True)
272+
change_feed_items = [item for item in change_feed]
273+
274+
# Ensure the same items are retrieved
275+
assert len(change_feed_items) == len(items), (
276+
f"Mismatch in document count: Change feed returned {len(change_feed_items)} items, "
277+
f"while {len(items)} items were created."
278+
)
279+
for index, item in enumerate(items):
280+
assert item['id'] == change_feed_items[index]['id'], f"Item {item} not found in change feed results."
281+
282+
finally:
283+
# Clean up the container
284+
container._get_properties = original_get_properties
285+
self.db.delete_container(container.id)
286+
287+
227288
if __name__ == "__main__":
228289
unittest.main()

sdk/cosmos/azure-cosmos/tests/test_changefeed_partition_key_variation_async.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from azure.cosmos.aio import CosmosClient
55
import test_config
6-
from azure.cosmos.partition_key import PartitionKey
6+
from azure.cosmos.partition_key import PartitionKey, _get_partition_key_from_partition_key_definition
77

88
@pytest.mark.cosmosEmulator
99
@pytest.mark.asyncio
@@ -222,5 +222,67 @@ async def test_multiple_physical_partitions_async(self):
222222
await self.validate_changefeed_hpk(container_hpk)
223223
await self.db.delete_container(container_hpk.id)
224224

225+
async def test_partition_key_version_1_properties_async(self):
226+
"""Test container with version 1 partition key definition and validate properties (async)."""
227+
container_id = f"container_test_pk_version_1_properties_{uuid.uuid4()}"
228+
pk = PartitionKey(path="/pk", kind="Hash", version=1)
229+
container = await self.db.create_container(id=container_id, partition_key=pk)
230+
original_get_properties = container._get_properties
231+
232+
# Simulate the version key not being in the definition
233+
234+
async def _get_properties_override():
235+
properties = await original_get_properties()
236+
partition_key = properties["partitionKey"]
237+
partition_key.pop("version", None) # Remove version key for validation
238+
return {**properties, "partitionKey": partition_key}
239+
240+
container._get_properties = _get_properties_override
241+
242+
try:
243+
# Get container properties and validate partition key definition
244+
container_properties = await container._get_properties()
245+
partition_key_definition = container_properties["partitionKey"]
246+
# Ensure the version key is not included in the definition
247+
assert "version" not in partition_key_definition, ("Version key should not be included "
248+
"in the partition key definition.")
249+
250+
# Create a PartitionKey instance from the definition and validate
251+
partition_key_instance = _get_partition_key_from_partition_key_definition(partition_key_definition)
252+
assert partition_key_instance.kind == "Hash", "Partition key kind mismatch."
253+
assert partition_key_instance.version == 1, "Partition key version mismatch."
254+
255+
# Upsert items and validate _get_epk_range_for_partition_key
256+
items = [
257+
{"id": "1", "pk": "value1"},
258+
{"id": "2", "pk": "value2"},
259+
{"id": "3", "pk": "value3"}
260+
]
261+
await self.insert_items(container, items)
262+
263+
for item in items:
264+
try:
265+
epk_range = container._get_epk_range_for_partition_key(container_properties, item["pk"])
266+
assert epk_range is not None, f"EPK range should not be None for partition key {item['pk']}."
267+
except Exception as e:
268+
assert False, f"Failed to get EPK range for partition key {item['pk']}: {str(e)}"
269+
# Query the change feed and validate the results
270+
change_feed = container.query_items_change_feed(is_start_from_beginning=True)
271+
change_feed_items = [item async for item in change_feed]
272+
273+
# Ensure the same items are retrieved
274+
assert len(change_feed_items) == len(items), (
275+
f"Mismatch in document count: Change feed returned {len(change_feed_items)} items, "
276+
f"while {len(items)} items were created."
277+
)
278+
for index, item in enumerate(items):
279+
assert item['id'] == change_feed_items[index]['id'], f"Item {item} not found in change feed results."
280+
281+
finally:
282+
# Clean up the container
283+
container._get_properties = original_get_properties
284+
await self.db.delete_container(container.id)
285+
286+
225287
if __name__ == '__main__':
226-
unittest.main()
288+
unittest.main()

0 commit comments

Comments
 (0)