Uh oh!
There was an error while loading. Please reload this page.
(improvement) (Cython only) row_parser: cache ParseDesc for prepared statements (us improvements - 20-100x speedups!) - #742
Conversation
e355833 to
eb3ae98CompareThere was a problem hiding this comment.
Pull request overview
This PR optimizes the Cython row parsing fast-path for prepared statements by caching the ParseDesc built in recv_results_rows(), reducing repeated per-response construction overhead.
Changes:
- Add a module-level cache in
cassandra/row_parser.pyxkeyed byid(column_metadata)to reuseParseDesc,column_names, andcolumn_typesfor prepared statement executions. - Expose
clear_parse_desc_cache()and call it fromCluster.shutdown(). - Add a benchmark/correctness benchmark module to measure and validate the caching behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| cassandra/row_parser.pyx | Introduces ParseDesc caching and adds a public cache-clear helper. |
| cassandra/cluster.py | Clears the module-level ParseDesc cache during Cluster.shutdown(). |
| benchmarks/test_parse_desc_cache_benchmark.py | Adds benchmarks and cache-behavior checks for the new caching approach. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
ee3c279 to
9f2f3ffCompareThere was a problem hiding this comment.
Pull request overview
This PR improves the Cython result-row parsing hot path by caching the ParseDesc constructed in recv_results_rows() for repeated executions of the same prepared statement (stable result_metadata object), reducing repeated metadata processing and deserializer construction.
Changes:
- Add a bounded module-level cache in
cassandra/row_parser.pyxkeyed byid(column_metadata)with identity + session-settings validation. - Add cache management helpers (
clear_parse_desc_cache(),get_parse_desc_cache_size()) intended for test/benchmark visibility. - Add a new benchmark module to measure ParseDesc construction and end-to-end parsing impact.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| cassandra/row_parser.pyx | Introduces ParseDesc caching for prepared-statement row decoding and adds cache clear/size helpers. |
| benchmarks/test_parse_desc_cache_benchmark.py | Adds benchmarks (and some correctness assertions) for cached vs uncached ParseDesc construction/parsing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
9f2f3ff to
203080eCompareCache the ParseDesc object constructed in recv_results_rows() so that repeated executions of the same prepared statement skip the list comprehensions, ColDesc construction, and make_deserializers() call. The cache is keyed by id(column_metadata). For prepared statements the result_metadata list is stored on PreparedStatement and reused, so id() is stable. On cache hit we verify object identity (cached_ref is column_metadata) and that session-level settings (column_encryption_policy, protocol_version) still match. Implementation details: - _get_or_build_parse_desc: cached path, used only when column_metadata comes from result_metadata (prepared statements with stable id()). - _build_parse_desc: uncached path, used for inline metadata from non-prepared queries that creates a fresh list every execution. - Cache is bounded to 256 entries; cleared entirely when full. - Returns only (desc, column_names, column_types) to the caller, avoiding exposure of internal cache fields. - Thread-safety: dict get/set are atomic in CPython (GIL), but concurrent cache misses may cause redundant construction (benign). A clear_parse_desc_cache() and get_parse_desc_cache_size() function are exposed for testing. ## Benchmark results (median, pytest-benchmark) ### ParseDesc construction only (reference benchmarks) | Columns | **Before** (original) | **After** (with cache) | |---------|-----------------------|------------------------| | 5 cols | 3,966 ns | 191 ns | | 10 cols | 5,730 ns | 175 ns | | 20 cols | 9,266 ns | 166 ns | | 50 cols | 19,388 ns | 193 ns | ### Full pipeline integration (recv_results_rows through Cython) | Scenario | **Before** (original) | **After** (with cache) | |-------------------|-----------------------|------------------------| | 1 row x 10 col | 40,867 ns | 2,977 ns | | 100 rows x 5 col | 145,584 ns | 73,206 ns | | 1000 rows x 5 col | 1,099,825 ns | 999,517 ns | For small result sets (single-row lookups common with prepared statements), ParseDesc construction is a large fraction of the total response-path cost. Caching eliminates it entirely after the first execution. All 623 unit tests pass (16 skipped - pre-existing).
- Fix copyright header in benchmark file (DataStax -> ScyllaDB) - Add pytest.importorskip guard for pytest-benchmark in benchmark file - Add unit tests for ParseDesc cache under tests/unit/cython: cache hit, miss, protocol version invalidation, clear, bounded eviction, correctness
203080e to
d0549f7CompareImportant Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
mykaul
commented
Jul 29, 2026
Rebased onto current Cache-invalidation correctness on schema change / re-preparation (the main thing I scrutinized): verified safe. Summary:
I added one small doc-only change (amended into the first commit, no new commit): a comment in CI / review status: all 20 checks were green on the pre-rebase commit; no unresolved review threads (Copilot's comments — shutdown-hook global side effect, memory retention, missing unit tests, copyright header — were all addressed in the second commit or by removing the Testing performed locally (Cython extensions rebuilt in-place):
Force-pushed the rebased branch (2 commits, same as before, amended — no new commits added). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (11)
tests/unit/cython/test_parse_desc_cache.py:117
- If the Cython
cassandra.row_parserimport fails (_HAS_ROW_PARSER == False), these tests can still be collected and then fail withNameError/TypeErrorwhen callingget_parse_desc_cache_size()or_recv_results_rows. Consider skipping the entire test class when_HAS_ROW_PARSERis false (e.g.,setUp()callsself.skipTest(...)when unavailable, or a@unittest.skipUnless(_HAS_ROW_PARSER, ...)decorator).
class ParseDescCacheTest(unittest.TestCase):
"""Tests for the Cython ParseDesc cache in row_parser.pyx."""
def setUp(self):
if _HAS_ROW_PARSER:
clear_parse_desc_cache()
def tearDown(self):
if _HAS_ROW_PARSER:
clear_parse_desc_cache()
@cythontest
def test_cache_hit_returns_same_objects(self):
cassandra/row_parser.pyx:63
- The ParseDesc construction logic is duplicated between
_get_or_build_parse_desc()(miss path) and_build_parse_desc(). To avoid drift/bugs when this construction changes in the future, consider factoring the shared build logic into a single internal helper (e.g.,_construct_parse_desc(...)returning(desc, names, types)), and have both functions call it.
cdef inline tuple _get_or_build_parse_desc(object column_metadata, object column_encryption_policy, int protocol_version):
cassandra/row_parser.pyx:86
- The ParseDesc construction logic is duplicated between
_get_or_build_parse_desc()(miss path) and_build_parse_desc(). To avoid drift/bugs when this construction changes in the future, consider factoring the shared build logic into a single internal helper (e.g.,_construct_parse_desc(...)returning(desc, names, types)), and have both functions call it.
# Cache miss -- build everything
cdef list column_names = [md[2] for md in column_metadata]
cdef list column_types = [md[3] for md in column_metadata]
cdef object desc = ParseDesc(
column_names, column_types, column_encryption_policy,
[ColDesc(md[0], md[1], md[2]) for md in column_metadata],
make_deserializers(column_types), protocol_version)
cassandra/row_parser.pyx:100
- The ParseDesc construction logic is duplicated between
_get_or_build_parse_desc()(miss path) and_build_parse_desc(). To avoid drift/bugs when this construction changes in the future, consider factoring the shared build logic into a single internal helper (e.g.,_construct_parse_desc(...)returning(desc, names, types)), and have both functions call it.
cdef inline tuple _build_parse_desc(object column_metadata, object column_encryption_policy, int protocol_version):
cassandra/row_parser.pyx:110
- The ParseDesc construction logic is duplicated between
_get_or_build_parse_desc()(miss path) and_build_parse_desc(). To avoid drift/bugs when this construction changes in the future, consider factoring the shared build logic into a single internal helper (e.g.,_construct_parse_desc(...)returning(desc, names, types)), and have both functions call it.
cdef list column_names = [md[2] for md in column_metadata]
cdef list column_types = [md[3] for md in column_metadata]
cdef object desc = ParseDesc(
column_names, column_types, column_encryption_policy,
[ColDesc(md[0], md[1], md[2]) for md in column_metadata],
make_deserializers(column_types), protocol_version)
cassandra/row_parser.pyx:88
- Clearing the entire cache at capacity can cause sudden churn if an application legitimately uses slightly more than 256 prepared statements (repeated full rebuilds after each threshold crossing). A low-overhead alternative is evicting a single entry (e.g., oldest insertion-order key) on overflow, which preserves most cached entries while still avoiding complex LRU bookkeeping on the hot path (eviction only happens on misses).
# Simple bounded eviction: if the cache is too large, clear it entirely.
cassandra/row_parser.pyx:93
- Clearing the entire cache at capacity can cause sudden churn if an application legitimately uses slightly more than 256 prepared statements (repeated full rebuilds after each threshold crossing). A low-overhead alternative is evicting a single entry (e.g., oldest insertion-order key) on overflow, which preserves most cached entries while still avoiding complex LRU bookkeeping on the hot path (eviction only happens on misses).
if len(_parse_desc_cache) >= _PARSE_DESC_CACHE_MAX_SIZE:
_parse_desc_cache.clear()
cassandra/row_parser.pyx:115
- These
deffunctions become part of the publiccassandra.row_parserimport surface. If they are intended strictly for tests/debugging, consider marking them as internal (e.g., prefix with_) and/or adding explicit documentation that they are not a stable public API. This avoids committing the project to long-term support of testing hooks as user-facing API.
def clear_parse_desc_cache():
"""Clear the ParseDesc cache. Exposed for testing."""
cassandra/row_parser.pyx:120
- These
deffunctions become part of the publiccassandra.row_parserimport surface. If they are intended strictly for tests/debugging, consider marking them as internal (e.g., prefix with_) and/or adding explicit documentation that they are not a stable public API. This avoids committing the project to long-term support of testing hooks as user-facing API.
def get_parse_desc_cache_size():
"""Return the current number of entries in the ParseDesc cache. Exposed for testing."""
benchmarks/test_parse_desc_cache_benchmark.py:43
- Module-level
pytest.importorskip('pytest_benchmark')skips all tests in this file (including the non-benchmark correctness tests), which can unintentionally reduce coverage in environments that runpytestwithout the plugin. Consider limiting the skip to only the benchmark-dependent tests (or splitting correctness checks intotests/and leaving only benchmarks underbenchmarks/).
# Skip the entire module when pytest-benchmark is not installed.
benchmarks/test_parse_desc_cache_benchmark.py:48
- Module-level
pytest.importorskip('pytest_benchmark')skips all tests in this file (including the non-benchmark correctness tests), which can unintentionally reduce coverage in environments that runpytestwithout the plugin. Consider limiting the skip to only the benchmark-dependent tests (or splitting correctness checks intotests/and leaving only benchmarks underbenchmarks/).
pytest.importorskip("pytest_benchmark")
Cache the ParseDesc object constructed in recv_results_rows() so that repeated executions of the same prepared statement skip the list comprehensions, ColDesc construction, and make_deserializers() call.
The cache is keyed by id(column_metadata). For prepared statements the result_metadata list is stored on PreparedStatement and reused, so id() is stable. On cache hit we verify object identity (cached_ref is column_metadata) and that session-level settings (column_encryption_policy, protocol_version) still match.
A clear_parse_desc_cache() function is exposed for testing.
Benchmark results (median, pytest-benchmark)
ParseDesc construction only
Full pipeline (ParseDesc + row parsing)
For small result sets (single-row lookups common with prepared statements), ParseDesc construction is a large fraction of the total response-path cost. Caching eliminates it entirely after the first execution.
All 116 unit tests pass (1 skipped — pre-existing test_datetype issue).
Pre-review checklist
./docs/source/.Fixes:annotations to PR description.