Skip to content

Commit 3595cb1

Browse files
Analytical_TTL Replace Functionality (Azure#25839)
* Update database.py * Update _database.py * Update database.py * Create test_analytical_ttl.py * Create test_analytical_ttl_asnyc.py * Update test_analytical_ttl_async.py * Update test_analytical_ttl.py * Update database.py * Update _database.py * Update README.md * Update CHANGELOG.md * Update sdk/cosmos/azure-cosmos/CHANGELOG.md Co-authored-by: Simon Moreno <30335873+simorenoh@users.noreply.github.com> Co-authored-by: Simon Moreno <30335873+simorenoh@users.noreply.github.com>
1 parent 4b52b12 commit 3595cb1

File tree

6 files changed

+607
-4
lines changed

6 files changed

+607
-4
lines changed

sdk/cosmos/azure-cosmos/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
#### Features Added
66
- GA release of integrated cache functionality. For more information on integrated cache please see [Azure Cosmos DB integrated cache](https://docs.microsoft.com/azure/cosmos-db/integrated-cache).
7+
- Added ability to replace analytical ttl on containers. For more information on analytical ttl please see [Azure Cosmos DB analytical store](https://docs.microsoft.com/azure/cosmos-db/analytical-store-introduction).
78

89
#### Bugs Fixed
910
- Fixed parsing of args for overloaded `container.read()` method.

sdk/cosmos/azure-cosmos/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,6 @@ Currently the features below are **not supported**. For alternatives options, ch
164164
* Create Geospatial Index
165165
* Provision Autoscale DBs or containers
166166
* Update Autoscale throughput
167-
* Update analytical store ttl (time to live)
168167
* Get the connection string
169168
* Get the minimum RU/s of a container
170169

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,9 @@ async def replace_container(
437437
:paramtype response_hook: Callable[[Dict[str, str], Dict[str, Any]], None]
438438
:raises ~azure.cosmos.exceptions.CosmosHttpResponseError: Raised if the container couldn't be replaced.
439439
This includes if the container with given id does not exist.
440+
:keyword int analytical_storage_ttl: Analytical store time to live (TTL) for items in the container. A value of
441+
None leaves analytical storage off and a value of -1 turns analytical storage on with no TTL. Please
442+
note that analytical storage can only be enabled on Synapse Link enabled accounts.
440443
:returns: A `ContainerProxy` instance representing the container after replace completed.
441444
:rtype: ~azure.cosmos.aio.ContainerProxy
442445
@@ -458,6 +461,7 @@ async def replace_container(
458461
indexing_policy = kwargs.pop('indexing_policy', None)
459462
default_ttl = kwargs.pop('default_ttl', None)
460463
conflict_resolution_policy = kwargs.pop('conflict_resolution_policy', None)
464+
analytical_storage_ttl = kwargs.pop("analytical_storage_ttl", None)
461465
parameters = {
462466
key: value
463467
for key, value in {
@@ -466,6 +470,7 @@ async def replace_container(
466470
"indexingPolicy": indexing_policy,
467471
"defaultTtl": default_ttl,
468472
"conflictResolutionPolicy": conflict_resolution_policy,
473+
"analyticalStorageTtl": analytical_storage_ttl,
469474
}.items()
470475
if value is not None
471476
}

sdk/cosmos/azure-cosmos/azure/cosmos/database.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def create_container(
178178
has changed, and act according to the condition specified by the `match_condition` parameter.
179179
:keyword ~azure.core.MatchConditions match_condition: The match condition to use upon the etag.
180180
:keyword Callable response_hook: A callable invoked with the response metadata.
181-
:keyword analytical_storage_ttl: Analytical store time to live (TTL) for items in the container. A value of
181+
:keyword int analytical_storage_ttl: Analytical store time to live (TTL) for items in the container. A value of
182182
None leaves analytical storage off and a value of -1 turns analytical storage on with no TTL. Please
183183
note that analytical storage can only be enabled on Synapse Link enabled accounts.
184184
:returns: A `ContainerProxy` instance representing the new container.
@@ -278,7 +278,7 @@ def create_container_if_not_exists(
278278
has changed, and act according to the condition specified by the `match_condition` parameter.
279279
:keyword ~azure.core.MatchConditions match_condition: The match condition to use upon the etag.
280280
:keyword Callable response_hook: A callable invoked with the response metadata.
281-
:keyword analytical_storage_ttl: Analytical store time to live (TTL) for items in the container. A value of
281+
:keyword int analytical_storage_ttl: Analytical store time to live (TTL) for items in the container. A value of
282282
None leaves analytical storage off and a value of -1 turns analytical storage on with no TTL. Please
283283
note that analytical storage can only be enabled on Synapse Link enabled accounts.
284284
:returns: A `ContainerProxy` instance representing the container.
@@ -304,7 +304,7 @@ def create_container_if_not_exists(
304304
offer_throughput=offer_throughput,
305305
unique_key_policy=unique_key_policy,
306306
conflict_resolution_policy=conflict_resolution_policy,
307-
analytical_storage_ttl=analytical_storage_ttl
307+
analytical_storage_ttl=analytical_storage_ttl,
308308
)
309309

310310
@distributed_trace
@@ -487,6 +487,9 @@ def replace_container(
487487
:keyword Callable response_hook: A callable invoked with the response metadata.
488488
:raises ~azure.cosmos.exceptions.CosmosHttpResponseError: Raised if the container couldn't be replaced.
489489
This includes if the container with given id does not exist.
490+
:keyword int analytical_storage_ttl: Analytical store time to live (TTL) for items in the container. A value of
491+
None leaves analytical storage off and a value of -1 turns analytical storage on with no TTL. Please
492+
note that analytical storage can only be enabled on Synapse Link enabled accounts.
490493
:returns: A `ContainerProxy` instance representing the container after replace completed.
491494
:rtype: ~azure.cosmos.ContainerProxy
492495
@@ -502,6 +505,7 @@ def replace_container(
502505
"""
503506
request_options = build_options(kwargs)
504507
response_hook = kwargs.pop('response_hook', None)
508+
analytical_storage_ttl = kwargs.pop("analytical_storage_ttl", None)
505509
if populate_query_metrics is not None:
506510
warnings.warn(
507511
"the populate_query_metrics flag does not apply to this method and will be removed in the future",
@@ -519,6 +523,7 @@ def replace_container(
519523
"indexingPolicy": indexing_policy,
520524
"defaultTtl": default_ttl,
521525
"conflictResolutionPolicy": conflict_resolution_policy,
526+
"analyticalStorageTtl": analytical_storage_ttl,
522527
}.items()
523528
if value is not None
524529
}
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
# The MIT License (MIT)
2+
# Copyright (c) 2022 Microsoft Corporation
3+
4+
# Permission is hereby granted, free of charge, to any person obtaining a copy
5+
# of this software and associated documentation files (the "Software"), to deal
6+
# in the Software without restriction, including without limitation the rights
7+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
# copies of the Software, and to permit persons to whom the Software is
9+
# furnished to do so, subject to the following conditions:
10+
11+
# The above copyright notice and this permission notice shall be included in all
12+
# copies or substantial portions of the Software.
13+
14+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
# SOFTWARE.
21+
22+
import unittest
23+
import uuid
24+
import time
25+
import pytest
26+
27+
import azure.cosmos.cosmos_client as cosmos_client
28+
import azure.cosmos.exceptions as exceptions
29+
from azure.cosmos.http_constants import StatusCodes
30+
import test_config
31+
from azure.cosmos.partition_key import PartitionKey
32+
33+
pytestmark = pytest.mark.cosmosEmulator
34+
35+
36+
# IMPORTANT NOTES:
37+
38+
# Most test cases in this file create collections in your Azure Cosmos account.
39+
# Collections are billing entities. By running these test cases, you may incur monetary costs on your account.
40+
41+
# To Run the test, replace the two member fields (masterKey and host) with values
42+
# associated with your Azure Cosmos account.
43+
44+
# In order for the analytical ttl tests to work you have to enable Azure Synapse Link for your Azure Cosmos DB
45+
# account.
46+
47+
48+
@pytest.mark.usefixtures("teardown")
49+
class Test_ttl_tests(unittest.TestCase):
50+
"""TTL Unit Tests.
51+
"""
52+
53+
host = test_config._test_config.host
54+
masterKey = test_config._test_config.masterKey
55+
connectionPolicy = test_config._test_config.connectionPolicy
56+
57+
def __AssertHTTPFailureWithStatus(self, status_code, func, *args, **kwargs):
58+
"""Assert HTTP failure with status.
59+
60+
:Parameters:
61+
- `status_code`: int
62+
- `func`: function
63+
"""
64+
try:
65+
func(*args, **kwargs)
66+
self.assertFalse(True, 'function should fail.')
67+
except exceptions.CosmosHttpResponseError as inst:
68+
self.assertEqual(inst.status_code, status_code)
69+
70+
@classmethod
71+
def setUpClass(cls):
72+
if (cls.masterKey == '[YOUR_KEY_HERE]' or
73+
cls.host == '[YOUR_ENDPOINT_HERE]'):
74+
raise Exception(
75+
"You must specify your Azure Cosmos account values for "
76+
"'masterKey' and 'host' at the top of this class to run the "
77+
"tests.")
78+
cls.client = cosmos_client.CosmosClient(cls.host, cls.masterKey, consistency_level="Session",
79+
connection_policy=cls.connectionPolicy)
80+
cls.created_db = cls.client.create_database_if_not_exists("TTL_tests_database" + str(uuid.uuid4()))
81+
82+
def test_collection_ttl_replace_values(self):
83+
created_collection = self.created_db.create_container_if_not_exists(
84+
id='test_ttl_values1' + str(uuid.uuid4()),
85+
partition_key=PartitionKey(path='/id'),
86+
analytical_storage_ttl=-1)
87+
self.created_db.replace_container(created_collection, partition_key=PartitionKey(path='/id'),
88+
analytical_storage_ttl=1000)
89+
created_collection_properties = created_collection.read()
90+
self.assertEqual(created_collection_properties['analyticalStorageTtl'], 1000)
91+
92+
self.created_db.delete_container(container=created_collection)
93+
94+
def test_collection_and_document_ttl_values(self):
95+
ttl = 10
96+
created_collection = self.created_db.create_container_if_not_exists(
97+
id='test_ttl_values1' + str(uuid.uuid4()),
98+
partition_key=PartitionKey(path='/id'),
99+
analytical_storage_ttl=ttl)
100+
created_collection_properties = created_collection.read()
101+
self.assertEqual(created_collection_properties['analyticalStorageTtl'], ttl)
102+
103+
collection_id = 'test_ttl_values4' + str(uuid.uuid4())
104+
ttl = -10
105+
106+
# -10 is an unsupported value for ttl. Valid values are -1 or a non-zero positive 32-bit integer value
107+
self.__AssertHTTPFailureWithStatus(
108+
StatusCodes.BAD_REQUEST,
109+
self.created_db.create_container,
110+
collection_id,
111+
PartitionKey(path='/id'),
112+
None,
113+
ttl)
114+
115+
document_definition = {'id': 'doc1' + str(uuid.uuid4()),
116+
'name': 'sample document',
117+
'key': 'value',
118+
'ttl': 0}
119+
120+
# 0 is an unsupported value for ttl. Valid values are -1 or a non-zero positive 32-bit integer value
121+
self.__AssertHTTPFailureWithStatus(
122+
StatusCodes.BAD_REQUEST,
123+
created_collection.create_item,
124+
document_definition)
125+
126+
document_definition['id'] = 'doc2' + str(uuid.uuid4())
127+
document_definition['ttl'] = None
128+
129+
# None is an unsupported value for ttl. Valid values are -1 or a non-zero positive 32-bit integer value
130+
self.__AssertHTTPFailureWithStatus(
131+
StatusCodes.BAD_REQUEST,
132+
created_collection.create_item,
133+
document_definition)
134+
135+
self.created_db.delete_container(container=created_collection)
136+
137+
def test_document_ttl_with_positive_analyticalStorageTtl(self):
138+
created_collection = self.created_db.create_container_if_not_exists(
139+
id='test_ttl_values3' + str(uuid.uuid4()),
140+
partition_key=PartitionKey(path='/id'),
141+
analytical_storage_ttl=5)
142+
143+
document_definition = {'id': 'doc1' + str(uuid.uuid4()),
144+
'name': 'sample document',
145+
'key': 'value'}
146+
147+
created_document = created_collection.create_item(body=document_definition)
148+
149+
document_definition['id'] = 'doc2' + str(uuid.uuid4())
150+
document_definition['ttl'] = -1
151+
created_document = created_collection.create_item(body=document_definition)
152+
153+
time.sleep(5)
154+
155+
# the created document should NOT be gone as its ttl value is set to -1(never expire) which overrides the
156+
# collection's analyticalStorageTtl value
157+
read_document = created_collection.read_item(item=document_definition['id'],
158+
partition_key=document_definition['id'])
159+
self.assertEqual(created_document['id'], read_document['id'])
160+
161+
document_definition['id'] = 'doc3' + str(uuid.uuid4())
162+
document_definition['ttl'] = 2
163+
created_document = created_collection.create_item(body=document_definition)
164+
165+
document_definition['id'] = 'doc4' + str(uuid.uuid4())
166+
document_definition['ttl'] = 8
167+
created_document = created_collection.create_item(body=document_definition)
168+
169+
time.sleep(6)
170+
171+
# the created document should NOT be gone as its ttl value is set to 8 which overrides the collection's
172+
# analyticalStorageTtl value(5)
173+
read_document = created_collection.read_item(item=created_document['id'], partition_key=created_document['id'])
174+
self.assertEqual(created_document['id'], read_document['id'])
175+
176+
time.sleep(4)
177+
178+
self.created_db.delete_container(container=created_collection)
179+
180+
def test_document_ttl_with_negative_one_analyticalStorageTtl(self):
181+
created_collection = self.created_db.create_container_if_not_exists(
182+
id='test_ttl_values4' + str(uuid.uuid4()),
183+
partition_key=PartitionKey(path='/id'),
184+
analytical_storage_ttl=-1)
185+
186+
document_definition = {'id': 'doc1' + str(uuid.uuid4()),
187+
'name': 'sample document',
188+
'key': 'value'}
189+
190+
# the created document's ttl value would be -1 inherited from the collection's analyticalStorageTtl and this
191+
# document will never expire
192+
created_document1 = created_collection.create_item(body=document_definition)
193+
194+
# This document is also set to never expire explicitly
195+
document_definition['id'] = 'doc2' + str(uuid.uuid4())
196+
document_definition['ttl'] = -1
197+
created_document2 = created_collection.create_item(body=document_definition)
198+
199+
document_definition['id'] = 'doc3' + str(uuid.uuid4())
200+
document_definition['ttl'] = 2
201+
202+
# The documents with id doc1 and doc2 will never expire
203+
read_document = created_collection.read_item(item=created_document1['id'],
204+
partition_key=created_document1['id'])
205+
self.assertEqual(created_document1['id'], read_document['id'])
206+
207+
read_document = created_collection.read_item(item=created_document2['id'],
208+
partition_key=created_document2['id'])
209+
self.assertEqual(created_document2['id'], read_document['id'])
210+
211+
self.created_db.delete_container(container=created_collection)
212+
213+
def test_document_ttl_with_no_analyticalStorageTtl(self):
214+
created_collection = self.created_db.create_container_if_not_exists(
215+
id='test_ttl_no_analyticalStorageTtl' + str(uuid.uuid4()),
216+
partition_key=PartitionKey(path='/id', kind='Hash')
217+
)
218+
219+
document_definition = {'id': 'doc1' + str(uuid.uuid4()),
220+
'name': 'sample document',
221+
'key': 'value',
222+
'ttl': 5}
223+
224+
created_document = created_collection.create_item(body=document_definition)
225+
226+
time.sleep(7)
227+
228+
# Created document still exists even after ttl time has passed since the TTL is disabled at collection level
229+
# (no analyticalStorageTtl property defined)
230+
read_document = created_collection.read_item(item=created_document['id'], partition_key=created_document['id'])
231+
self.assertEqual(created_document['id'], read_document['id'])
232+
233+
self.created_db.delete_container(container=created_collection)
234+
235+
def test_document_ttl_misc(self):
236+
created_collection = self.created_db.create_container_if_not_exists(
237+
id='test_ttl_values5' + str(uuid.uuid4()),
238+
partition_key=PartitionKey(path='/id'),
239+
analytical_storage_ttl=8)
240+
241+
document_definition = {'id': 'doc1' + str(uuid.uuid4()),
242+
'name': 'sample document',
243+
'key': 'value'}
244+
245+
# We can create a document with the same id after the ttl time has expired
246+
created_collection.create_item(body=document_definition)
247+
created_document = created_collection.read_item(document_definition['id'], document_definition['id'])
248+
self.assertEqual(created_document['id'], document_definition['id'])
249+
250+
time.sleep(3)
251+
252+
# Upsert the document after 3 secs to reset the document's ttl
253+
document_definition['key'] = 'value2'
254+
upserted_docment = created_collection.upsert_item(body=document_definition)
255+
256+
time.sleep(7)
257+
258+
# Upserted document still exists after 10 secs from document creation time(with collection's
259+
# analyticalStorageTtl set to 8) since its ttl was reset after 3 secs by upserting it
260+
read_document = created_collection.read_item(item=upserted_docment['id'], partition_key=upserted_docment['id'])
261+
self.assertEqual(upserted_docment['id'], read_document['id'])
262+
263+
time.sleep(3)
264+
265+
documents = list(created_collection.query_items(
266+
query='SELECT * FROM root r',
267+
enable_cross_partition_query=True
268+
))
269+
270+
self.assertEqual(1, len(documents))
271+
272+
# Removes analyticalStorageTtl property from collection to disable ttl at collection level
273+
replaced_collection = self.created_db.replace_container(
274+
container=created_collection,
275+
partition_key=PartitionKey(path='/id', kind='Hash'),
276+
analytical_storage_ttl=None
277+
)
278+
279+
document_definition['id'] = 'doc2' + str(uuid.uuid4())
280+
created_document = created_collection.create_item(body=document_definition)
281+
282+
time.sleep(5)
283+
284+
# Created document still exists even after ttl time has passed since the TTL is disabled at collection level
285+
read_document = created_collection.read_item(item=created_document['id'], partition_key=created_document['id'])
286+
self.assertEqual(created_document['id'], read_document['id'])
287+
288+
self.created_db.delete_container(container=created_collection)
289+
290+
291+
if __name__ == '__main__':
292+
try:
293+
unittest.main()
294+
except SystemExit as inst:
295+
if inst.args[0] is True: # raised by sys.exit(True) when tests failed
296+
raise

0 commit comments

Comments
 (0)