Skip to content

[UUID 4/8] Server-side predicate evaluation for the logical UUID type - #18872

Merged
xiangfu0 merged 2 commits into
apache:masterfrom
xiangfu0:uuid-split/04-sse-predicates-cast
Aug 8, 2026
Merged

[UUID 4/8] Server-side predicate evaluation for the logical UUID type#18872
xiangfu0 merged 2 commits into
apache:masterfrom
xiangfu0:uuid-split/04-sse-predicates-cast

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Rebased onto master now that the three split-out PRs have merged: #19181 (CAST), #19182 (bloom filter) and #19183 (transform functions). This PR is the remaining piece — the predicate evaluators — at 12 files, +443, with no overlap against what landed (master has no case UUID in any of these factories).

What

UUID handling in the predicate evaluators, so =, !=, IN, NOT IN and range predicates work against a UUID column on both the raw and the dictionary path.

Approach

UUID follows the pattern TIMESTAMP already uses: a logical type whose stored type does the work. The literal is parsed to its 16-byte stored form once, when the evaluator is built, and from there the existing BYTES evaluators apply unchanged. There is no per-value conversion in the scan loop.

Why the dictionary path needs no UUID branch

Dictionary#getStoredValue returns hex for a UUID column, and indexOf(String) hex-decodes — so the existing String-keyed lookup is already correct, and an added UUID branch would be dead code. PredicateUtils renders the literal into that hex form, which is what those String-typed lookup APIs consume.

This is the same principle #19182 settled for bloom filters: at a String-typed API boundary, a UUID is carried as its stored BYTES rendering (lowercase hex), not as the canonical dashed form. Storage itself remains the raw 16 bytes in both cases.

Testing

UuidDictionaryPredicateEvaluatorTest covers the dictionary path across all five predicate kinds. The three NoDictionary*PredicateEvaluatorTest classes cover the raw path. Between them: canonical, dashless and mixed-case input forms, and rejection of malformed literals.

About this PR

Part of the #18140 UUID split. Depends on UuidUtils (#18869) and the UUID stored type (#18870), both on master.

@codecov-commenter

codecov-commenter commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 66.64%. Comparing base (e51b4e4) to head (577cc97).

Files with missing lines Patch % Lines
...mon/request/context/predicate/BaseInPredicate.java 87.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #18872      +/-   ##
============================================
- Coverage     66.65%   66.64%   -0.02%     
  Complexity     1423     1423              
============================================
  Files          3443     3443              
  Lines        218632   218663      +31     
  Branches      34793    34801       +8     
============================================
- Hits         145726   145722       -4     
- Misses        61192    61228      +36     
+ Partials      11714    11713       -1     
Flag Coverage Δ
custom-integration1 100.00% <ø> (ø)
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 66.64% <96.77%> (-0.02%) ⬇️
temurin 66.64% <96.77%> (-0.02%) ⬇️
unittests 66.63% <96.77%> (-0.02%) ⬇️
unittests1 57.31% <96.77%> (+0.08%) ⬆️
unittests2 38.89% <0.00%> (-0.01%) ⬇️

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 force-pushed the uuid-split/04-sse-predicates-cast branch 7 times, most recently from 25e3d0c to 8f7a1d6 Compare July 7, 2026 07:08
@xiangfu0
xiangfu0 force-pushed the uuid-split/04-sse-predicates-cast branch 11 times, most recently from 4969a9a to b501df6 Compare July 14, 2026 08:03
@xiangfu0
xiangfu0 force-pushed the uuid-split/04-sse-predicates-cast branch 3 times, most recently from ba8e60d to 050d032 Compare August 3, 2026 09:23
Comment thread pinot-spi/src/main/java/org/apache/pinot/spi/utils/ArrayCopyUtils.java Outdated
@xiangfu0
xiangfu0 force-pushed the uuid-split/04-sse-predicates-cast branch 3 times, most recently from 80120dc to ce1d3d0 Compare August 3, 2026 18:57

Copilot AI 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.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated no new comments.

Suppressed comments (5)

pinot-spi/src/main/java/org/apache/pinot/spi/utils/ArrayCopyUtils.java:1

  • These are public utility methods but the new comments use /// and Markdown-style references (e.g. [#copyFromUuid(...)]) which won’t be picked up by Javadoc tooling and won’t resolve as links. Prefer standard Javadoc (/** ... */) and {@link ArrayCopyUtils#copyFromUuid(...)} so IDEs and generated docs correctly hyperlink and surface the documentation.
    pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/CaseTransformFunction.java:854
  • For UUID-typed CASE with a bare STRING literal branch, this allocates a fresh byte[][] for every block evaluation. That can add noticeable GC pressure in tight loops. Consider reusing an existing per-instance buffer (e.g., initialize _bytesValuesSV and fill it) or caching the parsed UUID bytes inside the LiteralTransformFunction/CASE branch metadata so repeated calls don’t allocate a new array each time.
  private byte[][] getBytesValues(TransformFunction transformFunction, ValueBlock valueBlock) {
    if (_resultMetadata.getDataType() != DataType.UUID || !(transformFunction instanceof LiteralTransformFunction)) {
      return transformFunction.transformToBytesValuesSV(valueBlock);
    }
    LiteralTransformFunction literalTransformFunction = (LiteralTransformFunction) transformFunction;
    if (literalTransformFunction.isNull()
        || literalTransformFunction.getResultMetadata().getDataType() != DataType.STRING) {
      return transformFunction.transformToBytesValuesSV(valueBlock);
    }
    int numDocs = valueBlock.getNumDocs();
    byte[][] bytesValues = new byte[numDocs][];
    byte[] uuidBytes = UuidUtils.toBytes(literalTransformFunction.getStringLiteral());
    Arrays.fill(bytesValues, uuidBytes);
    return bytesValues;
  }

pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/CastTransformFunction.java:107

  • These are user-facing query validation failures, but Preconditions.checkState(...) throws IllegalStateException, which can surface as an internal error depending on where it’s caught/wrapped. For query-type errors, prefer throwing BadQueryRequestException (or the project’s standard query validation exception) to keep error classification consistent and to avoid treating invalid queries as server faults.
        case "UUID":
          Preconditions.checkState(sourceSV, "Cannot cast from MV to UUID");
          _resultMetadata = UUID_SV_NO_DICTIONARY_METADATA;
          break;
        case "UUID_ARRAY":
          Preconditions.checkState(!sourceSV, "Cannot cast from SV to UUID_ARRAY");
          _resultMetadata = UUID_MV_NO_DICTIONARY_METADATA;
          break;

pinot-common/src/test/java/org/apache/pinot/common/request/context/RequestContextUtilsTest.java:103

  • This test mutates the JVM-wide default Locale, which can cause flaky failures if tests run in parallel (other tests can observe the modified default during this window). To make it robust, consider marking the test/class as single-threaded/non-parallel in TestNG, or restructuring the code under test to accept an explicit Locale so the test doesn’t need to change global process state.
    Locale originalDefault = Locale.getDefault();
    Locale.setDefault(Locale.forLanguageTag("tr-TR"));
    try {
      FilterContext filter = compileFilter("uuidCol = CAST('" + UUID_1 + "' AS uuid)");

      assertEquals(filter.getType(), FilterContext.Type.PREDICATE);
      EqPredicate predicate = (EqPredicate) filter.getPredicate();
      assertEquals(predicate.getValue(), UUID_1_STORED);
    } finally {
      Locale.setDefault(originalDefault);
    }

pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/CaseTransformFunction.java:223

  • The newly added UUID literal validation drops the underlying exception cause, which can make debugging malformed literals harder (e.g., distinguishing parse failures vs length issues). Consider chaining the caught exception as the cause (e.g., new IllegalArgumentException(..., e)) so logs/error handlers retain the original failure details.
      case UUID:
        try {
          UuidUtils.toBytes(literal);
        } catch (Exception e) {
          throw new IllegalArgumentException("Invalid literal: " + literal + " for UUID");
        }
        break;

Adds UUID handling to the predicate evaluators, so =, !=, IN, NOT IN and range
predicates work against a UUID column on both the raw and the dictionary path.

UUID follows the pattern TIMESTAMP already uses: a logical type whose stored
type does the work. The literal is parsed to its 16-byte stored form once, when
the evaluator is built, and from there the existing BYTES evaluators apply --
no per-value conversion in the scan loop.

The dictionary path needs no UUID branch: Dictionary#getStoredValue returns hex
for a UUID column and indexOf(String) hex-decodes, so the existing String-keyed
lookup is already correct. PredicateUtils renders the literal to that hex form
for those String-typed lookup APIs.

Split into apache#19181 (CAST), apache#19182 (bloom filter pruning) and apache#19183 (transform
functions); this PR is now just the predicate evaluators.

Copilot AI 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.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (3)

pinot-core/src/test/java/org/apache/pinot/core/operator/filter/predicate/NoDictionaryInPredicateEvaluatorTest.java:410

  • The comment claims the raw BYTES/UUID IN evaluators are keyed directly on byte[] to avoid per-row wrapping, but BytesRawValueBased(In|NotIn)PredicateEvaluator.applySV(byte[]) currently does contains(new ByteArray(value)). Please update this comment to match the current implementation (or switch the evaluator to a Set<byte[]> + ByteArrays.HASH_STRATEGY if allocation-free lookup is desired).
  /// The BYTES/UUID raw evaluators key their matching set on the raw `byte[]` so that `applySV` does not
  /// wrap every scanned value. That only works if the set compares by *content*: with identity semantics a
  /// scanned array would never match a predicate array, and IN would silently return nothing while NOT IN returned
  /// everything. Probe with arrays that are equal but deliberately not the same instance.

pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/InPredicateEvaluatorFactory.java:149

  • The UUID IN path reuses BytesRawValueBasedInPredicateEvaluator, which currently allocates a new ByteArray(value) on every applySV(byte[]) call (see the evaluator implementation later in this file). For raw UUID columns this can create significant allocation/GC pressure during scans. Consider changing the bytes IN evaluator to key the set on raw byte[] with value semantics (e.g., ObjectOpenCustomHashSet<byte[]> + ByteArrays.HASH_STRATEGY) so applySV can probe with the already-scanned byte[] without wrapping.
      // UUID is a logical type stored as 16 raw bytes, so -- like TIMESTAMP over LONG above -- convert the
      // literals to their stored form and reuse the stored-type evaluator.
      case UUID: {

pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/NotInPredicateEvaluatorFactory.java:149

  • The UUID NOT IN path reuses BytesRawValueBasedNotInPredicateEvaluator, which currently allocates a new ByteArray(value) on every applySV(byte[]) call (see the evaluator implementation later in this file). For raw UUID columns this can create significant allocation/GC pressure during scans. Consider changing the bytes NOT IN evaluator to key the set on raw byte[] with value semantics (e.g., ObjectOpenCustomHashSet<byte[]> + ByteArrays.HASH_STRATEGY) so applySV can probe with the already-scanned byte[] without wrapping.
      // UUID is a logical type stored as 16 raw bytes, so -- like TIMESTAMP over LONG above -- convert the
      // literals to their stored form and reuse the stored-type evaluator.
      case UUID: {

The UUID branch accepted several input forms via UuidUtils.toBytes(Object),
which was inconsistent with every other branch -- they each cast to one type.

The input is in fact deterministic. GroupByDataTableReducer runs every column
through ColumnDataType#convert immediately before calling isMatch, and convert
returns UuidUtils.toUUID(...) for UUID, so the value is always a
java.util.UUID. The other reduce path cannot deliver a UUID here at all:
getConvertedKey has no UUID case and throws for it.

Adds testHavingFilterOnUuidColumn, which pins the contract -- this line was
previously untested in either direction. Verified it reaches the UUID branch
rather than passing vacuously.
@xiangfu0

xiangfu0 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Documentation follow-up: pinot-contrib/pinot-docs#970 (merged).

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

Labels

feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants