SteNicholas commented on code in PR #471:
URL: https://github.com/apache/paimon-cpp/pull/471#discussion_r4467266386


##########
src/paimon/format/vortex/vortex_file_batch_reader.cpp:
##########
@@ -232,26 +149,152 @@ std::shared_ptr<arrow::ArrayData> 
AlignTimestampTimezones(
 
 }  // namespace
 
+Result<VortexFileBatchReader::SelectionState> 
VortexFileBatchReader::SelectionState::Create(
+    const std::optional<RoaringBitmap32>& bitmap, uint64_t total_rows) {
+    SelectionState state;
+    state.has_selection_ = bitmap.has_value();
+    state.selected_row_count_ = total_rows;
+    if (bitmap) {
+        const int32_t cardinality = bitmap->Cardinality();
+        if (cardinality < 0 || static_cast<uint64_t>(cardinality) > 
total_rows) {
+            return Status::Invalid("Vortex selection cardinality exceeds the 
file row count");
+        }
+        state.selected_row_count_ = static_cast<uint64_t>(cardinality);
+        state.excludes_ = state.selected_row_count_ > total_rows / 2;
+        state.indices_.reserve(static_cast<size_t>(
+            state.excludes_ ? total_rows - state.selected_row_count_ : 
state.selected_row_count_));
+        uint64_t next_row = 0;
+        const auto end = bitmap->End();
+        for (auto iter = bitmap->Begin(); iter != end; ++iter) {

Review Comment:
   **Dense selections still enumerate every surviving row before the first 
batch.** Storing only exclusion indices fixes the retained-memory expansion, 
but this loop always walks the original selection. A file with one deleted row 
still visits almost every physical row, and an all-selected bitmap also incurs 
the full traversal before any pushed predicate can run.
   
   In an isolated `-O2` microbenchmark using this repository's bundled Roaring 
implementation on local arm64, constructing the exclusion vector for 100 
million rows with one deletion took about **133 ms** with this loop, versus 
about **0.2 ms** when copying/flipping the compressed bitmap and enumerating 
the excluded side. This measures selection construction, not an end-to-end scan.
   
   **Please validate the bounds and then enumerate a compressed complement for 
dense selections**, with an appropriate all-selected fast path. Large 
all-selected and single-deletion benchmarks would guard this first-batch 
latency.



##########
src/paimon/format/vortex/vortex_file_batch_reader.h:
##########
@@ -96,30 +155,26 @@ class VortexFileBatchReader : public FileBatchReader {
     VxSessionPtr session_;
     VxDataSourcePtr data_source_;
     VxScanPtr scan_;
-    // The file schema with Vortex's string/binary view types normalized to 
the standard types; this
-    // is what GetFileSchema() reports to paimon.
+    // Full file schema with standard Arrow types, as reported by 
GetFileSchema().
     std::shared_ptr<arrow::Schema> file_schema_;
-    // The RAW view-typed struct that Vortex's Arrow export produces. 
NextBatch imports each scanned
-    // batch as this type (so it must match the C export's buffer layout), 
then NormalizeViewArray
-    // rewrites it to file_schema_'s types. Keeping this view-typed is 
load-bearing: normalizing it
-    // would make ImportArray misread the buffers.
-    std::shared_ptr<arrow::DataType> struct_type_;
     // The schema NextBatch must output, set by SetReadSchema (the projected 
read schema). Null
     // until SetReadSchema is called, in which case the full file schema is 
returned.
     std::shared_ptr<arrow::Schema> read_schema_;
     uint64_t total_rows_ = 0;
 
     ::ArrowArrayStream current_stream_{};
+    // Actual scan output schema, including projection and any hidden physical 
row index.
+    std::shared_ptr<arrow::Schema> current_stream_schema_;
     bool stream_active_ = false;
     std::shared_ptr<arrow::Array> current_batch_;
     int64_t current_batch_offset_ = 0;
 
-    uint64_t rows_emitted_ = 0;
-    uint64_t previous_first_row_ = std::numeric_limits<uint64_t>::max();
-    uint64_t previous_batch_row_count_ = 0;
+    SelectionState selection_;
+    RowPositionState row_position_;

Review Comment:
   **Release the new selection and row-position state in `CloseInternal()`.** 
These members now own `selection_.indices_` and 
`row_position_.previous_batch_row_idx_`, but `CloseInternal()` only resets the 
stream, current batch, scan, data source and input. Calling `Close()` while 
retaining the reader therefore retains these allocations until destruction. For 
example, selecting half of a 10-million-row file leaves roughly 40 MB in the 
host index vector alone, even though `BatchReader::Close()` promises to release 
associated resources.
   
   **Please reset both states during close**, releasing the vector's capacity 
rather than only clearing its elements. A regression should release the 
returned batches, close the reader while keeping the reader object alive, and 
verify that its internal resources are released; separately retained output 
batches must remain valid.



##########
src/paimon/format/vortex/vortex_file_batch_reader.cpp:
##########
@@ -306,28 +347,67 @@ Result<std::unique_ptr<VortexFileBatchReader>> 
VortexFileBatchReader::Create(
     // scan may be consumed only once.
     return std::unique_ptr<VortexFileBatchReader>(new VortexFileBatchReader(
         input, batch_size, std::move(input_context), std::move(session), 
std::move(data_source),
-        VxScanPtr(nullptr, vx_scan_free), file_schema, struct_type, 
total_rows, arrow_pool));
+        VxScanPtr(nullptr, vx_scan_free), file_schema, total_rows, 
predicate_options, arrow_pool));
 }
 
 VortexFileBatchReader::~VortexFileBatchReader() {
     CloseInternal();
 }
 
+Status VortexFileBatchReader::CreateScan() {
+    vx_error* error = nullptr;
+    // Scan in storage order: Vortex defaults to ordered=false and may emit 
chunks out of order,
+    // which would misalign the physical row positions NextBatch assigns.
+    vx_scan_options options{};
+    options.ordered = true;
+    // The scan clones the projection, so these expressions need only outlive 
scan creation.
+    VxExpressionPtr root(nullptr, vx_expression_free);
+    VxExpressionPtr projection(nullptr, vx_expression_free);
+    std::vector<const char*> name_ptrs;
+    if (read_schema_ != nullptr && read_schema_->num_fields() > 0) {

Review Comment:
   **An explicitly empty read schema currently falls back to a full-column 
native scan.** When `num_fields() == 0`, `options.projection` stays null, which 
Vortex interprets as `root()`. All file columns can therefore be read and 
converted to standard Arrow arrays before `AlignToReadSchema()` discards them. 
Count-only/empty-projection reads can still expand large string payloads 
unnecessarily.
   
   **Please distinguish an unset read schema from an explicitly empty 
projection** and construct an empty native projection for the latter. 
`EmptyProjectionPreservesRowCountsAndPhysicalIds` verifies output shape and row 
mapping, but it would also pass with a full payload scan; add an I/O or 
export/materialization assertion showing that omitted payload columns are not 
consumed.



##########
src/paimon/format/vortex/vortex_file_batch_reader.cpp:
##########
@@ -338,15 +418,29 @@ Result<bool> 
VortexFileBatchReader::OpenNextPartitionStream() {
     if (partition == nullptr) {
         return false;  // Scan exhausted.
     }
-    // vx_partition_scan_arrow consumes the partition (even on error), so 
release ownership here.
+    // Arrow stream export consumes the partition (even on error), so release 
ownership here.
     ::ArrowArrayStream stream{};
     error = nullptr;
-    if (vx_partition_scan_arrow(session_.get(), partition.release(), &stream, 
&error) != 0) {
+    if (vx_partition_scan_arrow_with_batch_size(session_.get(), 
partition.release(),
+                                                
static_cast<size_t>(batch_size_), &stream,
+                                                &error) != 0) {
         return VortexCallbackError("scan Vortex partition to Arrow", error,
                                    input_context_->GetCallbackStatus());
     }
     current_stream_ = stream;
     stream_active_ = true;
+    // Import batches with the scan's actual output schema (projected columns, 
standard types).
+    ::ArrowSchema ffi_schema = {};
+    if (current_stream_.get_schema(&current_stream_, &ffi_schema) != 0) {
+        Status callback_status = input_context_->GetCallbackStatus();
+        ReleaseStream();

Review Comment:
   **Preserve the native Arrow stream error before releasing the stream.** If 
`get_schema()` fails without a filesystem callback error, the current path 
returns only `Vortex Arrow stream get_schema failed`, losing the actual 
schema/export failure detail.
   
   **Please copy `get_last_error()` before `ReleaseStream()` and include it in 
the returned `Status`**, following the existing `ReadNextArray()` pattern. Keep 
the callback status as the preferred error when present, and handle a null 
native message. Returning the detailed status is sufficient; an additional 
duplicate log is unnecessary.



##########
docs/source/api/file_format.rst:
##########
@@ -55,3 +55,59 @@ Interface
 .. doxygenclass:: paimon::WrittenFileStatsProvider
    :members:
    :undoc-members:
+
+
+.. _cpp-api-vortex-read-options:
+
+Vortex Read Options
+===================
+
+Pass Vortex read options through ``ReadContextBuilder::AddOption`` at runtime.
+Read options override table options for each key; a key absent from both uses
+its default. Changes take effect when a new reader is created and do not change
+existing readers.
+
+Predicate Pushdown
+------------------
+
+``vortex.read.in-pushdown.max-values`` sets the maximum list length eligible 
for
+native IN/NOT IN pushdown. It accepts a decimal integer from ``0`` to
+``4294967295`` (``UINT32_MAX``), with a default of ``32``. Negative, malformed 
or
+out-of-range values are rejected. The limit applies independently to each IN or
+NOT IN predicate and counts the original list entries, including duplicates.
+
+A positive value permits lists of up to that length, subject to type and value
+safety checks. ``0`` disables only IN/NOT IN pushdown. A list exceeding the 
limit

Review Comment:
   **Document the concrete fallback conditions behind “type and value safety 
checks.”** The implementation declines floating-point comparisons/membership, 
timestamps with lossy precision or unsupported stored timezones, and all native 
predicate pushdown when the file contains a user column named `_row_idx`. 
Increasing the membership limit does not change those conditions, so users need 
this information to understand when the optimization applies.
   
   **Please also document the new LTZ storage-metadata behavior** introduced by 
`NormalizeTimestampTimezones()`: newly written non-empty timezones are 
normalized to UTC while epoch values and timestamp units stay unchanged. This 
is observable through the file schema and deserves a user-facing compatibility 
note in addition to the PR description.



##########
src/paimon/format/vortex/vortex_predicate_converter.cpp:
##########
@@ -0,0 +1,541 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "paimon/format/vortex/vortex_predicate_converter.h"
+
+#include <cstdint>
+#include <cstring>
+#include <limits>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/type.h"
+#include "arrow/util/decimal.h"
+#include "paimon/common/utils/checked_cast.h"
+#include "paimon/common/utils/date_time_utils.h"
+#include "paimon/common/utils/field_type_utils.h"
+#include "paimon/core/casting/decimal_to_decimal_cast_executor.h"
+#include "paimon/data/decimal.h"
+#include "paimon/data/timestamp.h"
+#include "paimon/defs.h"
+#include "paimon/predicate/compound_predicate.h"
+#include "paimon/predicate/function.h"
+#include "paimon/predicate/leaf_predicate.h"
+#include "paimon/predicate/literal.h"
+#include "paimon/predicate/predicate.h"
+
+namespace paimon::vortex {
+namespace {
+
+VxExpressionPtr NullExpr() {
+    return VxExpressionPtr(nullptr, vx_expression_free);
+}
+
+Status CheckNativeResult(const std::string& operation, const void* handle,
+                         vx_error* error = nullptr) {
+    PAIMON_RETURN_NOT_OK(VortexFfiError(operation, error));
+    if (handle == nullptr) {
+        return Status::Invalid(operation, ": Vortex returned a null handle");
+    }
+    return Status::OK();
+}
+
+// vx_expression_not returns an owned handle typed const; reclaim it as an 
owned non-const pointer.
+Result<VxExpressionPtr> MakeNotExpr(const vx_expression* child) {
+    VxExpressionPtr 
expression(const_cast<vx_expression*>(vx_expression_not(child)),
+                               vx_expression_free);
+    PAIMON_RETURN_NOT_OK(CheckNativeResult("create Vortex NOT expression", 
expression.get()));
+    return expression;
+}
+
+// Reject precision loss and overflow before constructing a native timestamp 
scalar.
+bool GetTimestampValue(const Literal& literal, const 
std::shared_ptr<arrow::DataType>& arrow_type,
+                       int64_t* result, vx_time_unit* result_unit) {
+    if (arrow_type == nullptr || arrow_type->id() != arrow::Type::TIMESTAMP) {
+        return false;
+    }
+    const auto timestamp_type = 
checked_pointer_cast<arrow::TimestampType>(arrow_type);
+    if (!timestamp_type->timezone().empty() && timestamp_type->timezone() != 
"UTC") {
+        return false;
+    }
+    const auto timestamp = literal.GetValue<Timestamp>();
+    int64_t value = timestamp.GetMillisecond();
+    const int32_t nanos = timestamp.GetNanoOfMillisecond();
+    vx_time_unit unit = VX_TIME_UNIT_MILLISECOND;
+    int64_t factor = 1;
+    int64_t fraction = 0;
+    switch (timestamp_type->unit()) {
+        case arrow::TimeUnit::SECOND:
+            if (value % 1000 != 0 || nanos != 0) {
+                return false;
+            }
+            unit = VX_TIME_UNIT_SECOND;
+            break;
+        case arrow::TimeUnit::MILLI:
+            if (nanos != 0) {
+                return false;
+            }
+            break;
+        case arrow::TimeUnit::MICRO:
+            if (nanos % 1000 != 0) {
+                return false;
+            }
+            factor = 1000;
+            fraction = nanos / 1000;
+            unit = VX_TIME_UNIT_MICROSECOND;
+            break;
+        case arrow::TimeUnit::NANO:
+            factor = 1000000;
+            fraction = nanos;
+            unit = VX_TIME_UNIT_NANOSECOND;
+            break;
+        default:
+            return false;
+    }
+    if (value < std::numeric_limits<int64_t>::min() / factor ||
+        value > std::numeric_limits<int64_t>::max() / factor) {
+        return false;
+    }
+    value *= factor;
+    if (value > std::numeric_limits<int64_t>::max() - fraction) {
+        return false;
+    }
+    *result = DateTimeUtils::TimestampToInteger(
+        timestamp, DateTimeUtils::GetTimeTypeFromArrowType(timestamp_type));
+    *result_unit = unit;
+    return true;
+}
+
+// Normalize constants only; shared CAST may round, so accept it only if the 
value is unchanged.
+Result<std::optional<Decimal>> NormalizeDecimalLiteral(
+    const Literal& literal, const std::shared_ptr<arrow::DataType>& 
arrow_type) {
+    const auto decimal = literal.GetValue<Decimal>();
+    const auto& target_type = checked_cast<const 
arrow::Decimal128Type&>(*arrow_type);
+    // Keep literals within Paimon's decimal range and the shared scale table.
+    if (decimal.Precision() < Decimal::MIN_PRECISION ||
+        decimal.Precision() > Decimal::MAX_PRECISION || decimal.Scale() < 0 ||
+        decimal.Scale() > decimal.Precision() || target_type.precision() < 
Decimal::MIN_PRECISION ||
+        target_type.precision() > Decimal::MAX_PRECISION || 
target_type.scale() < 0 ||
+        target_type.scale() > target_type.precision()) {
+        return std::optional<Decimal>();
+    }
+    if (!arrow::Decimal128(decimal.HighBits(), decimal.LowBits())
+             .FitsInPrecision(decimal.Precision())) {
+        return std::optional<Decimal>();
+    }
+    if (decimal.Precision() == target_type.precision() && decimal.Scale() == 
target_type.scale()) {
+        return std::make_optional(decimal);
+    }
+    const DecimalToDecimalCastExecutor cast_executor;
+    PAIMON_ASSIGN_OR_RAISE(Literal converted, cast_executor.Cast(literal, 
arrow_type));
+    if (converted.IsNull()) {
+        return std::optional<Decimal>();
+    }
+    const auto normalized = converted.GetValue<Decimal>();
+    if (decimal.CompareTo(normalized) != 0) {
+        return std::optional<Decimal>();
+    }
+    return std::make_optional(normalized);
+}
+
+Result<VxScalarPtr> MakeLiteralScalar(const Literal& literal,
+                                      const std::shared_ptr<arrow::DataType>& 
arrow_type) {
+    VxScalarPtr scalar(nullptr, vx_scalar_free);
+    if (literal.IsNull()) {
+        return scalar;
+    }
+    vx_error* error = nullptr;
+    switch (literal.GetType()) {
+        case FieldType::BOOLEAN:
+            scalar.reset(vx_scalar_new_bool(literal.GetValue<bool>(), false));
+            break;
+        case FieldType::TINYINT:
+            scalar.reset(vx_scalar_new_i8(literal.GetValue<int8_t>(), false));
+            break;
+        case FieldType::SMALLINT:
+            scalar.reset(vx_scalar_new_i16(literal.GetValue<int16_t>(), 
false));
+            break;
+        case FieldType::INT:
+            scalar.reset(vx_scalar_new_i32(literal.GetValue<int32_t>(), 
false));
+            break;
+        // Vortex stores a date column as a date[days] extension and rejects 
comparing it against a
+        // plain int scalar, so build a date-typed scalar.
+        case FieldType::DATE:
+            scalar.reset(vx_scalar_new_date(literal.GetValue<int32_t>(), 
false, &error));
+            if (scalar == nullptr && error == nullptr) {
+                // A valid Paimon constant can exceed the native civil-date 
range.
+                return scalar;
+            }
+            break;
+        case FieldType::BIGINT:
+            scalar.reset(vx_scalar_new_i64(literal.GetValue<int64_t>(), 
false));
+            break;
+        case FieldType::FLOAT:
+        case FieldType::DOUBLE:
+            // Native totalOrder distinguishes NaN signs and payloads; Paimon 
treats all NaNs alike.
+            return scalar;
+        case FieldType::STRING: {
+            const auto value = literal.GetValue<std::string>();
+            scalar.reset(vx_scalar_new_utf8(value.data(), value.size(), false, 
&error));
+            break;
+        }
+        case FieldType::DECIMAL: {
+            PAIMON_ASSIGN_OR_RAISE(std::optional<Decimal> normalized,
+                                   NormalizeDecimalLiteral(literal, 
arrow_type));
+            if (!normalized.has_value()) {
+                return scalar;
+            }
+            // Cast validated the full source value before selecting a 
narrower representation.
+            const Decimal& decimal = normalized.value();
+            const auto precision = static_cast<uint8_t>(decimal.Precision());
+            const auto scale = static_cast<int8_t>(decimal.Scale());
+            if (decimal.IsCompact()) {
+                
scalar.reset(vx_scalar_new_decimal_i64(decimal.ToUnscaledLong(), precision, 
scale,
+                                                       false, &error));
+            } else {
+                // Little-endian 16-byte two's complement, low word first.
+                uint8_t bytes16[16];
+                const uint64_t low = decimal.LowBits();
+                const uint64_t high = decimal.HighBits();
+                std::memcpy(bytes16, &low, sizeof(low));
+                std::memcpy(bytes16 + sizeof(low), &high, sizeof(high));
+                scalar.reset(
+                    vx_scalar_new_decimal_i128_le(bytes16, precision, scale, 
false, &error));
+            }
+            break;
+        }
+        case FieldType::TIMESTAMP: {
+            int64_t value = 0;
+            vx_time_unit unit = VX_TIME_UNIT_MILLISECOND;
+            if (!GetTimestampValue(literal, arrow_type, &value, &unit)) {
+                return scalar;
+            }
+            const auto& timestamp_type = checked_cast<const 
arrow::TimestampType&>(*arrow_type);
+            const char* zone = timestamp_type.timezone().empty() ? nullptr : 
"UTC";
+            scalar.reset(vx_scalar_new_timestamp(value, unit, zone, false, 
&error));
+            if (scalar == nullptr && error == nullptr) {
+                // Preserve residual comparison when native temporal limits 
are narrower.
+                return scalar;
+            }
+            break;
+        }
+        default:
+            return scalar;
+    }
+    PAIMON_RETURN_NOT_OK(CheckNativeResult("create Vortex predicate scalar", 
scalar.get(), error));
+    return scalar;
+}
+
+Result<VxExpressionPtr> MakeLiteralExpr(const Literal& literal,
+                                        const 
std::shared_ptr<arrow::DataType>& arrow_type) {
+    PAIMON_ASSIGN_OR_RAISE(VxScalarPtr scalar, MakeLiteralScalar(literal, 
arrow_type));
+    if (scalar == nullptr) {
+        return NullExpr();
+    }
+    vx_error* error = nullptr;
+    VxExpressionPtr expression(vx_expression_literal(scalar.get(), &error), 
vx_expression_free);
+    PAIMON_RETURN_NOT_OK(
+        CheckNativeResult("create Vortex literal expression", 
expression.get(), error));
+    return expression;
+}
+
+// Type mismatches violate the predicate contract; valid but unsafe 
conversions fall back.
+Result<bool> LiteralMatchesColumn(const Literal& literal,

Review Comment:
   **The helper names conflate type validity with pushdown eligibility.** 
`LiteralMatchesColumn()` returns false for fully matching FLOAT/DOUBLE types 
and for legacy non-UTC timestamp columns. Consequently, 
`ValidateMembershipLiteralTypes()` can set `valid = false` for a perfectly 
legal predicate that simply needs residual evaluation.
   
   **Please use names that express pushdown eligibility**, such as 
`CheckLiteralPushdownEligibility()` and `all_literals_pushable`, or otherwise 
make the result semantics explicit. Keeping unsupported-but-valid predicates 
distinct from the type mismatches that return `Status::Invalid` will make these 
fallback branches easier to maintain.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to