Skip to content

perf: Cache cassandra type parsing with LRU caching (hundreds of ns improvements - x1.1-1.6 speedup) - #690

Draft
mykaul wants to merge 1 commit into
scylladb:masterfrom
mykaul:parse_custom
Draft

perf: Cache cassandra type parsing with LRU caching (hundreds of ns improvements - x1.1-1.6 speedup)#690
mykaul wants to merge 1 commit into
scylladb:masterfrom
mykaul:parse_custom

Conversation

@mykaul

@mykaul mykaul commented Feb 6, 2026

Copy link
Copy Markdown

Summary

Cache lookup_casstype_simple() and parse_casstype_args() with @functools.lru_cache() to avoid repeated string manipulation and regex scanning when the same type strings are resolved multiple times (common during schema parsing and query result deserialization).

The fast-path for simple types (without parentheses) was already merged separately. This PR adds caching on top of that.

Also fixes an unused variable warning (prev_names_).

Includes a pytest-benchmark comparison (cached vs uncached).

Changes

  • cassandra/cqltypes.py: Added import functools, @functools.lru_cache(maxsize=4096) on lookup_casstype_simple() and parse_casstype_args(), fixed unused prev_names variable, and fixed UserType.evict_udt_class() to also clear the parse_casstype_args() cache (see "Cache correctness" below).
  • benchmarks/test_casstype_cache_benchmark.py: New benchmark file with correctness tests and cached vs uncached performance comparisons.
  • tests/unit/test_types.py: Added unit tests covering cache hit/miss behavior and the UDT eviction/invalidation interaction described below.

Benchmark results

lookup_casstype_simple — clear wins from cache

Benchmark Cached Uncached Speedup
Short name (UTF8Type) 135 ns 217 ns 1.6x
Fully-qualified name (o.a.c.db.marshal.UTF8Type) 274 ns 414 ns 1.5x
Batch of 10 types 1.57 µs 1.83 µs 1.2x

parse_casstype_args — modest gains for parameterized types

Benchmark Cached Uncached Speedup
MapType(UTF8,Int32) 26.9 µs 29.1 µs 1.08x
Nested MapType(UTF8,ListType(Int32)) 46.9 µs 55.6 µs 1.19x

End-to-end lookup_casstype (mixed simple + parameterized)

Benchmark Cached Uncached Speedup
Mixed batch (simple + parameterized) 296 µs 321 µs 1.08x

The biggest gains are on lookup_casstype_simple, which is called most frequently (every column in every row). The parse_casstype_args cache helps for parameterized types (maps, lists, sets, tuples) where the regex scanner is the bottleneck.

Cache bound (corrected from an earlier revision)

An earlier revision of this PR called lru_cache() with no arguments and described that as "unbounded... no risk of unbounded memory growth." That claim was wrong on both counts: functools.lru_cache() with no arguments defaults to maxsize=128 (not unbounded), and 128 is small enough that a schema with more than 128 distinct type strings (easy to hit with more than a handful of tables/UDTs) would thrash the cache.

Both caches now use an explicit maxsize=4096. This is deliberately bounded rather than maxsize=None/unbounded: type strings reaching these functions aren't always schema-bounded — protocol.py's CUSTOM_TYPE branch passes a server-supplied class name straight to lookup_casstype()/lookup_casstype_simple() with no validation, so an unbounded cache would let a misbehaving or malicious server grow the cache without limit. 4096 is generous enough to avoid thrashing on realistic schemas while still capping worst-case memory growth.

Cache correctness (UDT invalidation)

UserType already has its own per-(keyspace, udt_name) class cache (UserType._cache), with an explicit evict_udt_class() invalidation hook called from Cluster.register_user_type(). Since parse_casstype_args() is now also cached, and it's the function that (via apply_parametersmake_udt_class) produces the class for a UserType(...) type string, the new LRU cache could return a stale, pre-eviction class for a type string that was already cached before evict_udt_class() ran — silently masking the eviction. Row (de)serialization itself is unaffected (it goes through protocol.py's read_type(), which calls make_udt_class() directly and always refreshes mapped_class), but schema-metadata resolution via lookup_casstype()/parse_casstype_args() was not.

Fixed by clearing parse_casstype_args's cache inside evict_udt_class(). Verified with a reproduction (confirmed the bug existed pre-fix, and no longer does), and added a regression test (test_parse_casstype_args_udt_cache_invalidated_on_evict).

@mykaul
mykaul requested a review from Copilot February 6, 2026 12:17
@mykaul mykaul added the enhancement New feature or request label Feb 6, 2026
@mykaul
mykaul marked this pull request as draft February 6, 2026 12:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This pull request optimizes custom type parsing by adding LRU caching to frequently-called type lookup functions and implementing a fast path for simple types without parameters. The optimization aims to reduce repeated string manipulation and regex scanning for type lookups that occur frequently during query execution.

Changes:

  • Added @functools.lru_cache(maxsize=256) decorators to lookup_casstype_simple and parse_casstype_args functions
  • Implemented fast-path optimization in lookup_casstype to avoid regex scanning for simple types (those without parentheses)
  • Removed error handling wrapper from lookup_casstype, changing behavior to return UnrecognizedType instead of raising ValueError for invalid types
  • Added benchmark script demonstrating cache effectiveness for repeated type lookups

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 10 comments.

File Description
cassandra/cqltypes.py Added LRU caching to type parsing functions, removed error handling wrapper, cleaned up unused variable, optimized type lookup with fast path
tests/unit/test_types.py Updated test to reflect new behavior where invalid type names create UnrecognizedType instead of raising ValueError
benchmarks/cache_benefit.py New benchmark script demonstrating LRU cache benefits for repeated type lookups with various type complexities

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/unit/test_types.py Outdated
Comment thread benchmarks/cache_benefit.py Outdated
Comment thread benchmarks/cache_benefit.py Outdated
Comment thread cassandra/cqltypes.py
Comment thread cassandra/cqltypes.py Outdated
Comment thread tests/unit/test_types.py Outdated
Comment thread cassandra/cqltypes.py Outdated
Comment thread cassandra/cqltypes.py
Comment thread cassandra/cqltypes.py Outdated
Comment thread benchmarks/cache_benefit.py Outdated
@mykaul mykaul changed the title (improvement)Optimize custom type parsing with LRU caching Cache cassandra type parsing with LRU caching Apr 2, 2026
@mykaul mykaul changed the title Cache cassandra type parsing with LRU caching Cache cassandra type parsing with LRU caching (hundreds of ns improvements - x1.1-1.6 speedup) Apr 7, 2026
@mykaul mykaul changed the title Cache cassandra type parsing with LRU caching (hundreds of ns improvements - x1.1-1.6 speedup) perf: Cache cassandra type parsing with LRU caching (hundreds of ns improvements - x1.1-1.6 speedup) Apr 7, 2026
Cache lookup_casstype_simple() and parse_casstype_args() with
@functools.lru_cache() to avoid repeated string manipulation and
regex scanning when the same type strings are resolved multiple times
(common during schema parsing and query result deserialization).

Both caches use an explicit, bounded maxsize (4096) instead of the
implicit default: type strings can include server-supplied custom
type class names (protocol.py CUSTOM_TYPE) and unrecognized type
names, so the cache is capped to bound worst-case memory growth
against a misbehaving/malicious server, while remaining generous
enough not to thrash for realistic schemas.

Also fixes UserType.evict_udt_class() (invoked by
Cluster.register_user_type()) to clear the parse_casstype_args()
cache. Without this, re-parsing the same UDT type string after an
eviction would keep returning the stale, pre-eviction class instead
of going through make_udt_class() again, silently defeating the
eviction.

Also fixes an unused variable warning (prev_names -> _).

Includes a pytest-benchmark comparison (cached vs uncached) and unit
tests covering cache correctness and the UDT eviction/invalidation
interaction.
Copilot AI review requested due to automatic review settings July 29, 2026 20:24
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2887d4a7-4779-4a73-8cd6-b9683a5e34b5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@mykaul

mykaul commented Jul 29, 2026

Copy link
Copy Markdown
Author

Rebased onto current `master` (was ~84 commits behind) and pushed a few fixes into the existing commit:

  • Bounded the LRU cache explicitly (maxsize=4096 instead of the implicit lru_cache() default). The PR description previously claimed the cache was "unbounded... no risk of unbounded memory growth," which was incorrect on both counts: functools.lru_cache() with no args defaults to maxsize=128, and 128 is small enough to thrash on realistic multi-table/multi-UDT schemas. An explicit bound also matters because protocol.py's CUSTOM_TYPE branch feeds server-supplied class names straight into lookup_casstype()/lookup_casstype_simple(), so truly unbounded caching would let a misbehaving/malicious server grow the cache without limit.
  • Fixed a real cache-invalidation bug: UserType.evict_udt_class() (called from Cluster.register_user_type()) only cleared UserType._cache, not the new parse_casstype_args() LRU cache. Since parse_casstype_args() is what produces the UDT class via apply_parameters()/make_udt_class(), a previously-cached type string would keep returning the stale, pre-eviction class after eviction, silently defeating it. Reproduced the bug against the pre-fix code, then fixed it by clearing parse_casstype_args's cache inside evict_udt_class(). Row (de)serialization itself was unaffected by this (it goes through protocol.py's read_type(), which calls make_udt_class() directly and always refreshes mapped_class) — the exposure was in schema-metadata resolution (cassandra/metadata.py) via lookup_casstype().
  • Added regression tests in tests/unit/test_types.py for cache hit/miss behavior and the UDT eviction interaction above.
  • Confirmed this isn't redundant with anything since merged to master: the simple-type fast path in lookup_casstype() (commit 153c913) is already on master and this PR's caching layers on top of it, not over it.

All 10 existing review threads were already resolved before this push and remain so. Full tests/unit/ suite passes (723 passed, 88 skipped — skips are pre-existing, unrelated to this change) both before and after these fixes. CI checks (build, asyncio/asyncore/libev × 3.11-3.14t) were passing before the rebase; will confirm they're still green on the new push.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 (2)

cassandra/cqltypes.py:203

  • Caching this lookup makes later type registrations invisible. CassandraTypeType.__new__ can replace an entry in _casstypes whenever a client defines a CassandraType subclass (as test_parse_casstype_args does), but neither this cache nor parse_casstype_args is invalidated. For example, looking up unknown FooType, then defining class FooType(CassandraType), still returns the cached _UnrecognizedType instead of the newly registered class; cached parameterized strings remain stale as well. Invalidate both caches when the metaclass changes the registries, and add a regression test for this ordering.
@functools.lru_cache(maxsize=_CASSTYPE_CACHE_MAXSIZE)
def lookup_casstype_simple(casstype):

cassandra/cqltypes.py:1026

  • cache_clear() cannot invalidate a parse already in flight. lru_cache executes a miss outside its internal lock, so a concurrent parse can obtain the old UDT class, this method can evict and clear, and then the first thread can insert that stale class back into the now-empty LRU cache. Subsequent lookups again mask the eviction. Please make invalidation generation-aware or otherwise synchronize parsing and eviction so pre-eviction misses cannot populate the post-eviction cache.
        parse_casstype_args.cache_clear()

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants