Skip to content

fix: re-sort extracted JSON path values before training value-ordered indexes#7771

Open
sohumt123 wants to merge 2 commits into
lance-format:mainfrom
sohumt123:fix/json-float-index-resort
Open

fix: re-sort extracted JSON path values before training value-ordered indexes#7771
sohumt123 wants to merge 2 commits into
lance-format:mainfrom
sohumt123:fix/json-float-index-resort

Conversation

@sohumt123

@sohumt123 sohumt123 commented Jul 14, 2026

Copy link
Copy Markdown

Problem

A JSON-path scalar index (index_type="json" with target_index_type="btree") over a pa.json_() column returns incorrect filter results when the indexed path holds float64 values that are not exactly representable (e.g. 40.1, -3.2). Range predicates come back empty and equality misses rows, while the same predicate over an un-indexed scan, an integer path, or exactly-representable floats is correct.

rows = [{"latitude": 10.5}, {"latitude": 40.1}, {"latitude": -3.2}]
# ... create a json/btree index on "latitude" ...
ds.to_table(filter="json_get_float(data,'latitude') > 0")     # -> []   (expected [1, 2])
ds.to_table(filter="json_get_float(data,'latitude') = 40.1")  # -> []   (expected [2])

Root cause

When training a JSON index, the scanner can only order the input on the raw JSONB bytes of the source column (scan_training_data in rust/lance/src/index/scalar.rs sorts with ColumnOrdering::asc_nulls_first over the binary column when the criteria ordering is Values). The JSON plugin then extracts the requested path and converts it to a typed value column, but hands that column to the target trainer without re-sorting it.

For floats whose byte encoding order does not match their numeric order, the typed column arrives out of order. The btree trainer assumes value-ordering and takes each page's min/max from the first and last rows (btree.rs, min_val = value(0), max_val = value(num_rows - 1)), so it records inverted statistics (e.g. min = 40.1 > max = -3.2). Range pruning and equality lookups then discard pages that actually contain matches, producing silently wrong results.

This is distinct from #7072 (path-based sub-parser routing); that fix corrected integer/equality routing but the float ordering problem remained.

Fix

In JsonIndexPlugin::train_index, after the typed value stream is produced and before it is handed to the target trainer, re-sort it ascending by the value column when the target requires value-ordering (target_request.criteria().ordering == TrainingOrdering::Values). This reuses the existing SortExec / PhysicalSortExpr pattern already used by the btree merge path (nulls_first: true, execute_plan with use_spilling: true). Ordering-agnostic targets (e.g. bitmap) take the untouched stream, so they are unaffected.

The change is confined to rust/lance-index/src/scalar/json.rs.

Testing

Added test_json_float_btree_index_non_exact_floats to the existing test module in json.rs. It trains a real JSON/btree index through the plugin trainer and searches the loaded index:

  • Case A — the issue's [10.5, 40.1, -3.2] values, fed in raw-JSONB-byte order to mirror scan_training_data. Asserts > 0 -> {0,1}, >= 10.5 -> {0,1}, = 40.1 -> {1}, = 10.5 -> {0}, < 100 -> {0,1,2}.
  • Case B — a deterministic inversion [40.1 -> row0, -3.2 -> row1] fed unsorted, which fails pre-fix regardless of the exact JSONB byte encoding.

Verified failing-before / passing-after with the test in place:

# before the fix
test scalar::json::tests::test_json_float_btree_index_non_exact_floats ... FAILED
  left:  Exact({})                     # `> 0` returned nothing
  right: Exact({0, 1})

# after the fix
test scalar::json::tests::test_json_float_btree_index_non_exact_floats ... ok
test scalar::json::tests::test_json_extract_with_type_info ... ok

cargo fmt --all -- --check and cargo clippy -p lance-index --tests -- -D warnings are clean.

Notes

  • A possible follow-up (out of scope here to keep the change focused) is to relax JsonTrainingRequest::criteria() to TrainingOrdering::None, which would let the upstream scanner skip the now-redundant raw-byte sort while this plugin does the value sort. That widens the blast radius, so I left it out.
  • This qualifies for the critical-fix label: it produces silent wrong query results rather than an error.

Closes #7485

Summary by CodeRabbit

  • Bug Fixes
    • Improved JSON index training when value-ordered input is required, ensuring consistent ordering during index construction.
    • Fixed incorrect search results for JSON-path indexes involving non-exact floating-point values.
    • Added regression coverage to validate index behavior across varying input feed orders and float precision scenarios.

… indexes

A JSON-path scalar index whose target is a value-ordered index (e.g. btree)
returned wrong results for non-exactly-representable float64 values: range
predicates came back empty and equality missed rows.

The training data reaching the JSON plugin is sorted by the raw JSONB bytes of
the source column (that is all the scanner can order on), then the requested
path is extracted and converted to a typed value column. The byte order of the
JSON encoding does not match the numeric order of the extracted values for
floats such as 40.1 or -3.2, so the typed column arrives out of order. The btree
trainer assumes value ordering and derives each page's min/max from the first
and last rows, so it recorded inverted statistics (min > max) and silently
pruned matching pages.

Re-sort the extracted value column ascending before handing it to the target
trainer, gated on the target requiring TrainingOrdering::Values so
ordering-agnostic targets (e.g. bitmap) are unaffected.

Closes lance-format#7485
@github-actions github-actions Bot added A-index Vector index, linalg, tokenizer bug Something isn't working labels Jul 14, 2026
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f634ca0b-d768-426c-a534-434dd4d22334

📥 Commits

Reviewing files that changed from the base of the PR and between e162373 and 181b5d6.

📒 Files selected for processing (1)
  • rust/lance-index/src/scalar/json.rs

📝 Walkthrough

Walkthrough

JSON index training now sorts extracted values when the target trainer requires value ordering. New regression coverage validates JSON-path float btree indexes with non-exact floating-point values and adversarial feed orders.

Changes

JSON index ordering

Layer / File(s) Summary
Conditional training stream ordering
rust/lance-index/src/scalar/json.rs
JsonIndexPlugin::train_index sorts the extracted value column for ordering-sensitive trainers and preserves the original stream for ordering-agnostic trainers.
Non-exact float regression coverage
rust/lance-index/src/scalar/json.rs
Adds float index training and search tests covering range and equality queries with deliberately ordered input values.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: ddupg

Sequence Diagram(s)

sequenceDiagram
  participant JsonIndexPlugin
  participant SortExec
  participant TargetTrainer
  JsonIndexPlugin->>SortExec: Sort extracted values when ordering is required
  SortExec->>TargetTrainer: Pass ordered training stream
  JsonIndexPlugin->>TargetTrainer: Pass unchanged stream otherwise
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: re-sorting extracted JSON path values before training value-ordered indexes.
Linked Issues check ✅ Passed The PR fixes the reported float JSON-path index ordering bug by re-sorting values for TrainingOrdering::Values and adds a regression test.
Out of Scope Changes check ✅ Passed The changes stay focused on the JSON index training fix and its regression coverage, with no obvious unrelated scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance-index/src/scalar/json.rs`:
- Around line 769-775: Track the bounded-memory limitation in the value-ordered
JSON index training path: convert_stream_by_type materializes all converted
batches before execute_plan runs SortExec, so use_spilling only constrains
sorting. Update the implementation or follow-up tracking so this path does not
imply end-to-end bounded memory, while preserving the existing sort behavior.
- Around line 1131-1184: Replace the manual loops over cases_a and cases_b with
rstest parameterized test cases, using named #[case::{name}(...)] entries for
each query and expected row set. Keep the existing
train_and_search_json_float_index invocation and exact SearchResult assertions,
including distinct case coverage and readable failure context.
- Around line 754-775: Update the sorting setup in the TrainingOrdering::Values
branch to pass a LexOrdering to SortExec::new, constructing it with
LexOrdering::new(vec![sort_expr]) instead of relying on [sort_expr].into().
Ensure the required LexOrdering import is available.
- Around line 1050-1063: Replace the manual schema and RecordBatch::try_new
construction in this test with arrow_array::record_batch!, building the
LargeBinary value column from jsonb and the UInt64 row-ID column from row_ids.
Preserve the existing column names, types, nullability, and row ordering.
- Around line 754-778: Update the converted_stream ordering logic to match
explicitly on target_request.criteria().ordering, including distinct branches
for Values, Addresses, and None. Preserve value sorting for
TrainingOrdering::Values, explicitly handle TrainingOrdering::Addresses
according to the address-ordering contract used by BloomFilter and ZoneMap, and
retain the current unsorted behavior only for None.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6a77a916-2423-4e89-afe4-62fa1b672f8a

📥 Commits

Reviewing files that changed from the base of the PR and between 2887837 and e162373.

📒 Files selected for processing (1)
  • rust/lance-index/src/scalar/json.rs

Comment thread rust/lance-index/src/scalar/json.rs Outdated
Comment on lines +754 to +775
let converted_stream = if target_request.criteria().ordering == TrainingOrdering::Values {
let input = Arc::new(OneShotExec::new(converted_stream));
let value_column_idx = input
.schema()
.column_with_name(VALUE_COLUMN_NAME)
.expect_ok()?
.0;
let sort_expr = PhysicalSortExpr {
expr: Arc::new(Column::new(VALUE_COLUMN_NAME, value_column_idx)),
options: SortOptions {
descending: false,
nulls_first: true,
},
};
let sorted = Arc::new(SortExec::new([sort_expr].into(), input));
execute_plan(
sorted,
LanceExecutionOptions {
use_spilling: true,
..Default::default()
},
)?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does the DataFusion LexOrdering struct (post the "Convert LexOrdering type to struct" change) implement From<[PhysicalSortExpr; 1]> or From<Vec<PhysicalSortExpr>>, and what is the recommended way to build a single-column LexOrdering?

💡 Result:

The DataFusion LexOrdering struct does not implement From<[PhysicalSortExpr; 1]> or From<Vec> [1][2]. Following the change that converted LexOrdering from a type alias to a struct, the primary and recommended way to construct a LexOrdering is by using the LexOrdering::new(exprs) associated function [1]. LexOrdering::new takes an impl IntoIterator<Item = PhysicalSortExpr> and returns an Option [1]. It returns None if the provided iterator is empty, as LexOrdering is designed to be non-degenerate (i.e., it must contain at least one element) [1]. To build a single-column LexOrdering, you can pass a single-element collection (such as a vector or an array) to this function: // Recommended way to build a single-column LexOrdering let sort_expr = PhysicalSortExpr {... }; let ordering = LexOrdering::new(vec![sort_expr]).expect("LexOrdering requires at least one expression"); Alternatively, you can initialize a LexOrdering with multiple expressions, which is similarly handled via the same constructor [1]. Because LexOrdering is an opaque struct with private fields, you cannot instantiate it directly (e.g., via struct literal syntax) [1]. top_results: 1, 2, 3, 8

Citations:


🏁 Script executed:

set -euo pipefail

printf 'Pinned DataFusion versions in workspace:\n'
rg -n '^name = "datafusion(-physical-expr)?\"|^version = ' Cargo.lock Cargo.toml rust/**/Cargo.toml 2>/dev/null | sed -n '1,120p'

printf '\nCall site context:\n'
sed -n '744,782p' rust/lance-index/src/scalar/json.rs

Repository: lance-format/lance

Length of output: 197


🏁 Script executed:

set -euo pipefail

echo 'Pinned DataFusion versions:'
git ls-files '**/Cargo.lock' '**/Cargo.toml' | sed -n '1,120p'
echo
rg -n '^(name = "datafusion(-physical-expr)?"|version = )' Cargo.lock rust/**/Cargo.toml Cargo.toml 2>/dev/null || true
echo
echo 'Relevant code:'
sed -n '744,782p' rust/lance-index/src/scalar/json.rs

Repository: lance-format/lance

Length of output: 34765


🌐 Web query:

DataFusion 53.1.0 LexOrdering From<[PhysicalSortExpr; 1]> From<Vec<PhysicalSortExpr>> SortExec::new docs

💡 Result:

In DataFusion 53.1.0, the structures LexOrdering and SortExec serve distinct roles in managing and executing sorted data streams [1][2]. LexOrdering LexOrdering represents a lexicographical ordering, consisting of a vector of PhysicalSortExpr objects [1][3]. It enforces two primary invariants: 1. It is non-degenerate, containing at least one element [1][3]. 2. It is duplicate-free, meaning it does not contain multiple entries for the same column [1][3]. Because LexOrdering does not implement From<[PhysicalSortExpr; 1]> or From<Vec> directly, you should use its new constructor, which accepts an impl IntoIterator<Item = PhysicalSortExpr> and returns an Option (returning None if the input is empty) [1][3]. SortExec::new SortExec is the execution plan responsible for sorting data. Its constructor is defined as follows [2][4]: pub fn new(expr: LexOrdering, input: Arc) -> Self This function is infallible and creates a new sort execution plan that produces a single, sorted output partition [2][5]. When called, it automatically computes plan properties such as the common sort prefix [5][4]. Additional modifications, such as setting a fetch limit or preserving input partitioning, are typically handled via builder-style methods like.with_fetch(fetch) and.with_preserve_partitioning(true) [5][6][4].

Citations:


Use LexOrdering::new(...) here. SortExec::new takes a LexOrdering, so [sort_expr].into() doesn’t match DataFusion 53.1.0; build the ordering with LexOrdering::new(vec![sort_expr]) instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/json.rs` around lines 754 - 775, Update the
sorting setup in the TrainingOrdering::Values branch to pass a LexOrdering to
SortExec::new, constructing it with LexOrdering::new(vec![sort_expr]) instead of
relying on [sort_expr].into(). Ensure the required LexOrdering import is
available.

Comment thread rust/lance-index/src/scalar/json.rs Outdated
Comment thread rust/lance-index/src/scalar/json.rs Outdated
Comment on lines +1050 to +1063
let schema = Arc::new(Schema::new(vec![
Field::new(VALUE_COLUMN_NAME, DataType::LargeBinary, true),
Field::new(ROW_ID, DataType::UInt64, false),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(LargeBinaryArray::from(
jsonb.iter().map(|v| v.as_deref()).collect::<Vec<_>>(),
)) as ArrayRef,
Arc::new(UInt64Array::from(row_ids)) as ArrayRef,
],
)
.unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does arrow-array's record_batch! macro support building a LargeBinary column from a Vec<&[u8]> variable in the arrow-rs version used by lance?

💡 Result:

Yes, the record_batch! macro in arrow-rs supports building a LargeBinary column from a variable (such as a Vec<&[u8]>) [1][2][3]. Following recent updates to the arrow-rs library (specifically in pull request #9522, merged in March 2026), the record_batch! macro was extended to accept both literal values and variables/expressions for array data, including support for LargeBinary types [1][4]. You can use it as follows: let large_binary_values = vec![b"some_data".as_slice]; let record_batch = record_batch!( ("my_col", LargeBinary, large_binary_values)).unwrap; In this syntax, the macro correctly handles the variable by utilizing LargeBinaryArray::from_vec [1][2][3]. As long as your version of arrow-rs includes these updates (which post-date March 2026), this functionality will be available [1][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and inspect the target range.
wc -l rust/lance-index/src/scalar/json.rs
sed -n '1015,1080p' rust/lance-index/src/scalar/json.rs

# Find any existing record_batch! usage in the repo for comparison.
rg -n "record_batch!\(" rust -g '*.rs' | head -n 40

Repository: lance-format/lance

Length of output: 6921


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the surrounding function and test context.
sed -n '990,1085p' rust/lance-index/src/scalar/json.rs

# Check whether this file contains cfg(test) sections around the target lines.
rg -n "cfg\\(test\\)|record_batch!|RecordBatch::try_new|LargeBinaryArray::from" rust/lance-index/src/scalar/json.rs

Repository: lance-format/lance

Length of output: 4492


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show a few representative record_batch! usages, especially with non-primitive types.
sed -n '430,455p' rust/lance/src/dataset/tests/dataset_migrations.rs
sed -n '3490,3535p' rust/lance/src/dataset/write.rs
sed -n '3735,3765p' rust/lance/src/dataset/write.rs

Repository: lance-format/lance

Length of output: 3694


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "record_batch!\\(.*LargeBinary|LargeBinary.*record_batch!|record_batch!\\(" rust -g '*.rs' | head -n 80

Repository: lance-format/lance

Length of output: 6024


Use arrow_array::record_batch!() here
This test can build the LargeBinary and UInt64 columns directly from jsonb and row_ids, removing the manual Schema / RecordBatch::try_new boilerplate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/json.rs` around lines 1050 - 1063, Replace the
manual schema and RecordBatch::try_new construction in this test with
arrow_array::record_batch!, building the LargeBinary value column from jsonb and
the UInt64 row-ID column from row_ids. Preserve the existing column names,
types, nullability, and row ordering.

Source: Coding guidelines

Comment thread rust/lance-index/src/scalar/json.rs Outdated
Make the ordering decision in JsonIndexPlugin::train_index an explicit
match on TrainingOrdering with distinct arms:
- Values: re-sort the extracted value column by value (unchanged behavior).
- Addresses: pass the stream through untouched. criteria() delegates the
  target's criteria verbatim, so the scanner already supplies address-ordered
  rows and the extraction/conversion pipeline preserves row order; re-sorting
  by value here would violate the Addresses contract.
- None: leave unsorted.

Document that use_spilling only bounds this SortExec, since the extraction
and conversion stages already materialize all batches in memory upstream.

Refactor the non-exact-float regression test to rstest parameterized #[case]s
(repo test idiom), keeping the same train/search calls and exact SearchResult
assertions and preserving the case A / case B feed-order asymmetry. Serialize
the cases with a Mutex so their spilling sorts do not contend for the shared
cached DataFusion memory pool, matching the original sequential behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-index Vector index, linalg, tokenizer bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSON-path scalar index returns wrong results for non-exact float values (ranges empty; equality misses)

1 participant