Skip to content

Commit 289fd60

Browse files
committed
PYCBC-1778: Fix KV range scan consistent_with and ScanResult.expiry_time
Changes ------- * Fix ScanResult.expiry_time reading the 'cas' field instead of 'expiry', * Fix consistent_with serialization: wrap tokens as {'tokens': [...]} so the C++ binding's mutation_state extraction receives them; a bare list was silently dropped, leaving scans with no consistency * Guard expiry_time access on content results in range scan tests (sync + async) * Add cluster-free RangeScanRequestBuilderUnitTests asserting the consistent_with serialization shape Change-Id: Ibe6a165ebbc4466bc1ebbf499e3d2e66837f76f7 Reviewed-on: https://review.couchbase.org/c/couchbase-python-client/+/246632 Tested-by: Build Bot <build@couchbase.com> Reviewed-by: Dimitris Christodoulou <dimitris.christodoulou@couchbase.com>
1 parent 60ee58b commit 289fd60

4 files changed

Lines changed: 71 additions & 5 deletions

File tree

acouchbase/tests/kv_range_scan_t.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16-
from datetime import timedelta
16+
from datetime import datetime, timedelta
1717
from uuid import uuid4
1818

1919
import pytest
@@ -120,6 +120,11 @@ async def _validate_result(self, result, expected_count=0, ids_only=False, retur
120120
content = r.content_as[dict]
121121
assert content is not None
122122
assert content == {'id': r.id}
123+
# Regression guard (PYCBC-1778): ScanResult.expiry_time previously read the 'cas' field instead
124+
# of 'expiry', passing a huge HLC value to datetime.fromtimestamp() raising an exception.
125+
# Accessing it on a content result must not raise. The value is either None (no expiry) or a datetime.
126+
expiry_time = r.expiry_time
127+
assert expiry_time is None or isinstance(expiry_time, datetime)
123128
rows.append(r)
124129

125130
if from_sample is True:

couchbase/logic/collection_req_builder.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,10 @@ def _process_scan_orchestrator_ops(self, orchestrator_opts: Dict[str, Any]) -> N
621621
if not (isinstance(consistent_with, MutationState) and len(consistent_with._sv) > 0):
622622
raise InvalidArgumentException('Passed empty or invalid mutation state')
623623
else:
624-
orchestrator_opts['consistent_with'] = list(token.as_dict() for token in consistent_with._sv)
624+
# The C++ binding expects a mutation_state dict ({'tokens': [...]}), not a bare list.
625+
orchestrator_opts['consistent_with'] = {
626+
'tokens': list(token.as_dict() for token in consistent_with._sv)
627+
}
625628

626629
def _get_scan_config(self, scan_type: ScanType) -> Dict[str, Any]: # noqa: C901
627630
scan_config = {}

couchbase/result.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,7 @@ def expiry_time(self) -> Optional[datetime]:
10861086
if self.ids_only:
10871087
raise InvalidArgumentException(("No expiry_time available when scan is requested with "
10881088
"`ScanOptions` ids_only set to True."))
1089-
time_ms = self._orig.raw_result.get('scan_item', {}).get('body', {}).get('cas', None)
1089+
time_ms = self._orig.raw_result.get('scan_item', {}).get('body', {}).get('expiry', None)
10901090
if time_ms:
10911091
return datetime.fromtimestamp(time_ms)
10921092
return None

couchbase/tests/kv_range_scan_t.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16-
from datetime import timedelta
16+
from datetime import datetime, timedelta
1717
from uuid import uuid4
1818

1919
import pytest
@@ -26,9 +26,13 @@
2626
RangeScan,
2727
SamplingScan,
2828
ScanTerm)
29+
from couchbase.logic.collection_req_builder import CollectionRequestBuilder
30+
from couchbase.logic.collection_types import CollectionDetails
2931
from couchbase.mutation_state import MutationState
3032
from couchbase.options import ScanOptions
31-
from couchbase.result import ScanResult, ScanResultIterable
33+
from couchbase.result import (MutationToken,
34+
ScanResult,
35+
ScanResultIterable)
3236
from tests.environments import CollectionType
3337
from tests.test_features import EnvironmentFeatures
3438

@@ -116,6 +120,11 @@ def _validate_result(self, result, expected_count=0, ids_only=False, return_rows
116120
assert content is not None
117121
if not skip_content_validation:
118122
assert content == {'id': r.id}
123+
# Regression guard (PYCBC-1778): ScanResult.expiry_time previously read the 'cas' field instead
124+
# of 'expiry', passing a huge HLC value to datetime.fromtimestamp() raising an exception.
125+
# Accessing it on a content result must not raise. The value is either None (no expiry) or a datetime.
126+
expiry_time = r.expiry_time
127+
assert expiry_time is None or isinstance(expiry_time, datetime)
119128
rows.append(r)
120129

121130
if from_sample is True:
@@ -364,3 +373,52 @@ def couchbase_test_environment(self, cb_base_env, test_manifest_validated, reque
364373
yield cb_base_env
365374
self._purge_temp_docs(cb_base_env, test_ids)
366375
cb_base_env.teardown(request.param)
376+
377+
378+
class RangeScanRequestBuilderUnitTests:
379+
"""Cluster-free unit tests for the range-scan request builder.
380+
381+
Regression coverage for the consistent_with serialization bug (PYCBC-1778)
382+
"""
383+
384+
@staticmethod
385+
def _builder():
386+
details = CollectionDetails('default', '_default', '_default', None)
387+
return CollectionRequestBuilder(details)
388+
389+
@staticmethod
390+
def _mutation_state(count):
391+
state = MutationState()
392+
for i in range(count):
393+
state.add_mutation_token(MutationToken({'partition_id': i,
394+
'partition_uuid': 1000 + i,
395+
'sequence_number': 1,
396+
'bucket_name': 'default'}))
397+
return state
398+
399+
def test_consistent_with_serialized_as_mutation_state(self):
400+
opts = {'consistent_with': self._mutation_state(3), 'concurrency': 1}
401+
self._builder()._process_scan_orchestrator_ops(opts)
402+
403+
# Must be a mutation_state dict ({'tokens': [...]}), not a bare list, or the C++
404+
# binding drops every token.
405+
consistent_with = opts['consistent_with']
406+
assert isinstance(consistent_with, dict)
407+
assert list(consistent_with.keys()) == ['tokens']
408+
409+
tokens = consistent_with['tokens']
410+
assert isinstance(tokens, list)
411+
assert len(tokens) == 3
412+
for token in tokens:
413+
assert set(token.keys()) == {'partition_id', 'partition_uuid',
414+
'sequence_number', 'bucket_name'}
415+
416+
def test_empty_consistent_with_raises(self):
417+
opts = {'consistent_with': MutationState()}
418+
with pytest.raises(InvalidArgumentException):
419+
self._builder()._process_scan_orchestrator_ops(opts)
420+
421+
def test_zero_concurrency_raises(self):
422+
opts = {'concurrency': 0}
423+
with pytest.raises(InvalidArgumentException):
424+
self._builder()._process_scan_orchestrator_ops(opts)

0 commit comments

Comments
 (0)