Repository navigation
fix(bigtable): resolve O(n^2) bottleneck in ReadRowsQuery.add_range #18706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f8d3b11
679ebed
285b2c1
01e759a
d08cc34
b864707
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,6 +87,17 @@ def __init__( | |
| init_dict["end_key_open"] = end_key | ||
| self._pb = RowRangePB(**init_dict) | ||
|
|
||
| def _key(self) -> tuple[bytes | None, bool, bytes | None, bool]: | ||
| return ( | ||
| self.start_key, | ||
| self.start_is_inclusive, | ||
| self.end_key, | ||
| self.end_is_inclusive, | ||
| ) | ||
|
|
||
| def __hash__(self) -> int: | ||
| return hash(self._key()) | ||
|
|
||
| @property | ||
| def start_key(self) -> bytes | None: | ||
| """ | ||
|
|
@@ -140,14 +151,14 @@ def _to_pb(self) -> RowRangePB: | |
| @classmethod | ||
| def _from_pb(cls, data: RowRangePB) -> RowRange: | ||
| """ | ||
| Creates a RowRange from a protobuf | ||
| Creates a RowRange from a protobuf without allocating temporary unused objects. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
|
|
||
| Args: | ||
| data (RowRangePB): The protobuf to convert | ||
| Returns: | ||
| RowRange: The converted RowRange | ||
| """ | ||
| instance = cls() | ||
| instance = cls.__new__(cls) | ||
| instance._pb = data | ||
| return instance | ||
|
|
||
|
|
@@ -250,6 +261,11 @@ def __init__( | |
| self._row_set = RowSetPB( | ||
| row_keys=row_keys, row_ranges=[r._pb for r in row_ranges] | ||
| ) | ||
| # Pre-initialize our signature set using hashable RowRange wrappers | ||
| self._range_signatures = set( | ||
| RowRange._from_pb(r) for r in self._row_set.row_ranges | ||
|
Comment on lines
+265
to
+266
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Heads-up: |
||
| ) | ||
|
|
||
| self.limit = limit or None | ||
| self.filter = row_filter | ||
|
|
||
|
|
@@ -349,7 +365,9 @@ def add_range( | |
| Args: | ||
| row_range: a range of row keys to add to this query | ||
| """ | ||
| if row_range not in self.row_ranges: | ||
| # Utilize pure O(1) set-membership checking | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I'd drop this comment. The code already says what it does. |
||
| if row_range not in self._range_signatures: | ||
| self._range_signatures.add(row_range) | ||
| self._row_set.row_ranges.append(row_range._pb) | ||
|
|
||
| def shard(self, shard_keys: RowKeySamples) -> ShardedQuery: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,8 @@ | |
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| from unittest import mock | ||
|
|
||
| import pytest | ||
|
|
||
| TEST_ROWS = [ | ||
|
|
@@ -586,3 +588,58 @@ def test_empty_row_set(self): | |
| """Empty strings should be treated as keys inputs""" | ||
| query = self._make_one(row_keys="") | ||
| assert query.row_keys == [b""] | ||
|
|
||
| def test_add_range_does_not_materialize_row_ranges(self): | ||
| """ | ||
| Ensures add_range scales in O(1) time and does not trigger O(N) scans. | ||
|
|
||
| The legacy implementation called the `row_ranges` property helper during | ||
| membership checks, which dynamically re-allocated and materialized brand | ||
| new Python RowRange wrapper objects for every element in the array on | ||
| each insertion. This led to an accidental O(N^2) memory and CPU leak. | ||
|
|
||
| This test mocks out the `row_ranges` property on the class and asserts | ||
| that it is never accessed during batch additions, proving we bypass the | ||
| quadratic evaluation path entirely. | ||
| """ | ||
| from google.cloud.bigtable.data.read_rows_query import RowRange | ||
|
|
||
| query = self._make_one() | ||
|
|
||
| # Intercept the `row_ranges` class property with a mock to track accesses | ||
| with mock.patch.object( | ||
| type(query), "row_ranges", new_callable=mock.PropertyMock | ||
| ) as row_ranges: | ||
| for i in range(100): | ||
| # Add unique sequential ranges. Utilizing our fast O(1) set signature lookup | ||
| # should never touch the slow wrapper-materialization properties. | ||
| query.add_range(RowRange(f"k{i:03d}", f"k{i + 1:03d}")) | ||
|
|
||
| # ASSERT: The slow, O(N) wrapper-building property was never accessed | ||
| row_ranges.assert_not_called() | ||
|
|
||
| # Verify that all ranges were successfully committed to the underlying protobuf | ||
| assert len(query._row_set.row_ranges) == 100 | ||
|
|
||
| def test_add_range_deduplication_behavior(self): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for adding this. One gap: the new set seeding in query = self._make_one(row_ranges=[RowRange("a", "b")])
query.add_range(RowRange("a", "b"))
assert len(query.row_ranges) == 1 |
||
| """Assert that identical ranges are deduplicated, but distinct ranges are kept.""" | ||
| from google.cloud.bigtable.data.read_rows_query import RowRange | ||
|
|
||
| query = self._make_one() | ||
|
|
||
| # Create two identical ranges | ||
| r1 = RowRange(start_key="a", end_key="b") | ||
| r2 = RowRange(start_key="a", end_key="b") | ||
|
|
||
| # Create a distinct range | ||
| r3 = RowRange(start_key="c", end_key="d") | ||
|
|
||
| # Add all three ranges | ||
| query.add_range(r1) | ||
| query.add_range(r2) # Duplicate, should be ignored | ||
| query.add_range(r3) # Distinct, should be kept | ||
|
|
||
| # Assert that only r1 and r3 are in the final query | ||
| assert len(query.row_ranges) == 2 | ||
| assert query.row_ranges[0] == r1 | ||
| assert query.row_ranges[1] == r3 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
__eq__compares_pb, but__hash__hashesself._key(). The legacy subclassgoogle.cloud.bigtable.row_set.RowRangeoverrides_key()with a different field order, so legacy and base ranges compare equal but hash differently. That breaks set membership for mixed types, and dedup regresses vsmain:Suggest hashing the same data
__eq__compares:and dropping
__hash__fromrow_set.RowRangeso it inherits this one (_key()then isn't needed here). I tried this locally: mixed legacy/base dedup works, the bigtable unit tests pass, and 10kadd_rangecalls still take ~0.23s. Could you add a test along these lines?