From 3e0079d5aa086602a722e594e70a99ecc3c0d210 Mon Sep 17 00:00:00 2001 From: Manas Lohani Date: Wed, 29 Jul 2026 17:41:04 +0530 Subject: [PATCH 1/2] Add Parquet DocValues read foundation: types + native bridge Introduce the foundational, runtime-inert pieces the Parquet DocValues codec read path builds on, so a Parquet-primary composite index can later serve its columns as Lucene doc values for DSL aggregations. This change adds only the leaf dependencies; nothing wires them into the query path yet, so there is no functional change at runtime. Java (parquet-data-format): - ParquetPhysicalType: enum of Parquet physical types whose code() (0-5) is the discriminant exchanged with the native reader; pinned by test to the ffm.rs TYPE_* constants. - FieldTypeMapping: OpenSearch mapping type -> (single/multi Lucene DocValuesType, ParquetPhysicalType), with forType/isSupported/validate. Unmapped types throw rather than serve wrong values. Native (ffm.rs, additive to the write/merge path): - Column-reader read functions: open/close/count, single-row and repeated reads, page-count, page-index, and whole-page decode into caller buffers with a packed presence bitset. A retained forward cursor keeps an ascending scan from reopening the row group per page. - Per-file metadata cache (footer + page layouts) shared across readers. Bridge: - RustBridge: MethodHandle binds + wrappers for the read functions. - NativeCall: invokeStatic/invokeIOStatic helpers for allocation-free native invocation with status checking. The DocValuesSkipper, multi-valued wiring, and the reader wrapper that turns this on arrive in later changes. Signed-off-by: Manas Lohani --- .../nativebridge/spi/NativeCall.java | 31 + .../opensearch/parquet/bridge/RustBridge.java | 274 +++- .../parquet/codec/FieldTypeMapping.java | 108 ++ .../parquet/codec/ParquetPhysicalType.java | 51 + .../src/main/rust/src/ffm.rs | 1443 ++++++++++++++++- .../parquet/codec/FieldTypeMappingTests.java | 82 + .../codec/ParquetPhysicalTypeTests.java | 51 + 7 files changed, 2038 insertions(+), 2 deletions(-) create mode 100644 sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/FieldTypeMapping.java create mode 100644 sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/ParquetPhysicalType.java create mode 100644 sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/FieldTypeMappingTests.java create mode 100644 sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/ParquetPhysicalTypeTests.java diff --git a/sandbox/libs/dataformat-native/src/main/java/org/opensearch/nativebridge/spi/NativeCall.java b/sandbox/libs/dataformat-native/src/main/java/org/opensearch/nativebridge/spi/NativeCall.java index ca52c3bc5e643..e46a59e0969b9 100644 --- a/sandbox/libs/dataformat-native/src/main/java/org/opensearch/nativebridge/spi/NativeCall.java +++ b/sandbox/libs/dataformat-native/src/main/java/org/opensearch/nativebridge/spi/NativeCall.java @@ -268,6 +268,37 @@ public static void invokeVoid(MethodHandle handle, Object... args) { } } + /** + * Invokes a {@code long}-returning native call and checks its status: returns the value + * when {@code >= 0} (positive status codes are passed through), throws {@link IOException} + * when {@code < 0}. Static (no {@link Arena}), so the caller must own every argument. + */ + public static long invokeIOStatic(MethodHandle handle, Object... args) throws IOException { + try { + long result = (long) handle.invokeWithArguments(args); + return NativeLibraryLoader.checkResultIO(result); + } catch (IOException | RuntimeException e) { + throw e; + } catch (Throwable t) { + throw new IOException(t); + } + } + + /** + * Same as {@link #invokeIOStatic} but throws {@link RuntimeException} on a {@code < 0} + * status instead of {@link IOException}. + */ + public static long invokeStatic(MethodHandle handle, Object... args) { + try { + long result = (long) handle.invokeWithArguments(args); + return NativeLibraryLoader.checkResult(result); + } catch (RuntimeException e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException(t); + } + } + @Override public void close() { closed = true; diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/RustBridge.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/RustBridge.java index a60d0e758c63e..27edad2011e7c 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/RustBridge.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/bridge/RustBridge.java @@ -34,7 +34,8 @@ import java.util.function.LongSupplier; /** - * FFM bridge to the native Rust parquet writer library. + * FFM bridge to the native Rust parquet library: the writer/merge path plus the DocValues codec's + * column-reader read path. */ public class RustBridge { @@ -57,6 +58,25 @@ public class RustBridge { private static final MethodHandle GET_POOL_STATS; private static final MethodHandle REGISTER_OVERCOMMIT_CALLBACKS; + // DocValues codec — Parquet column-reader FFI handles + private static final MethodHandle OPEN_COLUMN_READER; + private static final MethodHandle CLOSE_COLUMN_READER; + private static final MethodHandle OPEN_COLUMN_READER_COUNT; + private static final MethodHandle READ_VALUE_AT_ROW; + private static final MethodHandle READ_REPEATED_AT_ROW; + private static final MethodHandle GET_COLUMN_NUM_PAGES; + private static final MethodHandle GET_COLUMN_PAGE_INDEX; + private static final MethodHandle DECODE_PAGE_AT_ROW; + + /** + * Positive status code returned by the column-reader read/decode functions + * when a caller out-buffer was too small. The required sizes are written to + * the out-parameters so the caller can grow its buffers and retry once. This + * is distinct from the {@code < 0} error-pointer convention (a small negative + * constant would be dereferenced as an error pointer and corrupt memory). + */ + public static final long RC_OVERFLOW = 1L; + static { SymbolLookup lib = NativeLibraryLoader.symbolLookup(); Linker linker = Linker.nativeLinker(); @@ -288,6 +308,90 @@ public class RustBridge { lib.find("parquet_register_overcommit_callbacks").orElseThrow(), FunctionDescriptor.ofVoid(ValueLayout.ADDRESS, ValueLayout.ADDRESS) ); + + // ── DocValues codec column-reader functions ── + OPEN_COLUMN_READER = linker.downcallHandle( + lib.find("parquet_open_column_reader").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, + ValueLayout.ADDRESS, // file_ptr + ValueLayout.JAVA_LONG, // file_len + ValueLayout.ADDRESS, // col_ptr + ValueLayout.JAVA_LONG, // col_len + ValueLayout.JAVA_INT // expected_type + ) + ); + CLOSE_COLUMN_READER = linker.downcallHandle( + lib.find("parquet_close_column_reader").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG) + ); + OPEN_COLUMN_READER_COUNT = linker.downcallHandle( + lib.find("parquet_open_column_reader_count").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG) + ); + READ_VALUE_AT_ROW = linker.downcallHandle( + lib.find("parquet_read_value_at_row").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, // handle + ValueLayout.JAVA_LONG, // row + ValueLayout.ADDRESS, // out_present + ValueLayout.ADDRESS, // out_long + ValueLayout.ADDRESS, // out_buf + ValueLayout.JAVA_LONG, // out_buf_cap + ValueLayout.ADDRESS // out_len + ) + ); + READ_REPEATED_AT_ROW = linker.downcallHandle( + lib.find("parquet_read_repeated_at_row").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, // handle + ValueLayout.JAVA_LONG, // row + ValueLayout.ADDRESS, // out_count + ValueLayout.ADDRESS, // out_longs + ValueLayout.JAVA_LONG, // out_long_cap + ValueLayout.ADDRESS, // out_byte_buf + ValueLayout.ADDRESS, // out_byte_offsets + ValueLayout.JAVA_LONG // out_byte_buf_cap + ) + ); + GET_COLUMN_NUM_PAGES = linker.downcallHandle( + lib.find("parquet_get_column_num_pages").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG) + ); + GET_COLUMN_PAGE_INDEX = linker.downcallHandle( + lib.find("parquet_get_column_page_index").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, // handle + ValueLayout.ADDRESS, // out_first_row + ValueLayout.ADDRESS, // out_file_offset + ValueLayout.ADDRESS, // out_compressed_size + ValueLayout.ADDRESS, // out_null_count + ValueLayout.ADDRESS, // out_min_long + ValueLayout.ADDRESS, // out_max_long + ValueLayout.JAVA_LONG, // out_buf_capacity + ValueLayout.ADDRESS // out_actual_pages + ) + ); + DECODE_PAGE_AT_ROW = linker.downcallHandle( + lib.find("parquet_decode_page_at_row").orElseThrow(), + FunctionDescriptor.of( + ValueLayout.JAVA_LONG, + ValueLayout.JAVA_LONG, // handle + ValueLayout.JAVA_LONG, // row + ValueLayout.ADDRESS, // out_first_row + ValueLayout.ADDRESS, // out_last_row + ValueLayout.ADDRESS, // out_value_buf + ValueLayout.JAVA_LONG, // out_value_buf_cap + ValueLayout.ADDRESS, // out_value_actual_len + ValueLayout.ADDRESS, // out_byte_offsets + ValueLayout.JAVA_LONG, // out_byte_offsets_cap + ValueLayout.ADDRESS, // out_presence_bitset + ValueLayout.JAVA_LONG // out_presence_bits_cap + ) + ); } public static void initLogger() {} @@ -699,6 +803,174 @@ public static String readAsJson(String file) throws IOException { } } + // ──────────────────────────────────────────────────────────────────────── + // DocValues codec — Parquet column-reader bridge methods + // + // These methods own the MethodHandle invocations; the MemorySegment scratch + // buffers are supplied by the caller (ParquetColumnReader), which owns their + // lifecycle via its own Arena. Read/decode methods return the raw native + // status code so the caller can implement the grow-and-retry protocol on + // RC_OVERFLOW; a {@code < 0} result is decoded into an IOException here. + // ──────────────────────────────────────────────────────────────────────── + + /** + * Invokes a {@code long}-returning handle on caller-owned segments and checks the + * result, without allocating a per-call {@link NativeCall} arena. A {@code < 0} + * result is decoded into an {@link IOException}; {@code >= 0} (including + * {@link #RC_OVERFLOW}) is returned as-is. + * + *

Delegates to {@link NativeCall#invokeIOStatic} so the underlying MethodHandle + * invocation lives in the native-bridge library (which permits it), keeping this plugin + * free of the banned {@code MethodHandle#invokeWithArguments} call. Used on the column-reader + * read path. + */ + private static long invokeChecked(MethodHandle handle, Object... args) throws IOException { + return NativeCall.invokeIOStatic(handle, args); + } + + /** + * Opens a per-column reader over {@code file} for {@code column}, validating that the + * column exists and its physical type matches {@code expectedTypeCode} + * (0=INT32, 1=INT64, 2=FLOAT, 3=DOUBLE, 4=BOOL, 5=BYTE_ARRAY). + * + * @return a {@code >= 0} opaque reader handle (a Rust-side i64; lives until {@link #closeColumnReader}) + * @throws IOException if the column is missing, the type mismatches, or the file cannot be read + */ + public static long openColumnReader(String file, String column, int expectedTypeCode) throws IOException { + try (var call = new NativeCall()) { + var f = call.str(file); + var c = call.str(column); + return call.invokeIO(OPEN_COLUMN_READER, f.segment(), f.len(), c.segment(), c.len(), expectedTypeCode); + } + } + + /** + * Closes a column reader handle, releasing its native file handle and buffers. The native side + * treats an unknown handle as an error, so callers must not double-close; {@code ParquetColumnReader} + * enforces this by tracking a closed sentinel and calling here at most once per handle. + * + * @throws IOException if the handle is unknown + */ + public static void closeColumnReader(long handle) throws IOException { + invokeChecked(CLOSE_COLUMN_READER, handle); + } + + /** + * Returns the number of currently open native column-reader handles. Test-only: used to assert + * that readers do not leak their native handles (every open is matched by a close). + */ + public static long openColumnReaderCount() { + return NativeCall.invokeStatic(OPEN_COLUMN_READER_COUNT); + } + + /** + * Reads the single value at {@code row} into caller-provided out-segments. + * The caller (ParquetColumnReader) owns the segments. Returns the native status: + * {@code 0} on success, {@link #RC_OVERFLOW} when {@code outBuf} was too small + * (required length written to {@code outLen}); throws on a native error. + */ + static long readValueAtRow( + long handle, + long row, + MemorySegment outPresent, + MemorySegment outLong, + MemorySegment outBuf, + long outBufCap, + MemorySegment outLen + ) throws IOException { + return invokeChecked(READ_VALUE_AT_ROW, handle, row, outPresent, outLong, outBuf, outBufCap, outLen); + } + + /** + * Reads all values at {@code row} for a repeated (multi-valued) column into caller-provided + * out-segments. Returns the native status: {@code 0} on success, {@link #RC_OVERFLOW} when a + * buffer was too small (required element count in {@code outCount}; required byte + * size in {@code outByteOffsets[count]} when only the byte buffer overflowed). + */ + static long readRepeatedAtRow( + long handle, + long row, + MemorySegment outCount, + MemorySegment outLongs, + long outLongCap, + MemorySegment outByteBuf, + MemorySegment outByteOffsets, + long outByteBufCap + ) throws IOException { + return invokeChecked(READ_REPEATED_AT_ROW, handle, row, outCount, outLongs, outLongCap, outByteBuf, outByteOffsets, outByteBufCap); + } + + /** Returns the number of pages in the column (used to pre-size the page-index arrays). */ + static long getColumnNumPages(long handle) throws IOException { + return invokeChecked(GET_COLUMN_NUM_PAGES, handle); + } + + /** + * Loads the column's per-page row-range jump table + stats into caller-provided + * parallel out-segments, each of capacity {@code outBufCapacity} pages. Returns the + * native status: {@code 0} on success, {@link #RC_OVERFLOW} when capacity is too + * small (true page count written to {@code outActualPages}). + */ + static long getColumnPageIndex( + long handle, + MemorySegment outFirstRow, + MemorySegment outFileOffset, + MemorySegment outCompressedSize, + MemorySegment outNullCount, + MemorySegment outMinLong, + MemorySegment outMaxLong, + long outBufCapacity, + MemorySegment outActualPages + ) throws IOException { + return invokeChecked( + GET_COLUMN_PAGE_INDEX, + handle, + outFirstRow, + outFileOffset, + outCompressedSize, + outNullCount, + outMinLong, + outMaxLong, + outBufCapacity, + outActualPages + ); + } + + /** + * Decodes the page containing {@code row} (values + presence bitset) + * into caller-provided out-segments. Returns the native status: {@code 0} on success, + * {@link #RC_OVERFLOW} when a buffer was too small (page row range and required value + * byte length are still written so the caller can size every buffer and retry once). + */ + static long decodePageAtRow( + long handle, + long row, + MemorySegment outFirstRow, + MemorySegment outLastRow, + MemorySegment outValueBuf, + long outValueBufCap, + MemorySegment outValueActualLen, + MemorySegment outByteOffsets, + long outByteOffsetsCap, + MemorySegment outPresenceBitset, + long outPresenceBitsCap + ) throws IOException { + return invokeChecked( + DECODE_PAGE_AT_ROW, + handle, + row, + outFirstRow, + outLastRow, + outValueBuf, + outValueBufCap, + outValueActualLen, + outByteOffsets, + outByteOffsetsCap, + outPresenceBitset, + outPresenceBitsCap + ); + } + private record MapArrays(NativeCall.StrArray keys, NativeCall.StrArray values) { } diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/FieldTypeMapping.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/FieldTypeMapping.java new file mode 100644 index 0000000000000..47cc15ff5a562 --- /dev/null +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/FieldTypeMapping.java @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.parquet.codec; + +import org.apache.lucene.index.DocValuesType; + +import java.util.Locale; +import java.util.Map; + +/** + * Deterministic mapping from OpenSearch field mapping types to the Lucene DocValues type and + * the Parquet physical type the codec reads + * + *

Every Parquet column is written as {@code optional repeated }; whether a field + * is single- or multi-valued is decided by the OpenSearch mapping, not by this table. The DV + * type recorded here is the single-valued form; the matching repeated form + * ({@code SORTED_NUMERIC} for numerics, {@code SORTED_SET} for keyword/ip) is selected by the + * producer when a multi-valued iterator is requested. + */ +public final class FieldTypeMapping { + + /** The resolved Lucene DV type + Parquet physical type for a mapping type. */ + public record Mapping(DocValuesType singleValued, DocValuesType multiValued, ParquetPhysicalType physical) { + } + + // TODO(type-coverage): these writable OpenSearch types are not yet mapped, so the codec serves no + // doc values for them: half_float, unsigned_long, scaled_float, token_count, version, wildcard, + // constant_keyword, flat_object, match_only_text. Note scaled_float is stored as a raw scaled + // long, so it is not a plain INT64 mapping. forType and validate throw for unmapped types. + private static final Map BY_TYPE = Map.ofEntries( + Map.entry("boolean", new Mapping(DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.BOOL)), + Map.entry("byte", new Mapping(DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT32)), + Map.entry("short", new Mapping(DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT32)), + Map.entry("integer", new Mapping(DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT32)), + Map.entry("long", new Mapping(DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT64)), + Map.entry("float", new Mapping(DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.FLOAT)), + Map.entry("double", new Mapping(DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.DOUBLE)), + Map.entry("date", new Mapping(DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT64)), + Map.entry("date_nanos", new Mapping(DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT64)), + Map.entry("keyword", new Mapping(DocValuesType.SORTED, DocValuesType.SORTED_SET, ParquetPhysicalType.BYTE_ARRAY)), + Map.entry("ip", new Mapping(DocValuesType.SORTED, DocValuesType.SORTED_SET, ParquetPhysicalType.BYTE_ARRAY)), + Map.entry("text", new Mapping(DocValuesType.BINARY, DocValuesType.NONE, ParquetPhysicalType.BYTE_ARRAY)), + Map.entry("binary", new Mapping(DocValuesType.BINARY, DocValuesType.NONE, ParquetPhysicalType.BYTE_ARRAY)) + ); + + private FieldTypeMapping() {} + + /** True if the codec has a Parquet DocValues mapping for the given OpenSearch mapping type. */ + public static boolean isSupported(String mappingType) { + return BY_TYPE.containsKey(mappingType); + } + + /** + * Returns the mapping for {@code mappingType}. + * + * @throws IllegalArgumentException if the mapping type has no Parquet DocValues mapping + */ + public static Mapping forType(String mappingType) { + Mapping m = BY_TYPE.get(mappingType); + if (m == null) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Parquet DocValues codec has no mapping for OpenSearch type '%s'", mappingType) + ); + } + return m; + } + + /** + * Validates that the field's mapping type supports the requested Lucene DV type, throwing + * {@link IllegalArgumentException} naming the field and mapping type when incompatible. + * + *

The requested type may be the single- or multi-valued form of the mapping's DV type + * (e.g. requesting {@code SORTED_NUMERIC} for a {@code long} field, whose single-valued + * form is {@code NUMERIC}, is valid). + */ + public static void validate(String field, String mappingType, DocValuesType requested) { + Mapping m = BY_TYPE.get(mappingType); + if (m == null) { + throw new IllegalArgumentException( + String.format( + Locale.ROOT, + "field '%s' has mapping type '%s', which the Parquet DocValues codec does not support", + field, + mappingType + ) + ); + } + if (requested != m.singleValued() && requested != m.multiValued()) { + throw new IllegalArgumentException( + String.format( + Locale.ROOT, + "field '%s' (mapping type '%s') supports DocValues type %s/%s but %s was requested", + field, + mappingType, + m.singleValued(), + m.multiValued(), + requested + ) + ); + } + } +} diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/ParquetPhysicalType.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/ParquetPhysicalType.java new file mode 100644 index 0000000000000..7e3e3f22556ce --- /dev/null +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/ParquetPhysicalType.java @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.parquet.codec; + +/** + * The Parquet physical type of a column, as understood by the native column reader. + * + *

The {@link #code()} values are the discriminants exchanged with the Rust FFM + * bridge ({@code parquet_open_column_reader}'s {@code expected_type} argument and the + * physical-type validation it performs). They must stay in lock-step with the + * {@code TYPE_*} constants in {@code ffm.rs}. + * + *

Parquet's {@code FIXED_LEN_BYTE_ARRAY} is folded into {@link #BYTE_ARRAY} because + * the codec treats both as opaque byte sequences for {@code BinaryDocValues} purposes. + */ +public enum ParquetPhysicalType { + /** 32-bit signed integer; raw bits sign-extended to a {@code long}. */ + INT32(0), + /** 64-bit signed integer; raw bits verbatim. */ + INT64(1), + /** 32-bit IEEE-754 float; raw bits via {@link Float#floatToRawIntBits}. */ + FLOAT(2), + /** 64-bit IEEE-754 double; raw bits via {@link Double#doubleToRawLongBits}. */ + DOUBLE(3), + /** Boolean; encoded as 0 or 1. */ + BOOL(4), + /** Variable- or fixed-length byte array (UTF-8 strings, IP, binary). */ + BYTE_ARRAY(5); + + private final int code; + + ParquetPhysicalType(int code) { + this.code = code; + } + + /** The native discriminant exchanged with the Rust FFM bridge. */ + public int code() { + return code; + } + + /** True for the fixed-width primitive types whose values are exchanged as raw {@code long} bits. */ + public boolean isPrimitive() { + return this != BYTE_ARRAY; + } +} diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs index 36f94820a37ff..03812394941bb 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs @@ -6,15 +6,27 @@ * compatible open source license. */ -//! FFM bridge for the Parquet writer. +//! FFM bridge for the Parquet data format: the writer/merge path plus the DocValues codec's +//! column-reader read path. //! //! Return convention: `>= 0` success, `< 0` error pointer (negate to get ptr, //! call `native_error_message`/`native_error_free`). +use std::collections::HashMap; +use std::fs::File; use std::slice; use std::str; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Mutex, MutexGuard}; +use lazy_static::lazy_static; use native_bridge_common::{ffm_safe, log_debug}; +use parquet::basic::Type as PhysicalType; +use parquet::column::reader::{ColumnReader, ColumnReaderImpl}; +use parquet::data_type::DataType as ParquetDataType; +use parquet::file::page_index::column_index::ColumnIndexMetaData; +use parquet::file::reader::{FileReader, SerializedFileReader}; +use parquet::file::serialized_reader::ReadOptionsBuilder; use crate::field_config::FieldConfig; use crate::merge; @@ -1005,3 +1017,1432 @@ pub unsafe extern "C" fn parquet_get_pool_stats(out_buf: *mut i64) { *out_buf.add(i) = *val as i64; } } + +// =========================================================================== +// Parquet column reader +// --------------------------------------------------------------------------- +// +// Read-only per-column random-access reader used by the Lucene +// `ParquetDocValuesProducer`. The reader exposes the column's physical values +// by row position so the Java side can materialise per-document doc values. +// +// Return convention (consistent with the rest of this file): +// - `>= 0` success. For reads, `0` (`RC_OK`) means "value(s) written"; the +// positive sentinel `RC_OVERFLOW` (1) means "caller buffer too small — +// required sizes were written to the out-parameters, retry once". +// - `< 0` error pointer (negate, then `native_error_message`/`native_error_free`). +// +// Overflow is a positive status, not a negative one: negative returns are all +// reserved for error pointers, so a small negative constant would be +// dereferenced as an error pointer. This mirrors `parquet_finalize_writer`, +// which already returns `Ok(1)` for "no writer". + +/// Read succeeded; value(s) written to the caller buffers. +pub(crate) const RC_OK: i64 = 0; +/// A caller buffer was too small. Required sizes were written to the +/// out-parameters; the caller should grow its buffers and retry once. +pub(crate) const RC_OVERFLOW: i64 = 1; + +/// `expected_type` discriminants exchanged with Java +/// (matches `ParquetPhysicalType` on the Java side). +const TYPE_INT32: i32 = 0; +const TYPE_INT64: i32 = 1; +const TYPE_FLOAT: i32 = 2; +const TYPE_DOUBLE: i32 = 3; +const TYPE_BOOL: i32 = 4; +const TYPE_BYTE_ARRAY: i32 = 5; + +/// Per-column reader state, one instance per open handle in the registry. Owns the +/// file handle (via `SerializedFileReader`) and the row-group layout that maps a global +/// row position to a `(row_group, local_offset)` pair. See the per-field comments for +/// what each read function uses. +struct ColumnReaderState { + reader: SerializedFileReader, + /// Leaf column index within the Parquet schema descriptor. + leaf_idx: usize, + /// Physical type of the column (validated against the caller's expectation). + physical_type: PhysicalType, + /// True when the column has a repetition level > 0 (multi-valued). + repeated: bool, + /// Max definition level of the column (0 = required; >0 = optional/nested). + max_def_level: i16, + /// Total number of rows (records) in the file. + row_count: i64, + /// Global row index of the first row in each row group. + rg_first_row: Vec, + /// Number of rows in each row group. + rg_num_rows: Vec, + /// Per-page layout (row-range jump table + page stats), ascending by + /// `global_first_row`. Built once at `open()` from the Parquet OffsetIndex + + /// ColumnIndex when present, else one entry per row group as a fallback. + pages: Vec, + /// Decode cursor retained by `parquet_decode_page_at_row` across calls. `None` + /// until the first page decode and whenever it has been invalidated (row-group + /// change, backwards seek, or a decode error). The single-row readers + /// (`parquet_read_value_at_row`, `parquet_read_repeated_at_row`) do not use it. + cursor: Option, + /// Pre-allocated scratch buffers for page decoding, reused across + /// `parquet_decode_page_at_row` calls to avoid per-call heap allocation. The + /// Vecs keep their capacity across `.clear()`, so only the first decode allocates. + scratch: DecodeScratch, +} + +/// Retained cursor for `parquet_decode_page_at_row`. Keeps the typed column +/// reader open across consecutive page decodes within the same row group, so an +/// ascending scan skips forward from the current position instead of reopening +/// the row group (which re-reads row-group metadata and the dictionary page) for +/// every page. +struct CursorState { + /// The row group index this cursor was opened for. + rg_idx: usize, + /// The typed column reader, retained across pages within the same row group. + col_reader: ColumnReader, + /// Global row position: the next record read from this cursor would start at this row. + position: i64, +} + +/// Pre-allocated scratch buffers for page decoding. Kept on `ColumnReaderState` and cleared (not +/// reallocated) between calls, so steady-state decoding is allocation-free after the first miss. +struct DecodeScratch { + /// Definition levels returned by `read_records` (used to derive per-row presence). + def_levels: Vec, + /// Dense non-null values read from the page. One typed buffer per primitive type; a decode + /// uses only the buffer matching the column's type. Kept as separate Vecs (not one reused + /// byte buffer) so decoding is type-safe with no transmute. + values_i64: Vec, + values_i32: Vec, + values_f32: Vec, + values_f64: Vec, + values_bool: Vec, +} + +impl DecodeScratch { + fn new() -> Self { + DecodeScratch { + def_levels: Vec::new(), + values_i64: Vec::new(), + values_i32: Vec::new(), + values_f32: Vec::new(), + values_f64: Vec::new(), + values_bool: Vec::new(), + } + } +} + +/// One row-aligned page in the column (or one row group, in the no-page-index +/// fallback). All row indices are global (file-relative). +#[derive(Clone)] +struct PageEntry { + /// Global index of the first row in the page. + global_first_row: i64, + /// Number of rows in the page. + num_rows: i64, + /// Byte offset of the page in the file. 0 when unknown. + file_offset: i64, + /// Compressed page size in bytes. 0 when unknown. + compressed_size: i32, + /// Number of nulls in the page. -1 when unknown. + null_count: i64, + /// Min value raw bits; meaningful only for numeric columns with a + /// page index, else 0. + min_long: i64, + /// Max value raw bits; meaningful only for numeric columns with a + /// page index, else 0. + max_long: i64, + /// Row group containing the page. + rg_idx: usize, + /// Index of the page's first row within its row group. + local_first_row: i64, +} + +/// Gets or builds the per-file metadata cache entry. First call for a given path parses the +/// footer + page index and pre-computes every column's page layout; subsequent calls (other +/// columns, other queries) get an Arc clone in O(1) — no re-parse, no per-column layout work. +fn get_or_build_file_metadata(filename: &str) -> Result, String> { + let mut cache = FILE_METADATA_CACHE + .lock() + .map_err(|_| "file metadata cache mutex poisoned".to_string())?; + if let Some(entry) = cache.get(filename) { + return Ok(std::sync::Arc::clone(entry)); + } + + // First access to this file: parse footer + page index. + let file = File::open(filename).map_err(|e| format!("Failed to open '{}': {}", filename, e))?; + let options = ReadOptionsBuilder::new().with_page_index().build(); + let reader = SerializedFileReader::new_with_options(file, options) + .map_err(|e| format!("Failed to read parquet metadata '{}': {}", filename, e))?; + + let metadata = reader.metadata(); + let schema = metadata.file_metadata().schema_descr_ptr(); + let row_count = metadata.file_metadata().num_rows(); + + let n_rg = metadata.num_row_groups(); + let mut rg_first_row = Vec::with_capacity(n_rg); + let mut rg_num_rows = Vec::with_capacity(n_rg); + let mut acc = 0i64; + for i in 0..n_rg { + let rn = metadata.row_group(i).num_rows(); + rg_first_row.push(acc); + rg_num_rows.push(rn); + acc += rn; + } + + // Pre-compute per-column descriptors. + let columns: Vec<_> = (0..schema.num_columns()) + .map(|i| { + let d = schema.column(i); + (i, d.physical_type(), d.max_rep_level(), d.max_def_level()) + }) + .collect(); + + // Pre-compute page layouts for ALL columns (small per column; avoids per-column re-parse). + let mut column_pages = HashMap::new(); + for &(leaf_idx, phys, _, _) in &columns { + let pages = build_page_layout(metadata, leaf_idx, phys, &rg_first_row, &rg_num_rows); + column_pages.insert(leaf_idx, pages); + } + + let entry = std::sync::Arc::new(FileMetadataCache { + schema, + row_count, + rg_first_row, + rg_num_rows, + column_pages, + }); + cache.insert(filename.to_string(), std::sync::Arc::clone(&entry)); + Ok(entry) +} + +impl ColumnReaderState { + fn open(filename: &str, column: &str, expected_type: i32) -> Result { + // Use the node-level metadata cache — first call parses; subsequent calls are O(1). + let fmc = get_or_build_file_metadata(filename)?; + + // Resolve column from cached schema descriptor (no file I/O, no re-parse). + let mut found: Option<(usize, PhysicalType, i16, i16)> = None; + for i in 0..fmc.schema.num_columns() { + let descr = fmc.schema.column(i); + if descr.name() == column || descr.path().string() == column { + found = Some((i, descr.physical_type(), descr.max_rep_level(), descr.max_def_level())); + break; + } + } + let (leaf_idx, phys, max_rep, max_def) = found.ok_or_else(|| { + format!("Column '{}' not found in parquet file '{}'", column, filename) + })?; + + let actual = physical_type_code(phys); + if actual != expected_type { + return Err(format!( + "Column '{}' physical type mismatch in '{}': expected type code {}, found {:?} (code {})", + column, filename, expected_type, phys, actual + )); + } + + // Get the pre-computed page layout from the cache. + let pages = fmc.column_pages.get(&leaf_idx) + .cloned() + .unwrap_or_default(); + + // Open a file handle for this column reader WITH the page index loaded: the retained + // cursor's skip_records uses the page index internally for efficient page-hopping. The + // per-file metadata cache above saves the JAVA-FACING costs (schema resolution, page + // layout computation, ColumnPageIndex FFM marshal) — this reader's page-index load is + // cheap (already in OS page cache from the first parse) and necessary for decode perf. + // + // TODO(consider optimisation): this re-opens the file and re-reads the footer, which + // get_or_build_file_metadata already parsed above. Only the cheap footer read + syscall are + // duplicated (the page-index parse is cached, and the bytes are warm), but the reader could + // be constructed from the already-parsed ParquetMetaData to skip the second footer read. + let file = File::open(filename).map_err(|e| format!("Failed to open '{}': {}", filename, e))?; + let options = ReadOptionsBuilder::new().with_page_index().build(); + let reader = SerializedFileReader::new_with_options(file, options) + .map_err(|e| format!("Failed to read parquet '{}': {}", filename, e))?; + + Ok(ColumnReaderState { + reader, + leaf_idx, + physical_type: phys, + repeated: max_rep > 0, + max_def_level: max_def, + row_count: fmc.row_count, + rg_first_row: fmc.rg_first_row.clone(), + rg_num_rows: fmc.rg_num_rows.clone(), + pages, + cursor: None, + scratch: DecodeScratch::new(), + }) + } + + /// Translate a global row position into `(row_group_index, local_offset)`. + /// + /// TODO(consider optimisation): linear scan over row groups. Row groups are few (tens) and this + /// is only on the single-row read path (not the page-decode aggregation path), so it is low + /// priority, but it could be a binary search over `rg_first_row` like `page_for_row`. + fn locate(&self, row: i64) -> Result<(usize, i64), String> { + for i in 0..self.rg_first_row.len() { + let start = self.rg_first_row[i]; + let end = start + self.rg_num_rows[i]; + if row >= start && row < end { + return Ok((i, row - start)); + } + } + Err(format!("Row {} not found in any row group (row count {})", row, self.row_count)) + } + + /// Find the index of the page containing global row `row` (binary search over + /// the ascending page layout). + fn page_for_row(&self, row: i64) -> Result { + // partition_point finds the first page whose global_first_row > row; the + // page we want is the one immediately before it. + let p = self.pages.partition_point(|e| e.global_first_row <= row); + if p == 0 { + return Err(format!("Row {} precedes the first page (row count {})", row, self.row_count)); + } + let idx = p - 1; + let entry = &self.pages[idx]; + if row >= entry.global_first_row && row < entry.global_first_row + entry.num_rows { + Ok(idx) + } else { + Err(format!("Row {} not found in any page (row count {})", row, self.row_count)) + } + } +} + +/// Builds the per-page layout for a column. Prefers the Parquet OffsetIndex + +/// ColumnIndex (true page granularity); falls back to one entry per row group +/// when the file has no page index. +fn build_page_layout( + metadata: &parquet::file::metadata::ParquetMetaData, + leaf_idx: usize, + phys: PhysicalType, + rg_first_row: &[i64], + rg_num_rows: &[i64], +) -> Vec { + let n_rg = metadata.num_row_groups(); + let offset_index = metadata.offset_index(); + let column_index = metadata.column_index(); + + let mut pages: Vec = Vec::new(); + + for rg in 0..n_rg { + let oi_pages = offset_index + .and_then(|oi| oi.get(rg)) + .and_then(|cols| cols.get(leaf_idx)); + let ci = column_index + .and_then(|ci| ci.get(rg)) + .and_then(|cols| cols.get(leaf_idx)); + + match oi_pages { + Some(oi) => { + let locations = oi.page_locations(); + let rg_rows = rg_num_rows[rg]; + for (p, loc) in locations.iter().enumerate() { + let local_first = loc.first_row_index; + let next_local = if p + 1 < locations.len() { + locations[p + 1].first_row_index + } else { + rg_rows + }; + let num_rows = next_local - local_first; + let null_count = ci.and_then(|c| c.null_count(p)).unwrap_or(-1); + let (min_long, max_long) = ci + .map(|c| page_min_max(c, p, phys)) + .unwrap_or(MINMAX_UNKNOWN); + pages.push(PageEntry { + global_first_row: rg_first_row[rg] + local_first, + num_rows, + file_offset: loc.offset, + compressed_size: loc.compressed_page_size, + null_count, + min_long, + max_long, + rg_idx: rg, + local_first_row: local_first, + }); + } + } + None => { + // Fallback: treat the whole row group as a single "page". + let cc = metadata.row_group(rg).column(leaf_idx); + let null_count = cc + .statistics() + .and_then(|s| s.null_count_opt()) + .map(|n| n as i64) + .unwrap_or(-1); + let compressed = cc.compressed_size().min(i32::MAX as i64) as i32; + pages.push(PageEntry { + global_first_row: rg_first_row[rg], + num_rows: rg_num_rows[rg], + file_offset: cc.data_page_offset(), + compressed_size: compressed, + null_count, + min_long: MINMAX_UNKNOWN.0, + max_long: MINMAX_UNKNOWN.1, + rg_idx: rg, + local_first_row: 0, + }); + } + } + } + + pages +} + +/// Sentinel pair meaning "min/max unknown": the widest possible range, so a consumer making +/// skip decisions (the DocValuesSkipper) can never wrongly exclude the page. Distinguishable +/// from real data only in that real data spanning the full i64 range behaves identically — +/// which is exactly the safe behavior. +const MINMAX_UNKNOWN: (i64, i64) = (i64::MIN, i64::MAX); + +/// Extracts the per-page min/max as raw i64 bits from a typed ColumnIndex. +/// Returns [`MINMAX_UNKNOWN`] for byte-array/unsupported columns (binary min/max is not +/// exchanged as i64) and for pages whose stats are absent. +fn page_min_max(ci: &ColumnIndexMetaData, idx: usize, _phys: PhysicalType) -> (i64, i64) { + // A stat may be absent per page (stats disabled, or an all-null page). Report the unknown + // sentinel rather than 0 — 0 is indistinguishable from a real value and would let a + // skipper wrongly exclude pages. + match ci { + ColumnIndexMetaData::INT32(p) => match (p.min_value(idx), p.max_value(idx)) { + (Some(min), Some(max)) => (*min as i64, *max as i64), + _ => MINMAX_UNKNOWN, + }, + ColumnIndexMetaData::INT64(p) => match (p.min_value(idx), p.max_value(idx)) { + (Some(min), Some(max)) => (*min, *max), + _ => MINMAX_UNKNOWN, + }, + ColumnIndexMetaData::FLOAT(p) => match (p.min_value(idx), p.max_value(idx)) { + (Some(min), Some(max)) => (min.to_bits() as i64, max.to_bits() as i64), + _ => MINMAX_UNKNOWN, + }, + ColumnIndexMetaData::DOUBLE(p) => match (p.min_value(idx), p.max_value(idx)) { + (Some(min), Some(max)) => (min.to_bits() as i64, max.to_bits() as i64), + _ => MINMAX_UNKNOWN, + }, + ColumnIndexMetaData::BOOLEAN(p) => match (p.min_value(idx), p.max_value(idx)) { + (Some(min), Some(max)) => (if *min { 1 } else { 0 }, if *max { 1 } else { 0 }), + _ => MINMAX_UNKNOWN, + }, + _ => MINMAX_UNKNOWN, + } +} + +/// Maps a Parquet physical type to the Java-facing `expected_type` discriminant. +/// Returns `-1` for unsupported physical types (e.g. INT96), which can never +/// match a valid expectation and therefore surfaces as a clear mismatch error. +fn physical_type_code(t: PhysicalType) -> i32 { + match t { + PhysicalType::BOOLEAN => TYPE_BOOL, + PhysicalType::INT32 => TYPE_INT32, + PhysicalType::INT64 => TYPE_INT64, + PhysicalType::FLOAT => TYPE_FLOAT, + PhysicalType::DOUBLE => TYPE_DOUBLE, + PhysicalType::BYTE_ARRAY => TYPE_BYTE_ARRAY, + PhysicalType::FIXED_LEN_BYTE_ARRAY => TYPE_BYTE_ARRAY, + PhysicalType::INT96 => -1, + } +} + +/// Reads exactly one record (after skipping `skip` records) from a typed column +/// reader, returning that record's non-null values. For a single-valued column +/// the result holds 0 (null) or 1 value; for a repeated column it holds all the +/// values of the record. +fn read_record_values( + r: &mut ColumnReaderImpl, + skip: usize, +) -> Result, String> { + if skip > 0 { + let skipped = r.skip_records(skip).map_err(|e| e.to_string())?; + if skipped < skip { + return Err(format!("requested skip of {} records but only {} available", skip, skipped)); + } + } + let mut def_levels: Vec = Vec::new(); + let mut rep_levels: Vec = Vec::new(); + let mut values: Vec = Vec::new(); + r.read_records(1, Some(&mut def_levels), Some(&mut rep_levels), &mut values) + .map_err(|e| e.to_string())?; + Ok(values) +} + +/// Cached per-file metadata: footer + page-index parse results shared across all column readers +/// for the same file. Parquet files are immutable (changed data = new file = new path), so entries +/// never need invalidation — they can only be evicted when a file is deleted (shard close). This +/// is the `.dvm` equivalent: parsed once at first column open, then every subsequent query reuses +/// it without FFM/IO, at node lifetime scope. +struct FileMetadataCache { + /// Schema descriptor pointer (for column lookup). + schema: std::sync::Arc, + /// Number of rows in the file. + row_count: i64, + /// Per-row-group: global first row. + rg_first_row: Vec, + /// Per-row-group: number of rows. + rg_num_rows: Vec, + /// Per-column page layouts, keyed by leaf column index. Computed once per file. + column_pages: HashMap>, +} + +lazy_static! { + /// Node-level file metadata cache. Keyed by absolute file path. Entries are never + /// invalidated (immutable files) — evicted only on explicit `parquet_evict_file_metadata`. + static ref FILE_METADATA_CACHE: Mutex>> = Mutex::new(HashMap::new()); + + /// Per-handle registry of open column readers, keyed by an opaque i64 handle. + /// Mirrors the writer-side handle pattern; serialised behind a single mutex + /// since column readers are not shared across threads. + static ref COLUMN_READERS: Mutex> = Mutex::new(HashMap::new()); +} + +/// Monotonic handle allocator. Always `>= 0`, so a returned handle is never +/// confused with the `< 0` error-pointer convention. +static NEXT_COLUMN_READER_HANDLE: AtomicI64 = AtomicI64::new(0); + +/// Locks the column-reader registry, converting a poisoned mutex into a normal +/// FFM error instead of propagating the panic. +fn lock_readers<'a>() -> Result>, String> { + COLUMN_READERS + .lock() + .map_err(|_| "column reader registry mutex poisoned".to_string()) +} + +/// Evicts a file's cached metadata (footer + page index) from the node-level cache, e.g. on +/// shard close or file deletion. No-op if the file isn't cached. Returns 0 on success. +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn parquet_evict_file_metadata( + file_ptr: *const u8, + file_len: i64, +) -> i64 { + let filename = str_from_raw(file_ptr, file_len) + .map_err(|e| format!("parquet_evict_file_metadata: {}", e))? + .to_string(); + FILE_METADATA_CACHE + .lock() + .map_err(|_| "file metadata cache mutex poisoned".to_string())? + .remove(&filename); + Ok(RC_OK) +} + +/// Opens a per-column reader over `file` for `col`, validating that the column +/// exists and its physical type matches `expected_type` +/// (0=INT32,1=INT64,2=FLOAT,3=DOUBLE,4=BOOL,5=BYTE_ARRAY). +/// +/// Returns `>= 0` handle id on success, `< 0` negated error pointer on failure. +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn parquet_open_column_reader( + file_ptr: *const u8, + file_len: i64, + col_ptr: *const u8, + col_len: i64, + expected_type: i32, +) -> i64 { + let filename = str_from_raw(file_ptr, file_len) + .map_err(|e| format!("parquet_open_column_reader file: {}", e))? + .to_string(); + let column = str_from_raw(col_ptr, col_len) + .map_err(|e| format!("parquet_open_column_reader column: {}", e))? + .to_string(); + + let state = ColumnReaderState::open(&filename, &column, expected_type)?; + + let handle = NEXT_COLUMN_READER_HANDLE.fetch_add(1, Ordering::SeqCst); + lock_readers()?.insert(handle, state); + log_debug!( + "parquet_open_column_reader: file={}, column={}, handle={}", + filename, column, handle + ); + Ok(handle) +} + +/// Closes a column reader handle and releases its file handle and buffers. +/// Returns `0` on success, a `< 0` error pointer if the handle is unknown. +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn parquet_close_column_reader(handle: i64) -> i64 { + match lock_readers()?.remove(&handle) { + Some(_) => { + log_debug!("parquet_close_column_reader: handle={}", handle); + Ok(RC_OK) + } + None => Err(format!("parquet_close_column_reader: unknown handle {}", handle)), + } +} + +/// Debug-only symbol: returns the number of currently open column-reader +/// handles. Used by Property 7 (native handle non-leakage). Never errors; +/// recovers from a poisoned mutex rather than panicking. +#[no_mangle] +pub unsafe extern "C" fn parquet_open_column_reader_count() -> i64 { + match COLUMN_READERS.lock() { + Ok(guard) => guard.len() as i64, + Err(poisoned) => poisoned.into_inner().len() as i64, + } +} + +/// Reads the single value at `row` for a single-valued column. +/// +/// On success writes: +/// - `out_present` = 1 if the row has a value, 0 if null/absent +/// - `out_long` = the value's raw bits for primitive columns: +/// INT32 sign-extended to i64; INT64 verbatim; +/// FLOAT = `f32::to_bits` (zero-extended); +/// DOUBLE = `f64::to_bits`; +/// BOOL = 0 or 1 +/// - for BYTE_ARRAY columns: the value bytes are copied into `out_buf` and +/// `out_len` is set to the byte length (or -1 when the value is null). +/// +/// Returns a `< 0` error pointer naming the row when `row >= row_count`, when +/// the handle is unknown, or when `out_buf` is too small for a BYTE_ARRAY value +/// (in which case `out_len` is set to the required length first). +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn parquet_read_value_at_row( + handle: i64, + row: i64, + out_present: *mut i64, + out_long: *mut i64, + out_buf: *mut u8, + out_buf_cap: i64, + out_len: *mut i64, +) -> i64 { + let mut guard = lock_readers()?; + let state = guard + .get_mut(&handle) + .ok_or_else(|| format!("parquet_read_value_at_row: unknown handle {}", handle))?; + + if row < 0 { + return Err(format!("parquet_read_value_at_row: negative row {}", row)); + } + if row >= state.row_count { + return Err(format!( + "parquet_read_value_at_row: row {} out of range (row count {})", + row, state.row_count + )); + } + + // Default outputs: absent value. + if !out_present.is_null() { + *out_present = 0; + } + if !out_long.is_null() { + *out_long = 0; + } + if !out_len.is_null() { + *out_len = -1; + } + + let (rg_idx, local) = state.locate(row)?; + let rg = state.reader.get_row_group(rg_idx).map_err(|e| e.to_string())?; + let col = rg.get_column_reader(state.leaf_idx).map_err(|e| e.to_string())?; + let local = local as usize; + + match col { + ColumnReader::Int32ColumnReader(mut r) => { + if let Some(v) = read_record_values(&mut r, local)?.first() { + set_present(out_present, out_long, *v as i64); + } + } + ColumnReader::Int64ColumnReader(mut r) => { + if let Some(v) = read_record_values(&mut r, local)?.first() { + set_present(out_present, out_long, *v); + } + } + ColumnReader::FloatColumnReader(mut r) => { + if let Some(v) = read_record_values(&mut r, local)?.first() { + set_present(out_present, out_long, v.to_bits() as i64); + } + } + ColumnReader::DoubleColumnReader(mut r) => { + if let Some(v) = read_record_values(&mut r, local)?.first() { + set_present(out_present, out_long, v.to_bits() as i64); + } + } + ColumnReader::BoolColumnReader(mut r) => { + if let Some(v) = read_record_values(&mut r, local)?.first() { + set_present(out_present, out_long, if *v { 1 } else { 0 }); + } + } + ColumnReader::ByteArrayColumnReader(mut r) => { + if let Some(v) = read_record_values(&mut r, local)?.first() { + return write_bytes_value(v.data(), out_present, out_buf, out_buf_cap, out_len); + } + } + ColumnReader::FixedLenByteArrayColumnReader(mut r) => { + if let Some(v) = read_record_values(&mut r, local)?.first() { + return write_bytes_value(v.data(), out_present, out_buf, out_buf_cap, out_len); + } + } + ColumnReader::Int96ColumnReader(_) => { + return Err("parquet_read_value_at_row: INT96 columns are not supported".to_string()); + } + } + + Ok(RC_OK) +} + +/// Marks a primitive value present and stores its raw bits. +unsafe fn set_present(out_present: *mut i64, out_long: *mut i64, bits: i64) { + if !out_present.is_null() { + *out_present = 1; + } + if !out_long.is_null() { + *out_long = bits; + } +} + +/// Copies a single BYTE_ARRAY value into the caller buffer. Sets `out_present=1` +/// and `out_len` to the byte length. Returns `RC_OVERFLOW` (after recording the +/// required length in `out_len`) when the value does not fit in `out_buf_cap`, +/// so the caller can grow its buffer and retry once. +unsafe fn write_bytes_value( + bytes: &[u8], + out_present: *mut i64, + out_buf: *mut u8, + out_buf_cap: i64, + out_len: *mut i64, +) -> Result { + if !out_present.is_null() { + *out_present = 1; + } + let n = bytes.len(); + if !out_len.is_null() { + *out_len = n as i64; + } + if (n as i64) > out_buf_cap || (n > 0 && out_buf.is_null()) { + return Ok(RC_OVERFLOW); + } + if n > 0 { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_buf, n); + } + Ok(RC_OK) +} + +/// Reads all values at `row` for a repeated (multi-valued) column. +/// +/// Capacity contract: `out_long_cap` is the maximum element count for *both* +/// primitive and BYTE_ARRAY columns; `out_byte_offsets` (BYTE_ARRAY only) must +/// have capacity `out_long_cap + 1`. +/// +/// On success (`RC_OK`): +/// - `out_count` = number of values at the row +/// - primitive columns: raw bits (see `parquet_read_value_at_row`) written to +/// `out_longs` +/// - BYTE_ARRAY columns: concatenated bytes in `out_byte_buf`, CSR offsets +/// (length `count + 1`) in `out_byte_offsets` +/// +/// On `RC_OVERFLOW`: `out_count` holds the required element count. When the +/// element count fits but only the byte buffer is too small, the full CSR +/// offsets are still written so `out_byte_offsets[count]` reports the required +/// total byte size, enabling a single retry. +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn parquet_read_repeated_at_row( + handle: i64, + row: i64, + out_count: *mut i64, + out_longs: *mut i64, + out_long_cap: i64, + out_byte_buf: *mut u8, + out_byte_offsets: *mut i64, + out_byte_buf_cap: i64, +) -> i64 { + let mut guard = lock_readers()?; + let state = guard + .get_mut(&handle) + .ok_or_else(|| format!("parquet_read_repeated_at_row: unknown handle {}", handle))?; + + if row < 0 { + return Err(format!("parquet_read_repeated_at_row: negative row {}", row)); + } + if row >= state.row_count { + return Err(format!( + "parquet_read_repeated_at_row: row {} out of range (row count {})", + row, state.row_count + )); + } + + if !out_count.is_null() { + *out_count = 0; + } + + let (rg_idx, local) = state.locate(row)?; + let rg = state.reader.get_row_group(rg_idx).map_err(|e| e.to_string())?; + let col = rg.get_column_reader(state.leaf_idx).map_err(|e| e.to_string())?; + let local = local as usize; + + match col { + ColumnReader::Int32ColumnReader(mut r) => { + let vals = read_record_values(&mut r, local)?; + write_primitive_repeated(vals.iter().map(|v| *v as i64), vals.len(), out_count, out_longs, out_long_cap) + } + ColumnReader::Int64ColumnReader(mut r) => { + let vals = read_record_values(&mut r, local)?; + write_primitive_repeated(vals.iter().copied(), vals.len(), out_count, out_longs, out_long_cap) + } + ColumnReader::FloatColumnReader(mut r) => { + let vals = read_record_values(&mut r, local)?; + write_primitive_repeated(vals.iter().map(|v| v.to_bits() as i64), vals.len(), out_count, out_longs, out_long_cap) + } + ColumnReader::DoubleColumnReader(mut r) => { + let vals = read_record_values(&mut r, local)?; + write_primitive_repeated(vals.iter().map(|v| v.to_bits() as i64), vals.len(), out_count, out_longs, out_long_cap) + } + ColumnReader::BoolColumnReader(mut r) => { + let vals = read_record_values(&mut r, local)?; + write_primitive_repeated(vals.iter().map(|v| if *v { 1i64 } else { 0i64 }), vals.len(), out_count, out_longs, out_long_cap) + } + ColumnReader::ByteArrayColumnReader(mut r) => { + let vals = read_record_values(&mut r, local)?; + let slices: Vec<&[u8]> = vals.iter().map(|v| v.data()).collect(); + write_bytes_repeated(&slices, out_count, out_long_cap, out_byte_buf, out_byte_offsets, out_byte_buf_cap) + } + ColumnReader::FixedLenByteArrayColumnReader(mut r) => { + let vals = read_record_values(&mut r, local)?; + let slices: Vec<&[u8]> = vals.iter().map(|v| v.data()).collect(); + write_bytes_repeated(&slices, out_count, out_long_cap, out_byte_buf, out_byte_offsets, out_byte_buf_cap) + } + ColumnReader::Int96ColumnReader(_) => { + Err("parquet_read_repeated_at_row: INT96 columns are not supported".to_string()) + } + } +} + +/// Writes repeated primitive values to `out_longs`, or reports overflow. +unsafe fn write_primitive_repeated( + values: impl Iterator, + count: usize, + out_count: *mut i64, + out_longs: *mut i64, + out_long_cap: i64, +) -> Result { + if !out_count.is_null() { + *out_count = count as i64; + } + if (count as i64) > out_long_cap || out_longs.is_null() { + return Ok(RC_OVERFLOW); + } + for (i, v) in values.enumerate() { + *out_longs.add(i) = v; + } + Ok(RC_OK) +} + +/// Writes repeated BYTE_ARRAY values (CSR layout) to the caller buffers, or +/// reports overflow with required sizes. +unsafe fn write_bytes_repeated( + slices: &[&[u8]], + out_count: *mut i64, + out_long_cap: i64, + out_byte_buf: *mut u8, + out_byte_offsets: *mut i64, + out_byte_buf_cap: i64, +) -> Result { + let count = slices.len(); + let total_bytes: usize = slices.iter().map(|s| s.len()).sum(); + if !out_count.is_null() { + *out_count = count as i64; + } + + // Element-count overflow: cannot safely write offsets (capacity is count+1). + if (count as i64) > out_long_cap { + return Ok(RC_OVERFLOW); + } + + // Element count fits: write the full CSR offsets so that, even on a byte + // overflow, out_byte_offsets[count] == total_bytes reports the required size. + if !out_byte_offsets.is_null() { + let mut acc = 0i64; + for (i, s) in slices.iter().enumerate() { + *out_byte_offsets.add(i) = acc; + acc += s.len() as i64; + } + *out_byte_offsets.add(count) = acc; + } + + if (total_bytes as i64) > out_byte_buf_cap || (total_bytes > 0 && out_byte_buf.is_null()) { + return Ok(RC_OVERFLOW); + } + + let mut acc = 0usize; + for s in slices { + if !s.is_empty() { + std::ptr::copy_nonoverlapping(s.as_ptr(), out_byte_buf.add(acc), s.len()); + } + acc += s.len(); + } + Ok(RC_OK) +} + +// --------------------------------------------------------------------------- +// Page-index loader + page decoder +// --------------------------------------------------------------------------- +// +// Page-oriented reads used by the Java `ParquetColumnReader` to decode a whole +// page per call rather than a row per call: +// - `parquet_get_column_num_pages` — page count, so Java can pre-size buffers +// - `parquet_get_column_page_index` — per-page row-range jump table + page stats +// - `parquet_decode_page_at_row` — decoded values + presence bitset for a page +// +// All row indices exchanged here are global (file-relative). + +/// Returns the number of pages in the column (`>= 0`), or a `< 0` error pointer +/// for an unknown handle. Java reads this first to size the parallel arrays +/// passed to `parquet_get_column_page_index`. +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn parquet_get_column_num_pages(handle: i64) -> i64 { + let guard = lock_readers()?; + let state = guard + .get(&handle) + .ok_or_else(|| format!("parquet_get_column_num_pages: unknown handle {}", handle))?; + Ok(state.pages.len() as i64) +} + +/// Writes the column's per-page row-range jump table and page statistics into +/// caller-provided parallel arrays, each of capacity `out_buf_capacity` +/// (= the page count from `parquet_get_column_num_pages`). +/// +/// Arrays (length = page count): +/// - `out_first_row` global index of the page's first row +/// - `out_file_offset` byte offset of the page in the file (0 if unknown) +/// - `out_compressed_size` compressed page size in bytes (0 if unknown) +/// - `out_null_count` nulls in the page, or -1 when unknown +/// - `out_min_long` per-page min raw bits (numeric only; 0 otherwise) +/// - `out_max_long` per-page max raw bits (numeric only; 0 otherwise) +/// +/// `out_actual_pages` always receives the true page count. Returns `RC_OVERFLOW` +/// (a positive sentinel) without writing the arrays when `out_buf_capacity` is +/// smaller than the page count, so the caller can grow and retry. Returns a +/// `< 0` error pointer for an unknown handle. +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn parquet_get_column_page_index( + handle: i64, + out_first_row: *mut i64, + out_file_offset: *mut i64, + out_compressed_size: *mut i32, + out_null_count: *mut i64, + out_min_long: *mut i64, + out_max_long: *mut i64, + out_buf_capacity: i64, + out_actual_pages: *mut i64, +) -> i64 { + let guard = lock_readers()?; + let state = guard + .get(&handle) + .ok_or_else(|| format!("parquet_get_column_page_index: unknown handle {}", handle))?; + + let n = state.pages.len(); + if !out_actual_pages.is_null() { + *out_actual_pages = n as i64; + } + if (n as i64) > out_buf_capacity { + return Ok(RC_OVERFLOW); + } + + for (i, e) in state.pages.iter().enumerate() { + if !out_first_row.is_null() { + *out_first_row.add(i) = e.global_first_row; + } + if !out_file_offset.is_null() { + *out_file_offset.add(i) = e.file_offset; + } + if !out_compressed_size.is_null() { + *out_compressed_size.add(i) = e.compressed_size; + } + if !out_null_count.is_null() { + *out_null_count.add(i) = e.null_count; + } + if !out_min_long.is_null() { + *out_min_long.add(i) = e.min_long; + } + if !out_max_long.is_null() { + *out_max_long.add(i) = e.max_long; + } + } + Ok(RC_OK) +} + +/// Decodes one primitive page through a (possibly retained) typed column reader and writes +/// values + packed presence straight into the caller's out-buffers. `skip` is relative to the +/// reader's current position, NOT the row group start — the retained-cursor caller computes it +/// from the cursor position so a reused reader only skips forward the remaining distance. +#[allow(clippy::too_many_arguments)] +unsafe fn decode_primitive_page( + r: &mut ColumnReaderImpl, + skip: usize, + num_rows: usize, + max_def_level: i16, + effective_null_count: i64, + def_scratch: &mut Vec, + val_scratch: &mut Vec, + to_bits: impl Fn(T::T) -> i64, + out_value_buf: *mut u8, + out_presence_bitset: *mut i64, +) -> Result<(), String> +where + T::T: Copy, +{ + decode_page_records(r, skip, num_rows, def_scratch, val_scratch)?; + pack_presence_from_def_levels(def_scratch, max_def_level, num_rows, out_presence_bitset); + expand_to_outbuf(val_scratch, to_bits, effective_null_count, num_rows, out_value_buf, out_presence_bitset as *const i64); + Ok(()) +} + +/// Decodes one page's worth of single-valued records, returning a per-row +/// presence flag (`true` = value present) and the dense list of non-null values +/// in row order. `skip` records are skipped first, then `num_rows` records are +/// read. Works for required columns (`max_def_level == 0`, all present) and +/// optional non-repeated columns. +fn decode_page_records( + r: &mut ColumnReaderImpl, + skip: usize, + num_rows: usize, + scratch_def: &mut Vec, + scratch_vals: &mut Vec, +) -> Result<(), String> { + if skip > 0 { + let skipped = r.skip_records(skip).map_err(|e| e.to_string())?; + if skipped < skip { + return Err(format!( + "page decode: requested skip of {} records but only {} available", + skip, skipped + )); + } + } + + scratch_def.clear(); + scratch_vals.clear(); + scratch_def.reserve(num_rows.saturating_sub(scratch_def.capacity())); + scratch_vals.reserve(num_rows.saturating_sub(scratch_vals.capacity())); + + let (records_read, _values_read, _levels_read) = r + .read_records(num_rows, Some(scratch_def), None, scratch_vals) + .map_err(|e| e.to_string())?; + if records_read < num_rows { + return Err(format!( + "page decode: expected {} records but read {}", + num_rows, records_read + )); + } + Ok(()) +} + +/// Packs definition levels directly into a little-endian `long[]` bitset in the +/// caller's out-buffer, with bit `i` set when `def_levels[i] == max_def_level`. +/// When `max_def_level == 0` (required column), all bits are set. +/// +/// Writes `ceil(num_rows / 64)` words; the caller must ensure `out` has capacity +/// for that many. Uses a branchless comparison so the inner loop auto-vectorizes. +/// +/// The primitive path uses this because it has def_levels in hand and no per-row +/// bool vector; the byte path instead uses `write_presence_bitset`, packing from +/// the `Vec` it already builds to slice its values. +#[inline] +unsafe fn pack_presence_from_def_levels( + def_levels: &[i16], + max_def_level: i16, + num_rows: usize, + out: *mut i64, +) { + let words = (num_rows + 63) / 64; + if max_def_level == 0 { + // Required column: every row is present → all-ones, mask tail. + for w in 0..words { + let remaining = num_rows - w * 64; + if remaining >= 64 { + *out.add(w) = -1i64; // all bits set + } else { + *out.add(w) = ((1u64 << remaining) - 1) as i64; + } + } + } else { + // Optional column: branchless pack. The inner loop auto-vectorizes because + // `(d == max_def_level) as u64` is a conditional-move / compare instruction. + for w in 0..words { + let mut bits: u64 = 0; + let base = w * 64; + let end = (base + 64).min(num_rows); + for b in base..end { + bits |= ((*def_levels.get_unchecked(b) == max_def_level) as u64) << (b - base); + } + *out.add(w) = bits as i64; + } + } +} + +/// Writes dense non-null values into the caller's out-buffer as per-row raw i64 +/// bits, using the packed presence bitset to scatter. Null slots hold 0. +/// +/// The inner scatter loop is split by nullability: when `null_count == 0` the +/// entire dense buffer can be converted with a tight widening loop that +/// auto-vectorizes (LLVM emits vpmovsxdq / sshll). The nullable path reads the +/// packed bits we just wrote and scatters accordingly. +/// +/// # Safety +/// `out` must be valid for `num_rows * 8` bytes. `presence_bits` must contain +/// the packed bitset already written by `pack_presence_from_def_levels`. +#[inline] +unsafe fn expand_to_outbuf( + dense: &[T], + to_bits: impl Fn(T) -> i64, + null_count: i64, + num_rows: usize, + out: *mut u8, + presence_bits: *const i64, +) { + let out_i64 = out as *mut i64; + if null_count == 0 { + // All rows present — tight conversion loop, no branching, SIMD-friendly. + for i in 0..num_rows { + *out_i64.add(i) = to_bits(*dense.get_unchecked(i)); + } + } else { + // Scatter using the packed presence bits. Read one word at a time and use + // trailing_zeros to jump to set bits (pop-and-scatter pattern). + // First zero-fill so null slots hold 0 without an explicit branch. + std::ptr::write_bytes(out, 0, num_rows * 8); + let mut di = 0usize; + let words = (num_rows + 63) / 64; + for w in 0..words { + let mut bits = *presence_bits.add(w) as u64; + while bits != 0 { + let b = bits.trailing_zeros() as usize; + let row = w * 64 + b; + *out_i64.add(row) = to_bits(*dense.get_unchecked(di)); + di += 1; + bits &= bits - 1; // clear lowest set bit + } + } + } +} + +/// Packs a per-row presence slice into a little-endian `long[]` bitset (bit i set +/// when row i is present), writing into `out` (capacity `out_words`). Returns the +/// number of words required; on capacity shortfall writes nothing and the caller +/// treats the positive required count as an overflow signal. +/// +/// The byte path uses this because it already builds a `Vec` to slice its +/// values; the primitive path instead uses `pack_presence_from_def_levels`, which +/// packs straight from def_levels without materializing a bool vector. +unsafe fn write_presence_bitset(presence: &[bool], out: *mut i64, out_words: i64) -> i64 { + let words_needed = ((presence.len() + 63) / 64) as i64; + if words_needed > out_words || out.is_null() { + return words_needed; + } + for w in 0..words_needed as usize { + let mut bits: u64 = 0; + let base = w * 64; + for b in 0..64 { + let idx = base + b; + if idx >= presence.len() { + break; + } + if presence[idx] { + bits |= 1u64 << b; + } + } + *out.add(w) = bits as i64; + } + words_needed +} + +/// Decode the page containing global row `row` into caller buffers (values + +/// presence bitset). +/// +/// On success (`RC_OK`): +/// - `out_first_row` / `out_last_row` = inclusive global row range of the page +/// - primitive columns: per-row raw bits written to `out_value_buf` +/// (interpreted as `long[]`, one slot per row; null rows hold 0); +/// `out_value_actual_len` = `rows * 8` +/// - BYTE_ARRAY columns: concatenated value bytes in `out_value_buf`, per-row +/// CSR offsets (length `rows + 1`) in `out_byte_offsets`; +/// `out_value_actual_len` = total bytes used +/// - `out_presence_bitset` = packed `long[]`, one bit per row +/// +/// On `RC_OVERFLOW` (a positive sentinel): `out_first_row`, `out_last_row` and +/// `out_value_actual_len` are populated so the caller can size every buffer +/// (values = `out_value_actual_len` bytes; offsets = `rows + 1`; presence = +/// `ceil(rows / 64)` words) and retry once. Returns a `< 0` error pointer for an +/// unknown handle, an out-of-range row, or a repeated (multi-valued) column. +#[ffm_safe] +#[no_mangle] +pub unsafe extern "C" fn parquet_decode_page_at_row( + handle: i64, + row: i64, + out_first_row: *mut i64, + out_last_row: *mut i64, + out_value_buf: *mut u8, + out_value_buf_cap: i64, + out_value_actual_len: *mut i64, + out_byte_offsets: *mut i32, + out_byte_offsets_cap: i64, + out_presence_bitset: *mut i64, + out_presence_bits_cap: i64, +) -> i64 { + let mut guard = lock_readers()?; + let state = guard + .get_mut(&handle) + .ok_or_else(|| format!("parquet_decode_page_at_row: unknown handle {}", handle))?; + + if row < 0 || row >= state.row_count { + return Err(format!( + "parquet_decode_page_at_row: row {} out of range (row count {})", + row, state.row_count + )); + } + if state.repeated { + return Err(format!( + "parquet_decode_page_at_row: column is repeated (multi-valued); use parquet_read_repeated_at_row (handle {})", + handle + )); + } + + let page_idx = state.page_for_row(row)?; + // Copy out the page's coordinates before borrowing the reader mutably. + let (rg_idx, local_first, num_rows, first_global) = { + let e = &state.pages[page_idx]; + (e.rg_idx, e.local_first_row, e.num_rows, e.global_first_row) + }; + let num_rows_usize = num_rows as usize; + let max_def_level = state.max_def_level; + let physical_type = state.physical_type; + + // Always report the page row range so the caller can bound its cache and + // size buffers even on the overflow path. + if !out_first_row.is_null() { + *out_first_row = first_global; + } + if !out_last_row.is_null() { + *out_last_row = first_global + num_rows - 1; + } + + // For primitive types the value byte length is known before decode (num_rows * 8), so check + // capacity up front and write straight into the out-buffers. BYTE_ARRAY defers its capacity + // check to after decode, since its total byte length is data-dependent. + let is_primitive = physical_type != PhysicalType::BYTE_ARRAY + && physical_type != PhysicalType::FIXED_LEN_BYTE_ARRAY; + + if is_primitive { + let value_bytes = (num_rows_usize * 8) as i64; + if !out_value_actual_len.is_null() { + *out_value_actual_len = value_bytes; + } + let presence_words = ((num_rows_usize + 63) / 64) as i64; + if value_bytes > out_value_buf_cap + || out_value_buf.is_null() + || presence_words > out_presence_bits_cap + || out_presence_bitset.is_null() + { + return Ok(RC_OVERFLOW); + } + } + + // Get the null_count from the page entry for the expand path (0 means all-present). + let page_null_count = state.pages[page_idx].null_count; + // If null_count is unknown (-1), treat as potentially nullable. + let effective_null_count = if page_null_count < 0 { 1 } else { page_null_count }; + + // Retained-cursor reader acquisition. A typed column reader can only move forward, so it + // is reusable exactly when the target page is in the same row group at-or-ahead of the + // cursor's position; then the skip is the remaining forward distance and — crucially — + // the dictionary page and row-group metadata are NOT re-read. Any backward jump or + // row-group change falls back to a fresh reader (dictionary re-decoded once). + // The cursor is take()n up front so a decode error leaves it invalidated; each arm + // re-installs it only after a successful decode. + let (col, skip) = match state.cursor.take() { + Some(c) if c.rg_idx == rg_idx && c.position <= first_global => { + (c.col_reader, (first_global - c.position) as usize) + } + _ => { + let rg = state.reader.get_row_group(rg_idx).map_err(|e| e.to_string())?; + let col = rg.get_column_reader(state.leaf_idx).map_err(|e| e.to_string())?; + (col, local_first as usize) + } + }; + // Position of the reader after this page is consumed; stored on the re-installed cursor. + let next_position = first_global + num_rows; + + // Decode and write directly to the out-buffers. Primitive types decode with reused scratch + // buffers (no per-call allocation), pack presence branchlessly from def_levels, and scatter + // values straight to the out-buffer. BYTE_ARRAY/FIXED_LEN_BYTE_ARRAY decode into a temporary + // value vector first, since their total byte length must be computed before the overflow check. + match col { + ColumnReader::Int32ColumnReader(mut r) => { + let scratch = &mut state.scratch; + decode_primitive_page( + &mut r, skip, num_rows_usize, max_def_level, effective_null_count, + &mut scratch.def_levels, &mut scratch.values_i32, |v| v as i64, + out_value_buf, out_presence_bitset, + )?; + state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::Int32ColumnReader(r), position: next_position }); + return Ok(RC_OK); + } + ColumnReader::Int64ColumnReader(mut r) => { + let scratch = &mut state.scratch; + decode_primitive_page( + &mut r, skip, num_rows_usize, max_def_level, effective_null_count, + &mut scratch.def_levels, &mut scratch.values_i64, |v| v, + out_value_buf, out_presence_bitset, + )?; + state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::Int64ColumnReader(r), position: next_position }); + return Ok(RC_OK); + } + ColumnReader::FloatColumnReader(mut r) => { + let scratch = &mut state.scratch; + decode_primitive_page( + &mut r, skip, num_rows_usize, max_def_level, effective_null_count, + &mut scratch.def_levels, &mut scratch.values_f32, |v| v.to_bits() as i64, + out_value_buf, out_presence_bitset, + )?; + state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::FloatColumnReader(r), position: next_position }); + return Ok(RC_OK); + } + ColumnReader::DoubleColumnReader(mut r) => { + let scratch = &mut state.scratch; + decode_primitive_page( + &mut r, skip, num_rows_usize, max_def_level, effective_null_count, + &mut scratch.def_levels, &mut scratch.values_f64, |v| v.to_bits() as i64, + out_value_buf, out_presence_bitset, + )?; + state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::DoubleColumnReader(r), position: next_position }); + return Ok(RC_OK); + } + ColumnReader::BoolColumnReader(mut r) => { + let scratch = &mut state.scratch; + decode_primitive_page( + &mut r, skip, num_rows_usize, max_def_level, effective_null_count, + &mut scratch.def_levels, &mut scratch.values_bool, |v| if v { 1i64 } else { 0i64 }, + out_value_buf, out_presence_bitset, + )?; + state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::BoolColumnReader(r), position: next_position }); + return Ok(RC_OK); + } + ColumnReader::ByteArrayColumnReader(mut r) => { + let rc = decode_byte_page( + &mut r, skip, num_rows_usize, max_def_level, &mut state.scratch.def_levels, + out_value_buf, out_value_buf_cap, out_value_actual_len, + out_byte_offsets, out_byte_offsets_cap, out_presence_bitset, out_presence_bits_cap, + )?; + // The page was fully consumed even on RC_OVERFLOW, so the cursor position is + // next_position either way; the overflow retry of the same row then simply + // misses the cursor (position > first_global) and opens a fresh reader. + state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::ByteArrayColumnReader(r), position: next_position }); + return Ok(rc); + } + ColumnReader::FixedLenByteArrayColumnReader(mut r) => { + let rc = decode_byte_page( + &mut r, skip, num_rows_usize, max_def_level, &mut state.scratch.def_levels, + out_value_buf, out_value_buf_cap, out_value_actual_len, + out_byte_offsets, out_byte_offsets_cap, out_presence_bitset, out_presence_bits_cap, + )?; + // See the ByteArray arm: page fully consumed even on RC_OVERFLOW. + state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::FixedLenByteArrayColumnReader(r), position: next_position }); + return Ok(rc); + } + ColumnReader::Int96ColumnReader(_) => { + let _ = physical_type; // silence unused in this arm + Err("parquet_decode_page_at_row: INT96 columns are not supported".to_string()) + } + } +} + +/// Expands dense non-null byte values into a per-row slice vector, null rows mapping to an empty +/// slice. Works for both `ByteArray` and `FixedLenByteArray` via the `AsBytes` trait they share. +fn expand_byte_values<'a, T: parquet::data_type::AsBytes>(presence: &[bool], dense: &'a [T]) -> Vec<&'a [u8]> { + let mut out: Vec<&[u8]> = Vec::with_capacity(presence.len()); + let mut di = 0usize; + for &present in presence { + if present { + out.push(dense[di].as_bytes()); + di += 1; + } else { + out.push(&[]); + } + } + out +} + +/// Decodes one BYTE_ARRAY / FIXED_LEN_BYTE_ARRAY page and writes it to the caller buffers +/// (concatenated value bytes + per-row CSR offsets + presence bitset), or returns `RC_OVERFLOW` +/// after recording the required sizes. Shared by both byte column types via the `AsBytes` trait. +/// Unlike the primitive path, the values decode into a temporary vector first because the total +/// byte length is data-dependent and must be known before the capacity check. +#[allow(clippy::too_many_arguments)] +unsafe fn decode_byte_page( + r: &mut ColumnReaderImpl, + skip: usize, + num_rows: usize, + max_def_level: i16, + def_scratch: &mut Vec, + out_value_buf: *mut u8, + out_value_buf_cap: i64, + out_value_actual_len: *mut i64, + out_byte_offsets: *mut i32, + out_byte_offsets_cap: i64, + out_presence_bitset: *mut i64, + out_presence_bits_cap: i64, +) -> Result +where + T::T: parquet::data_type::AsBytes, +{ + let mut values: Vec = Vec::new(); + decode_page_records(r, skip, num_rows, def_scratch, &mut values)?; + let presence: Vec = if max_def_level == 0 { + vec![true; num_rows] + } else { + def_scratch.iter().take(num_rows).map(|d| *d == max_def_level).collect() + }; + let slices = expand_byte_values(&presence, &values); + write_bytes_page( + &slices, &presence, out_value_buf, out_value_buf_cap, out_value_actual_len, + out_byte_offsets, out_byte_offsets_cap, out_presence_bitset, out_presence_bits_cap, + ) +} + +/// Writes a decoded BYTE_ARRAY page (concatenated bytes + CSR offsets + presence +/// bitset) to the caller buffers, or returns `RC_OVERFLOW` after recording the +/// required total byte length. +unsafe fn write_bytes_page( + slices: &[&[u8]], + presence: &[bool], + out_value_buf: *mut u8, + out_value_buf_cap: i64, + out_value_actual_len: *mut i64, + out_byte_offsets: *mut i32, + out_byte_offsets_cap: i64, + out_presence_bitset: *mut i64, + out_presence_bits_cap: i64, +) -> Result { + let total_bytes: usize = slices.iter().map(|s| s.len()).sum(); + if !out_value_actual_len.is_null() { + *out_value_actual_len = total_bytes as i64; + } + + let offsets_needed = (slices.len() + 1) as i64; + let presence_words = ((presence.len() + 63) / 64) as i64; + if (total_bytes as i64) > out_value_buf_cap + || (total_bytes > 0 && out_value_buf.is_null()) + || offsets_needed > out_byte_offsets_cap + || out_byte_offsets.is_null() + || presence_words > out_presence_bits_cap + || out_presence_bitset.is_null() + { + return Ok(RC_OVERFLOW); + } + + let mut acc: i32 = 0; + for (i, s) in slices.iter().enumerate() { + *out_byte_offsets.add(i) = acc; + if !s.is_empty() { + std::ptr::copy_nonoverlapping(s.as_ptr(), out_value_buf.add(acc as usize), s.len()); + } + acc += s.len() as i32; + } + *out_byte_offsets.add(slices.len()) = acc; + + write_presence_bitset(presence, out_presence_bitset, out_presence_bits_cap); + Ok(RC_OK) +} diff --git a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/FieldTypeMappingTests.java b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/FieldTypeMappingTests.java new file mode 100644 index 0000000000000..2792f34391c59 --- /dev/null +++ b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/FieldTypeMappingTests.java @@ -0,0 +1,82 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.parquet.codec; + +import org.apache.lucene.index.DocValuesType; +import org.opensearch.test.OpenSearchTestCase; + +/** + * Unit tests for {@link FieldTypeMapping} (task 4.3): the OpenSearch mapping type → Lucene DV + * type + Parquet physical type table, and the compatibility {@code validate} guard. + */ +public class FieldTypeMappingTests extends OpenSearchTestCase { + + public void testNumericMappings() { + assertMapping("boolean", DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.BOOL); + assertMapping("byte", DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT32); + assertMapping("short", DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT32); + assertMapping("integer", DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT32); + assertMapping("long", DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT64); + assertMapping("float", DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.FLOAT); + assertMapping("double", DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.DOUBLE); + assertMapping("date", DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT64); + assertMapping("date_nanos", DocValuesType.NUMERIC, DocValuesType.SORTED_NUMERIC, ParquetPhysicalType.INT64); + } + + public void testKeywordAndIpMappings() { + assertMapping("keyword", DocValuesType.SORTED, DocValuesType.SORTED_SET, ParquetPhysicalType.BYTE_ARRAY); + assertMapping("ip", DocValuesType.SORTED, DocValuesType.SORTED_SET, ParquetPhysicalType.BYTE_ARRAY); + } + + public void testTextAndBinaryMappings() { + assertMapping("text", DocValuesType.BINARY, DocValuesType.NONE, ParquetPhysicalType.BYTE_ARRAY); + assertMapping("binary", DocValuesType.BINARY, DocValuesType.NONE, ParquetPhysicalType.BYTE_ARRAY); + } + + public void testUnsupportedTypeThrows() { + assertFalse(FieldTypeMapping.isSupported("geo_point")); + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> FieldTypeMapping.forType("geo_point")); + assertTrue(e.getMessage().contains("geo_point")); + } + + public void testValidateAcceptsSingleAndMultiValuedForms() { + // long supports NUMERIC (single) and SORTED_NUMERIC (multi). + FieldTypeMapping.validate("age", "long", DocValuesType.NUMERIC); + FieldTypeMapping.validate("ages", "long", DocValuesType.SORTED_NUMERIC); + // keyword supports SORTED (single) and SORTED_SET (multi). + FieldTypeMapping.validate("tag", "keyword", DocValuesType.SORTED); + FieldTypeMapping.validate("tags", "keyword", DocValuesType.SORTED_SET); + } + + public void testValidateRejectsIncompatibleDvType() { + // Requesting NUMERIC for a keyword field must fail and name the field + type. + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> FieldTypeMapping.validate("tag", "keyword", DocValuesType.NUMERIC) + ); + assertTrue(e.getMessage().contains("tag")); + assertTrue(e.getMessage().contains("keyword")); + } + + public void testValidateRejectsUnsupportedMappingType() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> FieldTypeMapping.validate("loc", "geo_point", DocValuesType.BINARY) + ); + assertTrue(e.getMessage().contains("loc")); + assertTrue(e.getMessage().contains("geo_point")); + } + + private static void assertMapping(String type, DocValuesType single, DocValuesType multi, ParquetPhysicalType physical) { + FieldTypeMapping.Mapping m = FieldTypeMapping.forType(type); + assertEquals("single-valued DV for " + type, single, m.singleValued()); + assertEquals("multi-valued DV for " + type, multi, m.multiValued()); + assertEquals("physical type for " + type, physical, m.physical()); + } +} diff --git a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/ParquetPhysicalTypeTests.java b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/ParquetPhysicalTypeTests.java new file mode 100644 index 0000000000000..a3262b83fa4f0 --- /dev/null +++ b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/ParquetPhysicalTypeTests.java @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.parquet.codec; + +import org.opensearch.test.OpenSearchTestCase; + +/** + * Unit tests for {@link ParquetPhysicalType}. The {@code code()} discriminants are exchanged with + * the Rust FFM bridge ({@code parquet_open_column_reader}'s {@code expected_type}) and MUST stay in + * lock-step with the {@code TYPE_*} constants in {@code ffm.rs}; these tests pin the exact integer + * values so a future reorder of the enum cannot silently desync Java from the native side. + */ +public class ParquetPhysicalTypeTests extends OpenSearchTestCase { + + public void testCodeDiscriminantsMatchNativeContract() { + // Must match the TYPE_* constants in ffm.rs. Do not change without updating the native side. + assertEquals(0, ParquetPhysicalType.INT32.code()); + assertEquals(1, ParquetPhysicalType.INT64.code()); + assertEquals(2, ParquetPhysicalType.FLOAT.code()); + assertEquals(3, ParquetPhysicalType.DOUBLE.code()); + assertEquals(4, ParquetPhysicalType.BOOL.code()); + assertEquals(5, ParquetPhysicalType.BYTE_ARRAY.code()); + } + + public void testCodesAreUnique() { + ParquetPhysicalType[] values = ParquetPhysicalType.values(); + boolean[] seen = new boolean[values.length]; + for (ParquetPhysicalType t : values) { + int c = t.code(); + assertTrue("code out of expected range: " + c, c >= 0 && c < values.length); + assertFalse("duplicate code " + c + " for " + t, seen[c]); + seen[c] = true; + } + } + + public void testIsPrimitive() { + // Every fixed-width type is primitive (values exchanged as raw long bits); only BYTE_ARRAY is not. + assertTrue(ParquetPhysicalType.INT32.isPrimitive()); + assertTrue(ParquetPhysicalType.INT64.isPrimitive()); + assertTrue(ParquetPhysicalType.FLOAT.isPrimitive()); + assertTrue(ParquetPhysicalType.DOUBLE.isPrimitive()); + assertTrue(ParquetPhysicalType.BOOL.isPrimitive()); + assertFalse(ParquetPhysicalType.BYTE_ARRAY.isPrimitive()); + } +} From 91c6d645af96d58bb7baf5a55c31f9ab5d00a068 Mon Sep 17 00:00:00 2001 From: Manas Lohani Date: Thu, 30 Jul 2026 17:46:57 +0530 Subject: [PATCH 2/2] Address review feedback on Parquet DocValues foundation - Reject byte pages whose total exceeds i32::MAX instead of wrapping the offset accumulator (silent corruption / out-of-bounds write); accumulate in i64 and validate before storing into the i32 offset array. - Install the retained decode cursor only on a successful decode in the byte-array arms, matching the primitive path (an overflow retry reopens a fresh reader anyway). - Add a debug assertion guarding the unchecked def-level read in pack_presence_from_def_levels so a corrupt page index fails loudly in tests. - Rename ParquetPhysicalType.isPrimitive to isFixedWidth: Parquet's own taxonomy counts BYTE_ARRAY as a primitive, so the previous name was misleading; the real distinction is fixed- vs variable-width. - Document the Mapping record's (single, multi, physical) triple, including why boolean maps to a numeric DocValues type (0/1, as core OpenSearch does). - Explain why NativeCall exposes static invoke variants alongside the instance ones (read path owns its buffers in a pooled Arena; static calls allocate nothing and are stateless). Signed-off-by: Manas Lohani --- .../nativebridge/spi/NativeCall.java | 12 +++++++ .../parquet/codec/FieldTypeMapping.java | 15 +++++++- .../parquet/codec/ParquetPhysicalType.java | 8 +++-- .../src/main/rust/src/ffm.rs | 35 +++++++++++++------ .../codec/ParquetPhysicalTypeTests.java | 16 ++++----- 5 files changed, 65 insertions(+), 21 deletions(-) diff --git a/sandbox/libs/dataformat-native/src/main/java/org/opensearch/nativebridge/spi/NativeCall.java b/sandbox/libs/dataformat-native/src/main/java/org/opensearch/nativebridge/spi/NativeCall.java index e46a59e0969b9..f8b617046ed81 100644 --- a/sandbox/libs/dataformat-native/src/main/java/org/opensearch/nativebridge/spi/NativeCall.java +++ b/sandbox/libs/dataformat-native/src/main/java/org/opensearch/nativebridge/spi/NativeCall.java @@ -268,6 +268,18 @@ public static void invokeVoid(MethodHandle handle, Object... args) { } } + // ---- Static invocation (no Arena) ---- + // + // The instance invoke / invokeIO above run inside a NativeCall so short-lived arguments + // (marshalled strings, out-pointers) can be allocated in the instance's Arena and freed on + // close. The two static variants below are for callers that already own every argument in + // their own memory and so need no Arena — chiefly the column-reader read path, where + // ParquetColumnReader keeps its buffers in a pooled Arena reused across many reads. Using the + // instance form there would allocate and immediately close a throwaway Arena on every read. + // Being static, these allocate nothing; they only run the handle and check the status. They are + // also stateless (they touch only their parameters and locals), so concurrent calls from + // different query threads share no mutable state. + /** * Invokes a {@code long}-returning native call and checks its status: returns the value * when {@code >= 0} (positive status codes are passed through), throws {@link IOException} diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/FieldTypeMapping.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/FieldTypeMapping.java index 47cc15ff5a562..23d708230792c 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/FieldTypeMapping.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/FieldTypeMapping.java @@ -25,7 +25,20 @@ */ public final class FieldTypeMapping { - /** The resolved Lucene DV type + Parquet physical type for a mapping type. */ + /** + * The resolved DocValues + physical type for one OpenSearch mapping type. + * + * @param singleValued Lucene DV type served when the field holds one value per document + * (e.g. {@code NUMERIC} for {@code long}, {@code SORTED} for {@code keyword}) + * @param multiValued Lucene DV type served when the field is an array + * ({@code SORTED_NUMERIC} for numerics, {@code SORTED_SET} for keyword/ip; + * {@code NONE} for types with no array form, e.g. {@code text}) + * @param physical the Parquet physical type the column is stored as, read back by the codec + * + *

{@code boolean} maps to a numeric DV type ({@code NUMERIC} / {@code SORTED_NUMERIC}) because + * Lucene has no boolean DV type; it is stored as {@code 0}/{@code 1}, matching how core + * OpenSearch indexes boolean doc values. + */ public record Mapping(DocValuesType singleValued, DocValuesType multiValued, ParquetPhysicalType physical) { } diff --git a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/ParquetPhysicalType.java b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/ParquetPhysicalType.java index 7e3e3f22556ce..f0a9c0a9ba3e8 100644 --- a/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/ParquetPhysicalType.java +++ b/sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/codec/ParquetPhysicalType.java @@ -44,8 +44,12 @@ public int code() { return code; } - /** True for the fixed-width primitive types whose values are exchanged as raw {@code long} bits. */ - public boolean isPrimitive() { + /** + * True for the fixed-width types whose values are exchanged as raw {@code long} bits (everything + * except {@code BYTE_ARRAY}, which is variable-width and exchanged as bytes + length). Named for + * width rather than "primitive" because Parquet's own taxonomy counts BYTE_ARRAY as a primitive. + */ + public boolean isFixedWidth() { return this != BYTE_ARRAY; } } diff --git a/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs b/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs index 03812394941bb..a8cea7446fe00 100644 --- a/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs +++ b/sandbox/plugins/parquet-data-format/src/main/rust/src/ffm.rs @@ -2047,6 +2047,9 @@ unsafe fn pack_presence_from_def_levels( num_rows: usize, out: *mut i64, ) { + // The optional-column branch reads def_levels[0..num_rows] unchecked; assert the precondition + // so a corrupt page index or decoder state fails loudly in tests rather than reading OOB. + debug_assert!(def_levels.len() >= num_rows || max_def_level == 0); let words = (num_rows + 63) / 64; if max_def_level == 0 { // Required column: every row is present → all-ones, mask tail. @@ -2326,10 +2329,11 @@ pub unsafe extern "C" fn parquet_decode_page_at_row( out_value_buf, out_value_buf_cap, out_value_actual_len, out_byte_offsets, out_byte_offsets_cap, out_presence_bitset, out_presence_bits_cap, )?; - // The page was fully consumed even on RC_OVERFLOW, so the cursor position is - // next_position either way; the overflow retry of the same row then simply - // misses the cursor (position > first_global) and opens a fresh reader. - state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::ByteArrayColumnReader(r), position: next_position }); + // Install the cursor only on a successful decode, matching the primitive path. On + // RC_OVERFLOW the caller retries the same row, which reopens a fresh reader anyway. + if rc == RC_OK { + state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::ByteArrayColumnReader(r), position: next_position }); + } return Ok(rc); } ColumnReader::FixedLenByteArrayColumnReader(mut r) => { @@ -2338,8 +2342,10 @@ pub unsafe extern "C" fn parquet_decode_page_at_row( out_value_buf, out_value_buf_cap, out_value_actual_len, out_byte_offsets, out_byte_offsets_cap, out_presence_bitset, out_presence_bits_cap, )?; - // See the ByteArray arm: page fully consumed even on RC_OVERFLOW. - state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::FixedLenByteArrayColumnReader(r), position: next_position }); + // See the ByteArray arm: install the cursor only on a successful decode. + if rc == RC_OK { + state.cursor = Some(CursorState { rg_idx, col_reader: ColumnReader::FixedLenByteArrayColumnReader(r), position: next_position }); + } return Ok(rc); } ColumnReader::Int96ColumnReader(_) => { @@ -2433,15 +2439,24 @@ unsafe fn write_bytes_page( return Ok(RC_OVERFLOW); } - let mut acc: i32 = 0; + // Offsets are exchanged as i32 (a page is small by design, ~20k rows). Accumulate in i64 and + // reject a page whose byte total would exceed i32::MAX rather than let the offset wrap, which + // would corrupt the offsets and drive an out-of-bounds write into out_value_buf. + let mut acc: i64 = 0; for (i, s) in slices.iter().enumerate() { - *out_byte_offsets.add(i) = acc; + if acc > i32::MAX as i64 { + return Err(format!("byte page offset {} exceeds i32::MAX", acc)); + } + *out_byte_offsets.add(i) = acc as i32; if !s.is_empty() { std::ptr::copy_nonoverlapping(s.as_ptr(), out_value_buf.add(acc as usize), s.len()); } - acc += s.len() as i32; + acc += s.len() as i64; + } + if acc > i32::MAX as i64 { + return Err(format!("byte page total {} exceeds i32::MAX", acc)); } - *out_byte_offsets.add(slices.len()) = acc; + *out_byte_offsets.add(slices.len()) = acc as i32; write_presence_bitset(presence, out_presence_bitset, out_presence_bits_cap); Ok(RC_OK) diff --git a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/ParquetPhysicalTypeTests.java b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/ParquetPhysicalTypeTests.java index a3262b83fa4f0..0c98011973f55 100644 --- a/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/ParquetPhysicalTypeTests.java +++ b/sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/codec/ParquetPhysicalTypeTests.java @@ -39,13 +39,13 @@ public void testCodesAreUnique() { } } - public void testIsPrimitive() { - // Every fixed-width type is primitive (values exchanged as raw long bits); only BYTE_ARRAY is not. - assertTrue(ParquetPhysicalType.INT32.isPrimitive()); - assertTrue(ParquetPhysicalType.INT64.isPrimitive()); - assertTrue(ParquetPhysicalType.FLOAT.isPrimitive()); - assertTrue(ParquetPhysicalType.DOUBLE.isPrimitive()); - assertTrue(ParquetPhysicalType.BOOL.isPrimitive()); - assertFalse(ParquetPhysicalType.BYTE_ARRAY.isPrimitive()); + public void testIsFixedWidth() { + // Fixed-width types exchange values as raw long bits; only BYTE_ARRAY is variable-width. + assertTrue(ParquetPhysicalType.INT32.isFixedWidth()); + assertTrue(ParquetPhysicalType.INT64.isFixedWidth()); + assertTrue(ParquetPhysicalType.FLOAT.isFixedWidth()); + assertTrue(ParquetPhysicalType.DOUBLE.isFixedWidth()); + assertTrue(ParquetPhysicalType.BOOL.isFixedWidth()); + assertFalse(ParquetPhysicalType.BYTE_ARRAY.isFixedWidth()); } }