Skip to content

Commit d2037b8

Browse files
authored
Improve first iteration logic (#1121)
Backporting #1101 to 5.x branch to allow handling an exception raised when retrieving the first item.
1 parent 821a493 commit d2037b8

File tree

4 files changed

+57
-12
lines changed

4 files changed

+57
-12
lines changed

docs/release_notes.rst

+22
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,28 @@
33
Release Notes
44
=============
55

6+
v5.3.3
7+
----------
8+
* Fix :py:class:`~pynamodb.pagination.PageIterator` and :py:class:`~pynamodb.pagination.ResultIterator`
9+
to allow recovery from an exception when retrieving the first item (#1101).
10+
11+
.. code-block:: python
12+
13+
results = MyModel.query('hash_key')
14+
while True:
15+
try:
16+
item = next(results)
17+
except StopIteration:
18+
break
19+
except pynamodb.exceptions.QueryError as ex:
20+
if ex.cause_response_code == 'ThrottlingException':
21+
time.sleep(1) # for illustration purposes only
22+
else:
23+
raise
24+
else:
25+
handle_item(item)
26+
27+
628
v5.3.2
729
----------
830
* Prevent ``typing_tests`` from being installed into site-packages (#1118)

pynamodb/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
"""
88
__author__ = 'Jharrod LaFon'
99
__license__ = 'MIT'
10-
__version__ = '5.3.2'
10+
__version__ = '5.3.3'

pynamodb/pagination.py

+7-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import time
2-
from typing import Any, Callable, Dict, Iterable, Iterator, TypeVar, Optional
2+
from typing import Any, Callable, Dict, Iterable, Iterator, Optional, TypeVar
33

44
from pynamodb.constants import (CAMEL_COUNT, ITEMS, LAST_EVALUATED_KEY, SCANNED_COUNT,
55
CONSUMED_CAPACITY, TOTAL, CAPACITY_UNITS)
@@ -90,8 +90,8 @@ def __init__(
9090
self._operation = operation
9191
self._args = args
9292
self._kwargs = kwargs
93-
self._first_iteration = True
9493
self._last_evaluated_key = kwargs.get('exclusive_start_key')
94+
self._is_last_page = False
9595
self._total_scanned_count = 0
9696
self._rate_limiter = None
9797
if rate_limit:
@@ -102,18 +102,17 @@ def __iter__(self) -> Iterator[_T]:
102102
return self
103103

104104
def __next__(self) -> _T:
105-
if self._last_evaluated_key is None and not self._first_iteration:
105+
if self._is_last_page:
106106
raise StopIteration()
107107

108-
self._first_iteration = False
109-
110108
self._kwargs['exclusive_start_key'] = self._last_evaluated_key
111109

112110
if self._rate_limiter:
113111
self._rate_limiter.acquire()
114112
self._kwargs['return_consumed_capacity'] = TOTAL
115113
page = self._operation(*self._args, settings=self._settings, **self._kwargs)
116114
self._last_evaluated_key = page.get(LAST_EVALUATED_KEY)
115+
self._is_last_page = self._last_evaluated_key is None
117116
self._total_scanned_count += page[SCANNED_COUNT]
118117

119118
if self._rate_limiter:
@@ -170,10 +169,11 @@ def __init__(
170169
settings: OperationSettings = OperationSettings.default,
171170
) -> None:
172171
self.page_iter: PageIterator = PageIterator(operation, args, kwargs, rate_limit, settings)
173-
self._first_iteration = True
174172
self._map_fn = map_fn
175173
self._limit = limit
176174
self._total_count = 0
175+
self._index = 0
176+
self._count = 0
177177

178178
def _get_next_page(self) -> None:
179179
page = next(self.page_iter)
@@ -189,10 +189,6 @@ def __next__(self) -> _T:
189189
if self._limit == 0:
190190
raise StopIteration
191191

192-
if self._first_iteration:
193-
self._first_iteration = False
194-
self._get_next_page()
195-
196192
while self._index == self._count:
197193
self._get_next_page()
198194

@@ -209,7 +205,7 @@ def next(self) -> _T:
209205

210206
@property
211207
def last_evaluated_key(self) -> Optional[Dict[str, Dict[str, Any]]]:
212-
if self._first_iteration or self._index == self._count:
208+
if self._index == self._count:
213209
# Not started iterating yet: return `exclusive_start_key` if set, otherwise expect None; or,
214210
# Entire page has been consumed: last_evaluated_key is whatever DynamoDB returned
215211
# It may correspond to the current item, or it may correspond to an item evaluated but not returned.

tests/test_model.py

+27
Original file line numberDiff line numberDiff line change
@@ -1378,6 +1378,33 @@ def test_query_with_exclusive_start_key(self):
13781378
self.assertEqual(results_iter.total_count, 10)
13791379
self.assertEqual(results_iter.page_iter.total_scanned_count, 10)
13801380

1381+
def test_query_with_failure(self):
1382+
items = [
1383+
{
1384+
**GET_MODEL_ITEM_DATA[ITEM],
1385+
'user_id': {
1386+
STRING: f'id-{idx}'
1387+
},
1388+
}
1389+
for idx in range(30)
1390+
]
1391+
1392+
with patch(PATCH_METHOD) as req:
1393+
req.side_effect = [
1394+
Exception('bleep-bloop'),
1395+
{'Count': 10, 'ScannedCount': 10, 'Items': items[0:10], 'LastEvaluatedKey': {'user_id': items[10]['user_id']}},
1396+
]
1397+
results_iter = UserModel.query('foo', limit=10, page_size=10)
1398+
1399+
with pytest.raises(Exception, match='bleep-bloop'):
1400+
next(results_iter)
1401+
1402+
first_item = next(results_iter)
1403+
assert first_item.user_id == 'id-0'
1404+
1405+
second_item = next(results_iter)
1406+
assert second_item.user_id == 'id-1'
1407+
13811408
def test_query(self):
13821409
"""
13831410
Model.query

0 commit comments

Comments
 (0)