Skip to content

Rebuild JSON index when JsonIndexConfig changes; fix equals/hashCode to include all fields#18920

Open
Akanksha-kedia wants to merge 5 commits into
apache:masterfrom
Akanksha-kedia:fix/json-index-config-rebuild
Open

Rebuild JSON index when JsonIndexConfig changes; fix equals/hashCode to include all fields#18920
Akanksha-kedia wants to merge 5 commits into
apache:masterfrom
Akanksha-kedia:fix/json-index-config-rebuild

Conversation

@Akanksha-kedia

@Akanksha-kedia Akanksha-kedia commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #10506.

  • JsonIndexHandler: After creating a JSON index, the active JsonIndexConfig is serialised to JSON and stored in metadata.properties under column.<name>.jsonIndexConfig. On the next segment reload needUpdateIndices() reads the stored config, compares it with the current table config using equals(), and triggers a rebuild when they differ. Legacy segments (indexed before this change, no stored config present) are now treated as a one-time rebuild case — the index is rebuilt to backfill the stored config and catch any config drift that may have occurred during an upgrade.

  • JsonIndexConfig.equals / hashCode: Fixed to include _indexPaths and _maxBytesSize, which were previously missing. Without this fix, config changes to those two fields would be invisible to any equality-based comparison (including the new detection logic).

  • Performance: metadata.properties is loaded once per needUpdateIndices() / updateIndices() call; the persist step in updateIndices() batches all setProperty calls and writes the file once at the end rather than once per rebuilt column.

Review changes (v2)

Addressed @xiangfu0's feedback:

  • Fixed legacy-segment handling: The original guard storedConfig != null && !storedConfig.equals(currentConfig) silently skipped segments that had a JSON index but no persisted jsonIndexConfig (built before this feature). After an upgrade, changing maxLevels, includePaths, etc. could leave the old index in place and produce stale json_match results.

    Changed to properties != null && (storedConfig == null || !storedConfig.equals(currentConfig)): when metadata is readable (properties != null) but no stored config is found (storedConfig == null), the segment is treated as a one-time rebuild/backfill case. In-memory segments (properties == null) continue to skip the check.

  • Updated unit test: testNeedUpdateReturnsFalseWhenNoStoredConfigtestNeedUpdateReturnsTrueWhenNoStoredConfig with assertion flipped to assertTrue — regression test for the legacy-index case.

Test plan

  • JsonIndexHandlerTest (5 unit tests):

    • No rebuild when stored config equals current config
    • Rebuild triggered when maxLevels changes
    • Rebuild triggered when excludeArray changes
    • Rebuild triggered when maxBytesSize changes (would have silently failed before the equals fix)
    • Rebuild triggered for legacy segments with no stored config (one-time backfill)
  • Existing SegmentPreProcessorTest JSON index tests continue to pass unmodified

cc @Jackie-Jiang @xiangfu0 @yashmayya

@codecov-commenter

codecov-commenter commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 61.19403% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.22%. Comparing base (a2a27a1) to head (12118bd).
⚠️ Report is 7 commits behind head on master.

Files with missing lines Patch % Lines
...t/index/loader/invertedindex/JsonIndexHandler.java 61.19% 15 Missing and 11 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #18920      +/-   ##
============================================
+ Coverage     65.05%   65.22%   +0.16%     
- Complexity     1403     1405       +2     
============================================
  Files          3399     3418      +19     
  Lines        212808   214708    +1900     
  Branches      33568    33937     +369     
============================================
+ Hits         138443   140043    +1600     
- Misses        63223    63364     +141     
- Partials      11142    11301     +159     
Flag Coverage Δ
custom-integration1 100.00% <ø> (ø)
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-21 65.22% <61.19%> (+0.16%) ⬆️
temurin 65.22% <61.19%> (+0.16%) ⬆️
unittests 65.22% <61.19%> (+0.16%) ⬆️
unittests1 56.86% <37.31%> (-0.13%) ⬇️
unittests2 37.74% <50.74%> (+0.36%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@xiangfu0 xiangfu0 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.

Found a high-signal stale-index issue; see inline comment.

// Column exists in both existing indexes and config; check if config changed.
JsonIndexConfig currentConfig = _jsonIndexConfigs.get(column);
JsonIndexConfig storedConfig = readStoredJsonIndexConfig(column, properties);
if (storedConfig != null && !storedConfig.equals(currentConfig)) {

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.

This skips legacy segments that already have a JSON index but no persisted jsonIndexConfig. Because needProcess() is gated on this result, those segments never rebuild or backfill metadata; after upgrading, changing maxLevels/includePaths/etc. can leave the old JSON index in place and produce stale json_match results. Treat missing stored config as a one-time rebuild/backfill case and cover legacy-index plus config-change in a regression test.

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. The condition is now properties != null && (storedConfig == null || !storedConfig.equals(currentConfig)).

  • properties == null (in-memory segment) → skip rebuild, same as before.
  • properties != null && storedConfig == null (legacy segment with no persisted config) → trigger a one-time rebuild to backfill the stored config and catch any config drift.
  • properties != null && !storedConfig.equals(currentConfig) (config changed) → rebuild as before.

The test testNeedUpdateReturnsTrueWhenNoStoredConfig was also updated to assert true for legacy segments.

@Akanksha-kedia

Copy link
Copy Markdown
Contributor Author

Addressed @xiangfu0's review feedback: changed the storedConfig != null && ... guard to properties != null && (storedConfig == null || !storedConfig.equals(currentConfig)) in both needUpdateIndices and updateIndices. Legacy segments (no persisted jsonIndexConfig) now trigger a one-time rebuild to backfill config and catch any drift. Updated the unit test to assert true for the legacy-segment case. Ready for re-review.

@Akanksha-kedia

Copy link
Copy Markdown
Contributor Author

Fix: MAP column JSON re-indexing (commit a10a419)

The two CI failures (unit + integration TableIndexingTest.*_map_*,json_index) were caused by a bug in handleNonDictionaryBasedColumn that this new commit fixes.

Root cause: When the config-change or legacy-upgrade rebuild was triggered for a MAP column that already had a JSON index, handleNonDictionaryBasedColumn called forwardIndexReader.getString(docId, ctx) on the MAP forward index. MAP columns are stored as binary-encoded data (via MapUtils.serializeMap), not UTF-8 text. Decoding those bytes as UTF-8 produces garbage strings containing CTRL-CHAR(0) null bytes, which caused:

JsonParseException: Illegal character (CTRL-CHAR, code 0): only regular white space (\r, \n, \t) is allowed between tokens

Fix: For MAP columns, use forwardIndexReader.getMap(docId, ctx) + MapUtils.toString() instead of getString(), consistent with how ForwardIndexReader.getStrings(int[], ...) already handles the MAP type in its bulk-read path.

All 10 *_map_*,json_index test cases should now pass.

@Akanksha-kedia

Copy link
Copy Markdown
Contributor Author

The only failing check is Pinot Binary Compatibility Check, which is pre-existing and unrelated to this PR.

The failure is in pinot-spi — methods removed from org.apache.pinot.spi.data.readers.ColumnReader (hasNext(), nextInt(), nextLong(), etc.) and PinotDataType.toUuidBytesArray(). These are upstream changes by other contributors that already break binary compat on master.

This PR only touches pinot-segment-local (JSON index rebuild logic and JsonIndexConfig equals/hashCode). All other checks pass: Linter ✅, Unit Tests ✅, Integration Tests ✅, Quickstart ✅, Compatibility Regression ✅.

Akanksha-kedia and others added 4 commits July 12, 2026 15:25
…clude all fields

JsonIndexHandler now persists the active JsonIndexConfig into segment
metadata (column.<name>.jsonIndexConfig) after building a JSON index.
On reload, needUpdateIndices() compares the stored config against the
current table config and triggers a rebuild when they differ.
Segments indexed before this change carry no stored config and are
treated as unchanged (backward compatibility).

Also fixes JsonIndexConfig.equals/hashCode to include _indexPaths and
_maxBytesSize, which were previously silently excluded, causing config
changes to those fields to be invisible to any equality-based
comparison.

Resolves: apache#10506
Treat null storedConfig (legacy segment, pre-config-persistence) as a
one-time rebuild trigger rather than silently skipping. Without this fix,
segments with an existing JSON index but no persisted jsonIndexConfig were
never rebuilt after config changes (maxLevels, includePaths, etc.), leaving
stale json_match results after an upgrade.

Changed condition from `storedConfig != null && !storedConfig.equals(...)` to
`properties != null && (storedConfig == null || !storedConfig.equals(...))`
in both needUpdateIndices() and updateIndices(). When properties is null
(in-memory segment) the check is still skipped entirely.

Updated test to assert TRUE for the legacy-segment case (regression test for
the reported issue).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tring()

When JsonIndexHandler re-builds a JSON index for an existing MAP column
(triggered by the config-change or legacy-upgrade rebuild), handleNon
DictionaryBasedColumn was calling forwardIndexReader.getString() on MAP
column values.  MAP columns store binary-encoded data (via MapUtils.
serializeMap), not UTF-8 text; decoding those bytes as UTF-8 produces
garbage strings containing null bytes (CTRL-CHAR code 0), which cause
  JsonParseException: Illegal character (CTRL-CHAR, code 0)
when the JSON index creator tries to index them.

Fix: detect MAP data type and call getMap() + MapUtils.toString() instead,
consistent with how ForwardIndexReader.getStrings(int[], ...) already
handles MAP in its bulk-read path (line 427 of ForwardIndexReader.java).

Fixes failing TableIndexingTest cases for all *_map_*,json_index
combinations introduced by the legacy-rebuild trigger in the previous
commit.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@Akanksha-kedia
Akanksha-kedia force-pushed the fix/json-index-config-rebuild branch from d6b3c9c to 1d251ea Compare July 12, 2026 09:55

@xiangfu0 xiangfu0 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.

Found one high-severity issue; see inline comment.

// treat as a one-time rebuild to backfill the stored config and catch any config drift.
JsonIndexConfig currentConfig = _jsonIndexConfigs.get(column);
JsonIndexConfig storedConfig = readStoredJsonIndexConfig(column, properties);
if (properties != null && (storedConfig == null || !storedConfig.equals(currentConfig))) {

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.

Treating storedConfig == null as a rebuild forces every pre-metadata JSON index through createJsonIndexForColumn(). Existing segments can have a JSON index on a forward-index-disabled column without the dictionary/inverted pair required by createForwardIndexIfNeeded() because JsonIndexType.requiresDictionary() is false. Those segments loaded before, but this path now removes the existing JSON index and then fails reload even when table config did not change. Please only backfill this metadata when the forward index is present/reconstructable, or preserve the existing index and require refresh/backfill for this legacy case.

@Akanksha-kedia

Copy link
Copy Markdown
Contributor Author

@xiangfu0 @shounakmk219 would appreciate a review when you get a chance. This PR makes JsonIndexHandler detect when JSON index config changes (e.g. maxLevels, excludedFields) and rebuilds the index on segment reload, consistent with how range and vector index handlers detect config drift.

@Akanksha-kedia

Copy link
Copy Markdown
Contributor Author

@xiangfu0 @gortiz — would appreciate a review when you get a chance 🙏

…ility

JsonIndexHandler previously treated storedConfig == null (index predates
config persistence) as an unconditional rebuild trigger. Segments that have
a JSON index on a forward-index-disabled column with no dictionary/inverted
pair cannot call createForwardIndexIfNeeded() — the Preconditions.checkState
inside BaseIndexHandler throws with 'must have a dictionary to regenerate'.

Fix: introduce isForwardIndexAvailable() and use it to guard the legacy-
backfill path. Only trigger rebuild when the forward index already exists or
can be reconstructed from dictionary + inverted index. When the forward index
is absent and unrecoverable, the existing JSON index is preserved untouched
and no rebuild is attempted.

The configChanged path (storedConfig != null && !equal) is unchanged:
that path was pre-existing and requires an explicit segment refresh when
the forward index is unrecoverable.

Tests: split the single legacy-segment test into three cases:
- legacy with forward index present → rebuild (backfill)
- legacy with forward index absent and no dict/inverted → no rebuild (preserve)
- legacy with no forward index but dict+inverted reconstructable → rebuild
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.

Updating JSON index config

3 participants