-
Notifications
You must be signed in to change notification settings - Fork 3.9k
[fix](arrow-flight) Fix utf8/large string schema-array mismatch for oversized columns #65789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,7 @@ | |
| #include <utility> | ||
| #include <vector> | ||
|
|
||
| #include "common/config.h" | ||
| #include "common/status.h" | ||
| #include "core/block/column_with_type_and_name.h" | ||
| #include "core/column/column.h" | ||
|
|
@@ -193,13 +194,14 @@ Status FromBlockToRecordBatchConverter::convert(std::shared_ptr<arrow::RecordBat | |
| _cur_col = _block.get_by_position(idx).column; | ||
| _cur_type = _block.get_by_position(idx).type; | ||
| auto column = _cur_col->convert_to_full_column_if_const(); | ||
| // The Arrow schema is authoritative: build each array with exactly the field's declared | ||
| // type. (Previously a string/binary column whose data reached 2GB was silently upgraded | ||
| // here to large_utf8/large_binary while the schema kept utf8/binary, producing a batch | ||
| // whose int64 array offsets disagreed with its int32 schema -> data corruption on generic | ||
| // clients and negative-length crashes on the Doris receiver. Oversized columns are now | ||
| // handled up front by convert_to_arrow_batches, which splits the block by rows so every | ||
| // batch stays within the int32 offset limit.) | ||
| auto arrow_type = _schema->field(idx)->type(); | ||
| if (arrow_type->id() == arrow::Type::STRING && column->byte_size() >= MAX_ARROW_UTF8) { | ||
| arrow_type = arrow::large_utf8(); | ||
| } else if (arrow_type->id() == arrow::Type::BINARY && | ||
| column->byte_size() >= MAX_ARROW_UTF8) { | ||
| arrow_type = arrow::large_binary(); | ||
| } | ||
| std::unique_ptr<arrow::ArrayBuilder> builder; | ||
| auto arrow_st = arrow::MakeBuilder(_pool, arrow_type, &builder); | ||
| if (!arrow_st.ok()) { | ||
|
|
@@ -227,6 +229,11 @@ Status FromBlockToRecordBatchConverter::convert(std::shared_ptr<arrow::RecordBat | |
| } | ||
| } | ||
| *out = arrow::RecordBatch::Make(_schema, actual_rows, std::move(_arrays)); | ||
| // Defense-in-depth: guarantee the batch's array types match the schema (types + lengths). | ||
| // After removing the silent large_utf8 upgrade this always holds; keep the check so any | ||
| // future schema/array divergence fails loudly here instead of silently corrupting or | ||
| // crashing a downstream reader. | ||
| RETURN_IF_ERROR(to_doris_status((*out)->Validate())); | ||
| return Status::OK(); | ||
| } | ||
|
|
||
|
|
@@ -270,6 +277,122 @@ Status convert_to_arrow_batch(const Block& block, const std::shared_ptr<arrow::S | |
| return converter.convert(result); | ||
| } | ||
|
|
||
| namespace { | ||
|
|
||
| // Only utf8/binary use int32 offsets and can overflow at 2^31; fixed-size and large_* cannot. | ||
| bool is_int32_offset_binary_type(const std::shared_ptr<arrow::DataType>& type) { | ||
| return type->id() == arrow::Type::STRING || type->id() == arrow::Type::BINARY; | ||
| } | ||
|
|
||
| // Byte length written into the arrow string/binary array for `row` (0 for null). Only called for | ||
| // raw string/binary columns (see convert_to_arrow_batches), whose SerDe appends get_data_at(row) | ||
| // verbatim, so this equals the emitted arrow payload. | ||
| size_t arrow_value_byte_size_at(const IColumn& column, size_t row) { | ||
| const IColumn* data = &column; | ||
| if (const auto* nullable = check_and_get_column<ColumnNullable>(column)) { | ||
| if (nullable->is_null_at(row)) { | ||
| return 0; | ||
| } | ||
| data = &nullable->get_nested_column(); | ||
| } | ||
| return data->get_data_at(row).size; | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| Status convert_to_arrow_batches(const Block& block, const std::shared_ptr<arrow::Schema>& schema, | ||
| arrow::MemoryPool* pool, | ||
| std::vector<std::shared_ptr<arrow::RecordBatch>>* results, | ||
| const cctz::time_zone& timezone_obj) { | ||
| results->clear(); | ||
| const int num_fields = schema->num_fields(); | ||
| if (block.columns() != static_cast<size_t>(num_fields)) { | ||
| return Status::InvalidArgument("number fields not match"); | ||
| } | ||
| const size_t num_rows = block.rows(); | ||
| const auto max_bytes = static_cast<size_t>(config::arrow_flight_result_max_utf8_bytes); | ||
|
|
||
| // Fast path: O(#string cols), each byte_size() is O(1) and >= the actual arrow data size. | ||
| // If no int32-offset string/binary column reaches the limit, emit the whole block as one | ||
| // batch -- zero added cost on the normal path, and this never misses an overflow. | ||
| std::vector<int> big_fields; | ||
| for (int idx = 0; idx < num_fields; ++idx) { | ||
| if (!is_int32_offset_binary_type(schema->field(idx)->type())) { | ||
| continue; | ||
| } | ||
| // Only raw string/binary types append get_data_at(row) verbatim, so physical byte_size | ||
| // equals the arrow payload and per-row splitting is exact. Transform-serde types that | ||
| // also map to utf8/binary (JSONB -> JSON text, Variant, largeint/date/datetime -> text) | ||
| // do not, and Variant's column has no get_data_at at all. They stay on the fast path and | ||
| // rely on their SerDe's checked size cast plus the Arrow builder capacity check to error | ||
| // cleanly (never silently corrupt) if a value or batch overflows int32. | ||
| const auto pt = remove_nullable(block.get_by_position(idx).type)->get_primitive_type(); | ||
| if (!is_string_type(pt) && pt != TYPE_VARBINARY) { | ||
| continue; | ||
| } | ||
| if (block.get_by_position(idx).column->byte_size() >= max_bytes) { | ||
| big_fields.push_back(idx); | ||
| } | ||
| } | ||
| if (big_fields.empty() || num_rows == 0) { | ||
| std::shared_ptr<arrow::RecordBatch> batch; | ||
| RETURN_IF_ERROR(convert_to_arrow_batch(block, schema, pool, &batch, timezone_obj)); | ||
| results->push_back(std::move(batch)); | ||
| return Status::OK(); | ||
| } | ||
|
|
||
| // Materialize the (rarely) const columns once so per-row size probing is cheap and correct. | ||
| std::vector<ColumnPtr> big_cols; | ||
| big_cols.reserve(big_fields.size()); | ||
| for (int idx : big_fields) { | ||
| big_cols.push_back(block.get_by_position(idx).column->convert_to_full_column_if_const()); | ||
| } | ||
|
|
||
| // Walk rows, cutting a batch right before any tracked column would reach the int32 limit; | ||
| // the tightest-growing column decides each cut. | ||
| std::vector<size_t> running(big_cols.size()); | ||
| std::vector<size_t> row_bytes(big_cols.size()); | ||
| size_t start = 0; | ||
| while (start < num_rows) { | ||
| running.assign(running.size(), 0); | ||
| size_t end = start; | ||
| while (end < num_rows) { | ||
| bool fits = true; | ||
| for (size_t c = 0; c < big_cols.size(); ++c) { | ||
| const size_t rb = arrow_value_byte_size_at(*big_cols[c], end); | ||
| if (rb >= max_bytes) { | ||
| return Status::InternalError( | ||
| "Arrow Flight cannot send column '{}': a single value at row {} is {} " | ||
| "bytes, exceeding the Arrow utf8/binary int32 offset limit of {} " | ||
| "bytes.", | ||
| schema->field(big_fields[c])->name(), end, rb, max_bytes); | ||
| } | ||
| row_bytes[c] = rb; | ||
| if (running[c] + rb >= max_bytes) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Bound the complete Flight IPC body, not only value bytes The exact per-buffer threshold is covered separately; even after that is corrected, Arrow 17's |
||
| fits = false; | ||
| break; | ||
| } | ||
| } | ||
| if (!fits) { | ||
| break; | ||
| } | ||
| for (size_t c = 0; c < big_cols.size(); ++c) { | ||
| running[c] += row_bytes[c]; | ||
| } | ||
| ++end; | ||
| } | ||
| // The start row always fits alone (an oversized single value would have errored above), | ||
| // so `end` advances by at least one and the outer loop terminates. | ||
| DCHECK_GT(end, start); | ||
| std::shared_ptr<arrow::RecordBatch> batch; | ||
| RETURN_IF_ERROR( | ||
| convert_to_arrow_batch(block, schema, pool, &batch, timezone_obj, start, end)); | ||
| results->push_back(std::move(batch)); | ||
| start = end; | ||
| } | ||
| return Status::OK(); | ||
| } | ||
|
|
||
| Status make_zero_column_arrow_batch(const std::shared_ptr<arrow::Schema>& schema, int64_t rows, | ||
| std::shared_ptr<arrow::RecordBatch>* result) { | ||
| if (schema->num_fields() != 0) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -94,6 +94,14 @@ arrow::Status ArrowFlightBatchLocalReader::ReadNextImpl(std::shared_ptr<arrow::R | |
| // parameter *out not nullptr | ||
| *out = nullptr; | ||
| SCOPED_ATTACH_TASK(_mem_tracker); | ||
|
|
||
| // Drain any batches left over from splitting the previous block. | ||
| if (!_pending_batches.empty()) { | ||
| *out = std::move(_pending_batches.front()); | ||
| _pending_batches.pop_front(); | ||
| return arrow::Status::OK(); | ||
| } | ||
|
|
||
| TUniqueId tid = UniqueId(_statement->query_id).to_thrift(); | ||
| std::shared_ptr<ArrowFlightResultBlockBuffer> arrow_buffer; | ||
| RETURN_ARROW_STATUS_IF_ERROR( | ||
|
|
@@ -108,16 +116,23 @@ arrow::Status ArrowFlightBatchLocalReader::ReadNextImpl(std::shared_ptr<arrow::R | |
| } | ||
|
|
||
| { | ||
| // convert one batch | ||
| // convert one block, splitting into multiple batches if a string/binary column would | ||
| // overflow the int32 offset limit | ||
| SCOPED_ATOMIC_TIMER(&_convert_arrow_batch_timer); | ||
| st = convert_to_arrow_batch(*result, _schema, arrow::default_memory_pool(), out, | ||
| _timezone_obj); | ||
| std::vector<std::shared_ptr<arrow::RecordBatch>> batches; | ||
| st = convert_to_arrow_batches(*result, _schema, arrow::default_memory_pool(), &batches, | ||
| _timezone_obj); | ||
| st.prepend("ArrowFlightBatchLocalReader convert block to arrow batch failed"); | ||
| ARROW_RETURN_NOT_OK(to_arrow_status(st)); | ||
| for (auto& batch : batches) { | ||
| _pending_batches.push_back(std::move(batch)); | ||
| } | ||
| } | ||
|
|
||
| _packet_seq++; | ||
| if (*out != nullptr) { | ||
| if (!_pending_batches.empty()) { | ||
| *out = std::move(_pending_batches.front()); | ||
| _pending_batches.pop_front(); | ||
| VLOG_NOTICE << "ArrowFlightBatchLocalReader read next: " << (*out)->num_rows() << ", " | ||
| << (*out)->num_columns() << ", packet_seq: " << _packet_seq; | ||
| } | ||
|
|
@@ -295,22 +310,37 @@ arrow::Status ArrowFlightBatchRemoteReader::ReadNextImpl(std::shared_ptr<arrow:: | |
| // parameter *out not nullptr | ||
| *out = nullptr; | ||
| SCOPED_ATTACH_TASK(_mem_tracker); | ||
|
|
||
| // Drain any batches left over from splitting the previous block. | ||
| if (!_pending_batches.empty()) { | ||
| *out = std::move(_pending_batches.front()); | ||
| _pending_batches.pop_front(); | ||
| return arrow::Status::OK(); | ||
| } | ||
|
|
||
| ARROW_RETURN_NOT_OK(_fetch_data()); | ||
| if (_block == nullptr) { | ||
| // eof, normal path end, last _fetch_data return block is nullptr | ||
| return arrow::Status::OK(); | ||
| } | ||
| { | ||
| // convert one batch | ||
| // convert one block, splitting into multiple batches if a string/binary column would | ||
| // overflow the int32 offset limit | ||
| SCOPED_ATOMIC_TIMER(&_convert_arrow_batch_timer); | ||
| auto st = convert_to_arrow_batch(*_block, _schema, arrow::default_memory_pool(), out, | ||
| _timezone_obj); | ||
| std::vector<std::shared_ptr<arrow::RecordBatch>> batches; | ||
| auto st = convert_to_arrow_batches(*_block, _schema, arrow::default_memory_pool(), &batches, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Split before the remote protobuf boundary The remote reader reaches this helper only after |
||
| _timezone_obj); | ||
| st.prepend("ArrowFlightBatchRemoteReader convert block to arrow batch failed"); | ||
| ARROW_RETURN_NOT_OK(to_arrow_status(st)); | ||
| for (auto& batch : batches) { | ||
| _pending_batches.push_back(std::move(batch)); | ||
| } | ||
| } | ||
| _block = nullptr; | ||
|
|
||
| if (*out != nullptr) { | ||
| if (!_pending_batches.empty()) { | ||
| *out = std::move(_pending_batches.front()); | ||
| _pending_batches.pop_front(); | ||
| VLOG_NOTICE << "ArrowFlightBatchRemoteReader read next: " << (*out)->num_rows() << ", " | ||
| << (*out)->num_columns() << ", packet_seq: " << _packet_seq; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Recurse into nested int32-offset builders
This predicate only recognizes a top-level
STRING/BINARY.ARRAY,MAP, andSTRUCTcan contain shared child String/Binary builders (and List/Map themselves have int32 element offsets), but those fields always take the fast path. For example, twoARRAY<STRING>parent rows with individually representable child data can exceed the child builder together and fail conversion, although emitting one parent row per batch would succeed. Please account for descendant byte/element limits per top-level row, return a clear error when one parent row cannot fit, and add nested nullable collection tests.