From 43f5f00d492af01edc66f7d77d2bdf995b5c2167 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 10 Sep 2026 13:28:32 +0200 Subject: [PATCH 1/2] perf: cache compiled JSONPath expressions `extract_from_json_path` called `jsonpath_ng.parse` on every invocation, and `validate_object` calls it once per field per document on the JSON storage path. Field paths are fixed when the schema is constructed, so every parse after the first repeated work already done. Parsing is the expensive half of a JSONPath lookup: jsonpath-ng builds its grammar with PLY, so one `parse("$.t0")` measures 1.83 ms against microseconds to evaluate the result. Measured here on an 11-field JSON schema with validation enabled: reparsing every field 18.683 ms/doc 53 docs/s cache warm 0.062 ms/doc 16,138 docs/s a 302x difference, or about 5 hours of PLY parsing on a one-million document load. `SearchIndex` recommends `validate_on_load=True` in its own docstring, so this sat on a signposted path. Move the parse behind an `lru_cache` keyed on the raw path. Keying before the leading-`$` normalisation means `field` and `$.field` occupy separate entries compiling to equivalent expressions, which is harmless and keeps the lookup on the cheapest path. Sharing one parsed expression across calls is safe because the expression holds no per-evaluation state: `find()` reads it and returns fresh match objects. There is a test for that specifically, since it is the assumption the cache rests on. Not addressed here: `SchemaModelGenerator.get_model_for_schema` builds its cache key by re-serialising the whole schema per document, which dominates the remaining cost on the HASH path. Fixing it means keying on schema identity plus a mutation counter, which changes what happens when a schema is mutated in place, so it wants its own change. --- redisvl/schema/validation.py | 49 ++++++++++++++++++++---- tests/unit/test_validation.py | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/redisvl/schema/validation.py b/redisvl/schema/validation.py index 3fadeab86..c21313f80 100644 --- a/redisvl/schema/validation.py +++ b/redisvl/schema/validation.py @@ -6,6 +6,7 @@ """ import json +from functools import lru_cache from typing import Any from jsonpath_ng import parse as jsonpath_parse @@ -210,6 +211,43 @@ def _validate_vector(cls, value): return type(model_name, (BaseModel,), class_dict) +@lru_cache(maxsize=512) +def _compile_json_path(path: str) -> Any: + """ + Parse a JSONPath expression, reusing the result for repeated paths. + + Args: + path: JSONPath expression, with or without the leading ``$`` + + Returns: + The parsed jsonpath-ng expression + + Notes: + Parsing is the expensive half of a JSONPath lookup: jsonpath-ng builds + its grammar with PLY, and a single ``parse("$.field")`` costs on the + order of a millisecond, against microseconds to evaluate the result. + + Field paths come from the schema and are fixed once the schema is + constructed, so on a bulk load every parse after the first is repeated + work. Caching here turns the per-document cost from one parse per field + into one dictionary lookup per field. + + The cache is keyed on the raw path, so ``field`` and ``$.field`` + occupy separate entries that compile to equivalent expressions. That is + harmless, and keying before normalisation keeps the cache lookup on the + cheapest possible path. + + Parsed expressions hold no per-evaluation state -- ``find()`` reads the + expression and returns fresh match objects -- so sharing one across + calls and threads is safe. + """ + # If path doesn't start with $, add it as per JSONPath spec + if not path.startswith("$"): + path = f"$.{path}" + + return jsonpath_parse(path) + + def extract_from_json_path(obj: dict[str, Any], path: str) -> Any: """ Extract a value from a nested JSON object using a JSONPath expression. @@ -225,14 +263,11 @@ def extract_from_json_path(obj: dict[str, Any], path: str) -> Any: This function uses the jsonpath-ng library for proper JSONPath parsing and supports the full JSONPath specification including filters, wildcards, and array indexing. - """ - # If path doesn't start with $, add it as per JSONPath spec - if not path.startswith("$"): - path = f"$.{path}" - # Parse and find the JSONPath expression - jsonpath_expr = jsonpath_parse(path) - matches = jsonpath_expr.find(obj) + Expressions are compiled through :func:`_compile_json_path`, which + caches them; see that function for why. + """ + matches = _compile_json_path(path).find(obj) # Return the first match value, or None if no matches if matches: diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 552e7b0d9..9e73a83a9 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -14,11 +14,13 @@ import pytest from redisvl.schema import IndexSchema +from redisvl.schema import validation as validation_module from redisvl.schema.fields import FieldTypes, VectorDataType from redisvl.schema.schema import StorageType from redisvl.schema.type_utils import TypeInferrer from redisvl.schema.validation import ( SchemaModelGenerator, + _compile_json_path, extract_from_json_path, validate_object, ) @@ -301,6 +303,76 @@ def test_extract_from_json_path(self, valid_json_data, path, expected_value): """Test extracting values using JSON paths.""" assert extract_from_json_path(valid_json_data, path) == expected_value + def test_repeated_paths_are_parsed_once(self, valid_json_data, monkeypatch): + """A repeated path must not re-enter the jsonpath-ng grammar. + + Parsing is ~1ms against ~microseconds to evaluate, and schema field + paths are fixed, so a bulk load that re-parsed per document per field + spent essentially all of its time in the PLY parser. + """ + _compile_json_path.cache_clear() + + calls = [] + real_parse = validation_module.jsonpath_parse + + def counting_parse(path): + calls.append(path) + return real_parse(path) + + monkeypatch.setattr(validation_module, "jsonpath_parse", counting_parse) + + for _ in range(25): + assert ( + extract_from_json_path(valid_json_data, "$.metadata.user") == "user123" + ) + + assert calls == ["$.metadata.user"] + + def test_distinct_paths_each_compile(self, valid_json_data, monkeypatch): + """Caching must not collapse distinct paths onto one expression.""" + _compile_json_path.cache_clear() + + calls = [] + real_parse = validation_module.jsonpath_parse + + def counting_parse(path): + calls.append(path) + return real_parse(path) + + monkeypatch.setattr(validation_module, "jsonpath_parse", counting_parse) + + assert extract_from_json_path(valid_json_data, "$.metadata.user") == "user123" + assert extract_from_json_path(valid_json_data, "$.metadata.rating") == 4.5 + assert extract_from_json_path(valid_json_data, "$.content.title") == ( + "Test Document" + ) + + assert len(calls) == 3 + + def test_cached_expression_is_reusable_across_objects(self): + """One cached expression must evaluate correctly against many objects. + + Guards the assumption the cache rests on: a parsed jsonpath-ng + expression holds no per-evaluation state, so sharing it is safe. + """ + _compile_json_path.cache_clear() + + for expected in ("alice", "bob", "carol"): + obj = {"metadata": {"user": expected}} + assert extract_from_json_path(obj, "$.metadata.user") == expected + + # A path that misses on one object must still miss, not leak a value + # from the previous evaluation. + assert extract_from_json_path({"metadata": {}}, "$.metadata.user") is None + + def test_leading_dollar_is_optional_and_equivalent(self, valid_json_data): + """Normalisation happens inside the cache, so both spellings work.""" + _compile_json_path.cache_clear() + + assert extract_from_json_path( + valid_json_data, "metadata.user" + ) == extract_from_json_path(valid_json_data, "$.metadata.user") + # # -------------------- CATEGORY 2: PARAMETRIZED VALIDATOR TESTS -------------------- From 725e9749eb28e92913e0910265fc830a31178f58 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 10 Sep 2026 13:52:23 +0200 Subject: [PATCH 2/2] perf: trim the cache docstring and de-duplicate its tests --- redisvl/schema/validation.py | 26 +++-------------- tests/unit/test_validation.py | 55 ++++++++++------------------------- 2 files changed, 19 insertions(+), 62 deletions(-) diff --git a/redisvl/schema/validation.py b/redisvl/schema/validation.py index c21313f80..fece418bd 100644 --- a/redisvl/schema/validation.py +++ b/redisvl/schema/validation.py @@ -216,30 +216,15 @@ def _compile_json_path(path: str) -> Any: """ Parse a JSONPath expression, reusing the result for repeated paths. + Schema field paths are fixed, and parsing costs ~1ms against microseconds to + evaluate, so validating per field re-did the expensive half every time. + Sharing one expression is safe: it holds no per-evaluation state. + Args: path: JSONPath expression, with or without the leading ``$`` Returns: The parsed jsonpath-ng expression - - Notes: - Parsing is the expensive half of a JSONPath lookup: jsonpath-ng builds - its grammar with PLY, and a single ``parse("$.field")`` costs on the - order of a millisecond, against microseconds to evaluate the result. - - Field paths come from the schema and are fixed once the schema is - constructed, so on a bulk load every parse after the first is repeated - work. Caching here turns the per-document cost from one parse per field - into one dictionary lookup per field. - - The cache is keyed on the raw path, so ``field`` and ``$.field`` - occupy separate entries that compile to equivalent expressions. That is - harmless, and keying before normalisation keeps the cache lookup on the - cheapest possible path. - - Parsed expressions hold no per-evaluation state -- ``find()`` reads the - expression and returns fresh match objects -- so sharing one across - calls and threads is safe. """ # If path doesn't start with $, add it as per JSONPath spec if not path.startswith("$"): @@ -263,9 +248,6 @@ def extract_from_json_path(obj: dict[str, Any], path: str) -> Any: This function uses the jsonpath-ng library for proper JSONPath parsing and supports the full JSONPath specification including filters, wildcards, and array indexing. - - Expressions are compiled through :func:`_compile_json_path`, which - caches them; see that function for why. """ matches = _compile_json_path(path).find(obj) diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 9e73a83a9..f582b999e 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -303,72 +303,47 @@ def test_extract_from_json_path(self, valid_json_data, path, expected_value): """Test extracting values using JSON paths.""" assert extract_from_json_path(valid_json_data, path) == expected_value - def test_repeated_paths_are_parsed_once(self, valid_json_data, monkeypatch): - """A repeated path must not re-enter the jsonpath-ng grammar. - - Parsing is ~1ms against ~microseconds to evaluate, and schema field - paths are fixed, so a bulk load that re-parsed per document per field - spent essentially all of its time in the PLY parser. - """ - _compile_json_path.cache_clear() - - calls = [] + @pytest.fixture + def parse_calls(self, monkeypatch): + """Record every path handed to jsonpath-ng's parser.""" + calls: list[str] = [] real_parse = validation_module.jsonpath_parse def counting_parse(path): calls.append(path) return real_parse(path) + _compile_json_path.cache_clear() monkeypatch.setattr(validation_module, "jsonpath_parse", counting_parse) + return calls + def test_repeated_path_compiles_once(self, valid_json_data, parse_calls): + """A repeated path compiles once, not once per call.""" for _ in range(25): assert ( extract_from_json_path(valid_json_data, "$.metadata.user") == "user123" ) - assert calls == ["$.metadata.user"] + assert parse_calls == ["$.metadata.user"] - def test_distinct_paths_each_compile(self, valid_json_data, monkeypatch): + def test_distinct_paths_each_compile(self, valid_json_data, parse_calls): """Caching must not collapse distinct paths onto one expression.""" - _compile_json_path.cache_clear() - - calls = [] - real_parse = validation_module.jsonpath_parse - - def counting_parse(path): - calls.append(path) - return real_parse(path) - - monkeypatch.setattr(validation_module, "jsonpath_parse", counting_parse) - assert extract_from_json_path(valid_json_data, "$.metadata.user") == "user123" assert extract_from_json_path(valid_json_data, "$.metadata.rating") == 4.5 - assert extract_from_json_path(valid_json_data, "$.content.title") == ( - "Test Document" - ) + assert len(parse_calls) == 2 - assert len(calls) == 3 - - def test_cached_expression_is_reusable_across_objects(self): - """One cached expression must evaluate correctly against many objects. - - Guards the assumption the cache rests on: a parsed jsonpath-ng - expression holds no per-evaluation state, so sharing it is safe. - """ + def test_shared_expression_holds_no_state(self): + """Reusing one expression must not leak values between evaluations.""" _compile_json_path.cache_clear() for expected in ("alice", "bob", "carol"): obj = {"metadata": {"user": expected}} assert extract_from_json_path(obj, "$.metadata.user") == expected - # A path that misses on one object must still miss, not leak a value - # from the previous evaluation. assert extract_from_json_path({"metadata": {}}, "$.metadata.user") is None - def test_leading_dollar_is_optional_and_equivalent(self, valid_json_data): - """Normalisation happens inside the cache, so both spellings work.""" - _compile_json_path.cache_clear() - + def test_leading_dollar_is_optional(self, valid_json_data): + """Both spellings of a path resolve to the same value.""" assert extract_from_json_path( valid_json_data, "metadata.user" ) == extract_from_json_path(valid_json_data, "$.metadata.user")