Skip to content

fix(bigtable): resolve O(n^2) bottleneck in ReadRowsQuery.add_range - #18706

Open
parthea wants to merge 6 commits into
mainfrom
fix-bigtable-20261015
Open

parthea wants to merge 6 commits into
mainfrom
fix-bigtable-20261015

Conversation

@parthea

@parthea parthea commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

This PR resolves a severe client-side O(n^2) CPU performance bottleneck inside ReadRowsQuery.add_range. This regression was introduced when deduplication checks were added to prevent duplicate row ranges in read requests.

Fixes b/573522284
Fixes #18708

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request optimizes the performance of ReadRowsQuery.add_range from O(N) to O(1) by making RowRange hashable and maintaining a _range_signatures set. It also fixes a typo from slots to __slots__ to ensure proper memory optimization, and adds corresponding unit tests. However, the proposed self-healing logic in add_range compares the lengths of the _range_signatures set and the _row_set.row_ranges list. If the query is initialized with duplicate ranges, these lengths will never match, causing the cache to rebuild on every call and introducing an O(N^2) bottleneck. It is recommended to remove this self-healing check entirely.

Comment thread packages/google-cloud-bigtable/google/cloud/bigtable/data/read_rows_query.py Outdated
@parthea
parthea force-pushed the fix-bigtable-20261015 branch from c486b34 to f6a2fa6 Compare October 15, 2026 19:55
@parthea
parthea force-pushed the fix-bigtable-20261015 branch from f6a2fa6 to f8d3b11 Compare October 15, 2026 20:03
@parthea
parthea marked this pull request as ready for review October 15, 2026 20:15
@parthea
parthea requested a review from a team as a code owner October 15, 2026 20:15
Comment on lines +592 to +610
def test_add_range_performance_regression(self):
"""Assert O(1) range insertion scaling and prevent regression back to O(N^2)."""
from google.cloud.bigtable.data.read_rows_query import RowRange

query = self._make_one()
ranges_count = 1000

start_time = time.perf_counter()
for i in range(ranges_count):
# Using 4-digit zero-padding ensures 'key_0009' is lexicographically smaller than 'key_0010'
r = RowRange(start_key=f"key_{i:04d}", end_key=f"key_{i + 1:04d}")
query.add_range(r)
end_time = time.perf_counter()

elapsed = end_time - start_time
# Under O(N^2) on test runners, 1k iterations takes seconds.
# Under O(1) with tuple hashing, it takes <0.10s.
# Double the value to avoid flaky tests
assert elapsed < 0.20, (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wall-clock assertions tend to flake under coverage and when they fail they don't say why. Could we assert the behaviour instead? For example, check that add_range never builds row_ranges:

def test_add_range_does_not_materialize_row_ranges(self):
    from unittest import mock
    from google.cloud.bigtable.data.read_rows_query import RowRange
    query = self._make_one()
    with mock.patch.object(
        type(query), "row_ranges", new_callable=mock.PropertyMock
    ) as row_ranges:
        for i in range(100):
            query.add_range(RowRange(f"k{i:03d}", f"k{i + 1:03d}"))
    row_ranges.assert_not_called()
    assert len(query._row_set.row_ranges) == 100

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d08cc34

@ohmayr ohmayr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up. Using a set is the right fix. One blocking issue (inline): __hash__ and __eq__ disagree for the legacy row_set.RowRange, so mixed legacy/base ranges no longer dedup. The rest are small.

Separately, add_key and ReadRowsQuery.__eq__ have the same O(n²) pattern: 10k add_key calls take ~11.6s, and __eq__ on two 2k-range queries takes ~50s. These can go in a follow-up PR, but please don't close b/573522284 until they're tracked, since key-heavy workloads are still slow.

self.end_is_inclusive,
)

def __hash__(self) -> int:

Copy link
Copy Markdown
Contributor

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__ hashes self._key(). The legacy subclass google.cloud.bigtable.row_set.RowRange overrides _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 vs main:

from google.cloud.bigtable.row_set import RowRange as LegacyRowRange

b, l = RowRange(b"a", b"b"), LegacyRowRange(b"a", b"b")
b == l               # True
hash(b) == hash(l)   # False

q = ReadRowsQuery(row_ranges=[b])
q.add_range(l)
len(q.row_ranges)    # 2 on this branch, 1 on main

Suggest hashing the same data __eq__ compares:

def __hash__(self) -> int:
    pb = self._pb
    return hash(
        (pb.start_key_closed, pb.start_key_open, pb.end_key_closed, pb.end_key_open)
    )

and dropping __hash__ from row_set.RowRange so 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 10k add_range calls still take ~0.23s. Could you add a test along these lines?

def test_row_range_hash_matches_legacy(self):
    from google.cloud.bigtable.data.read_rows_query import RowRange
    from google.cloud.bigtable.row_set import RowRange as LegacyRowRange

    b, l = RowRange(b"a", b"b"), LegacyRowRange(b"a", b"b")
    assert b == l
    assert hash(b) == hash(l)
    query = self._make_one(row_ranges=[b])
    query.add_range(l)
    assert len(query.row_ranges) == 1

Comment on lines +265 to +266
self._range_signatures = set(
RowRange._from_pb(r) for r in self._row_set.row_ranges

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

row_ranges is already a list of RowRange here, so set(row_ranges) should be enough and avoids re-wrapping from the proto. Naming nit: this holds RowRange objects, not signatures. Maybe _row_range_set?

Heads-up: ReadRowsQuery declares slots = (...) (not __slots__), which is the only reason adding a new attribute works. If that typo gets fixed later, this attribute needs to be added there.

# 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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding this. One gap: the new set seeding in __init__ isn't covered. Could we add a case where a range is passed to the constructor and an equal one is then added?

query = self._make_one(row_ranges=[RowRange("a", "b")])
query.add_range(RowRange("a", "b"))
assert len(query.row_ranges) == 1

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

def _from_pb(cls, data: RowRangePB) -> RowRange:
"""
Creates a RowRange from a protobuf
Creates a RowRange from a protobuf without allocating temporary unused objects.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: cls.__new__(cls) is a good change, but the docstring now describes implementation. I'd keep the original wording and add a # comment on why __init__ is skipped if needed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(bigtable): Performance regression in ReadRowsQuery.add_range

2 participants