Skip to content

impl(bigquery): use arrow format with jobs.query - #6472

Draft
alvarowolfx wants to merge 9 commits into
googleapis:mainfrom
alvarowolfx:impl-bq-arrow-jobs-query
Draft

impl(bigquery): use arrow format with jobs.query#6472
alvarowolfx wants to merge 9 commits into
googleapis:mainfrom
alvarowolfx:impl-bq-arrow-jobs-query

Conversation

@alvarowolfx

Copy link
Copy Markdown
Contributor

Trying out arrow support on jobs.query. This is gonna break on CI because support for it is behind an allowlist.

@product-auto-label product-auto-label Bot added the api: bigquery Issues related to the BigQuery API. label Aug 19, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds support for Arrow serialization in BigQuery query results, allowing the SDK to request and process Arrow record batches directly. Key changes include updating the query execution to request Arrow format with Zstd compression, implementing Arrow-to-Value conversion for various data types, and parsing Arrow schemas into BigQuery table schemas. Feedback on the changes suggests using unsigned_abs() to prevent overflow panics when formatting intervals, avoiding unnecessary clones when parsing range objects, and ensuring DataType::Interval is correctly mapped to "INTERVAL" in the schema conversion logic.

Comment thread src/bigquery/src/query/row.rs Outdated
Comment thread src/bigquery/src/datatypes.rs Outdated
Comment thread src/bigquery/src/query/schema.rs Outdated
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 47.10526% with 402 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.10%. Comparing base (a76f20a) to head (47b11be).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
src/bigquery/src/query/from_sql.rs 10.61% 202 Missing ⚠️
src/bigquery/src/query/arrow.rs 27.55% 92 Missing ⚠️
src/bigquery/src/query/row.rs 69.39% 56 Missing ⚠️
src/bigquery/src/query/schema.rs 67.14% 23 Missing ⚠️
src/bigquery/src/datatypes.rs 62.79% 16 Missing ⚠️
src/bigquery/src/query/query_handle.rs 61.76% 13 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6472      +/-   ##
==========================================
- Coverage   96.52%   96.10%   -0.42%     
==========================================
  Files         304      305       +1     
  Lines       87969    88690     +721     
==========================================
+ Hits        84908    85234     +326     
- Misses       3061     3456     +395     

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@alvarowolfx

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Arrow results format acceleration for BigQuery query jobs (under the google_cloud_unstable_bigquery_arrow configuration flag), implementing direct deserialization from Arrow cells to Rust types and adding a performance benchmark. The review feedback focuses on enhancing robustness and performance: it suggests handling Arrow IPC stream parsing errors gracefully in RowIterator instead of panicking, extending support to LargeList and FixedSizeList array variants, and optimizing decimal string conversions to avoid unnecessary allocations.

Comment on lines +60 to 64
record_batches: VecDeque<Arc<RecordBatch>>,
row_index: usize,
rows: VecDeque<wkt::Struct>,
max_results: Option<u32>,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To handle Arrow IPC stream parsing errors gracefully without panicking, we should add an init_error field to the RowIterator struct. This allows us to defer returning the error to the next() call, adhering to the repository style guide which discourages unwrap() and expect() in production code.

Suggested change
record_batches: VecDeque<Arc<RecordBatch>>,
row_index: usize,
rows: VecDeque<wkt::Struct>,
max_results: Option<u32>,
}
record_batches: VecDeque<Arc<RecordBatch>>,
row_index: usize,
rows: VecDeque<wkt::Struct>,
max_results: Option<u32>,
init_error: Option<RowError>,
}
References
  1. Panics: unwrap() and expect() should typically be avoided in production code and examples (use ? or handle errors). (link)

Comment on lines +68 to 97
let (rows, record_batches) = match q.cached_data {
CachedData::Rows(rows) => (rows, VecDeque::new()),
CachedData::Arrow {
serialized_record_batch,
serialized_schema,
} => {
let reader = StreamReader::try_new(
Cursor::new(serialized_schema).chain(Cursor::new(serialized_record_batch)),
None,
)
.expect("valid arrow IPC stream"); // TODO: convert error
let batches = reader
.map(|res| res.map(Arc::new))
.collect::<std::result::Result<VecDeque<_>, _>>()
.expect("valid record batches"); // TODO: convert error
(VecDeque::new(), batches)
}
};

Self {
job_service: q.job_service,
job_ref: q.job_ref,
schema: q.schema,
page_token: q.page_token,
rows: q.cached_rows,
record_batches,
row_index: 0,
rows,
max_results: q.max_results,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Instead of calling .expect() when parsing the Arrow IPC stream and record batches, we should capture any errors into the init_error field so they can be returned gracefully during iteration. This adheres to the repository style guide's rule against panics in production code.

        let mut init_error = None;
        let (rows, record_batches) = match q.cached_data {
            CachedData::Rows(rows) => (rows, VecDeque::new()),
            CachedData::Arrow { 
                serialized_record_batch,
                serialized_schema,
            } => match StreamReader::try_new(
                Cursor::new(serialized_schema).chain(Cursor::new(serialized_record_batch)),
                None,
            ) {
                Ok(reader) => {
                    match reader
                        .map(|res| res.map(Arc::new))
                        .collect::<std::result::Result<VecDeque<_>, _>>()
                    {
                        Ok(batches) => (VecDeque::new(), batches),
                        Err(e) => {
                            init_error = Some(RowError::InvalidRowFormat(format!(
                                "failed to read record batches: {e}"
                            )));
                            (VecDeque::new(), VecDeque::new())
                        }
                    }
                }
                Err(e) => {
                    init_error = Some(RowError::InvalidRowFormat(format!(
                        "failed to parse arrow schema: {e}"
                    )));
                    (VecDeque::new(), VecDeque::new())
                }
            },
        };

        Self {
            job_service: q.job_service,
            job_ref: q.job_ref,
            schema: q.schema,
            page_token: q.page_token,
            record_batches,
            row_index: 0,
            rows,
            max_results: q.max_results,
            init_error,
        }
    }
References
  1. Panics: unwrap() and expect() should typically be avoided in production code and examples (use ? or handle errors). (link)

Comment on lines 139 to +141
pub async fn next(&mut self) -> Option<Result<Row>> {
loop {
while let Some(batch) = self.record_batches.front() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Check and return the deferred init_error if any error occurred during the initialization of the Arrow IPC stream reader.

    pub async fn next(&mut self) -> Option<Result<Row>> {
        if let Some(err) = self.init_error.take() {
            return Some(Err(err));
        }
        loop {
            while let Some(batch) = self.record_batches.front() {

Comment on lines +171 to +174
DataType::List(_) => {
let v = Vec::<wkt::Value>::from_arrow(cell)?;
Ok(wkt::Value::Array(v))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure robust handling of all list array variants that might be returned by the Arrow integration, we should support LargeList and FixedSizeList as well.

Suggested change
DataType::List(_) => {
let v = Vec::<wkt::Value>::from_arrow(cell)?;
Ok(wkt::Value::Array(v))
}
DataType::List(_) | DataType::LargeList(_) | DataType::FixedSizeList(_, _) => {
let v = Vec::<wkt::Value>::from_arrow(cell)?;
Ok(wkt::Value::Array(v))
}

Comment on lines +339 to 356
fn from_arrow(cell: ArrowCell<'_>) -> Result<Self, ConvertError> {
if cell.is_null() {
return Err(ConvertError::NotNull);
}
if let Some(arr) = cell.downcast_ref::<arrow::array::ListArray>() {
let value_arr = arr.value(cell.row_idx);
let mut result = Vec::with_capacity(value_arr.len());
for i in 0..value_arr.len() {
result.push(T::from_arrow(ArrowCell::new(value_arr.as_ref(), i))?);
}
return Ok(result);
}
Err(ConvertError::TypeMismatch {
expected: "list array",
got: wkt::Value::String(cell.data_type_str()),
})
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Support downcasting to LargeListArray and FixedSizeListArray in Vec<T>::from_arrow to prevent unexpected runtime type mismatch errors with other list array variants.

    fn from_arrow(cell: ArrowCell<'_>) -> Result<Self, ConvertError> {
        if cell.is_null() {
            return Err(ConvertError::NotNull);
        }
        if let Some(arr) = cell.downcast_ref::<arrow::array::ListArray>() {
            let value_arr = arr.value(cell.row_idx);
            let mut result = Vec::with_capacity(value_arr.len());
            for i in 0..value_arr.len() {
                result.push(T::from_arrow(ArrowCell::new(value_arr.as_ref(), i))?);
            }
            return Ok(result);
        }
        if let Some(arr) = cell.downcast_ref::<arrow::array::LargeListArray>() {
            let value_arr = arr.value(cell.row_idx);
            let mut result = Vec::with_capacity(value_arr.len());
            for i in 0..value_arr.len() {
                result.push(T::from_arrow(ArrowCell::new(value_arr.as_ref(), i))?);
            }
            return Ok(result);
        }
        if let Some(arr) = cell.downcast_ref::<arrow::array::FixedSizeListArray>() {
            let value_arr = arr.value(cell.row_idx);
            let mut result = Vec::with_capacity(value_arr.len());
            for i in 0..value_arr.len() {
                result.push(T::from_arrow(ArrowCell::new(value_arr.as_ref(), i))?);
            }
            return Ok(result);
        }
        Err(ConvertError::TypeMismatch {
            expected: "list array",
            got: wkt::Value::String(cell.data_type_str()),
        })
    }

Comment on lines +617 to 649
fn from_arrow(cell: ArrowCell<'_>) -> Result<Self, ConvertError> {
if cell.is_null() {
return Err(ConvertError::NotNull);
}
let row_idx = cell.row_idx;
if let Some(arr) = cell.downcast_ref::<arrow::array::Decimal128Array>() {
let val = arr.value(row_idx);
let scale = arr.scale() as u32;
return rust_decimal::Decimal::try_from_i128_with_scale(val, scale)
.map_err(|e| ConvertError::Convert(Box::new(e)));
}
if let Some(arr) = cell.downcast_ref::<arrow::array::Decimal256Array>() {
let s = arr.value_as_string(row_idx);
let trimmed = if let Some((int_part, frac_part)) = s.split_once('.') {
let frac_trimmed = frac_part.trim_end_matches('0');
if frac_trimmed.is_empty() {
int_part.to_string()
} else {
format!("{int_part}.{frac_trimmed}")
}
} else {
s
};
return trimmed
.parse::<rust_decimal::Decimal>()
.map_err(|e| ConvertError::Convert(Box::new(e)));
}
Err(ConvertError::TypeMismatch {
expected: "decimal",
got: wkt::Value::String(cell.data_type_str()),
})
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of allocating new strings via int_part.to_string() and format!, we can modify the owned String returned by arr.value_as_string(row_idx) in-place using truncate. This completely avoids extra string allocations, adhering to the repository style guide's focus on eliminating unnecessary allocations. Since we are slicing the string directly, we should also document the safety guarantee with a comment.

    fn from_arrow(cell: ArrowCell<'_>) -> Result<Self, ConvertError> {
        if cell.is_null() {
            return Err(ConvertError::NotNull);
        }
        let row_idx = cell.row_idx;
        if let Some(arr) = cell.downcast_ref::<arrow::array::Decimal128Array>() {
            let val = arr.value(row_idx);
            let scale = arr.scale() as u32;
            return rust_decimal::Decimal::try_from_i128_with_scale(val, scale)
                .map_err(|e| ConvertError::Convert(Box::new(e)));
        }
        if let Some(arr) = cell.downcast_ref::<arrow::array::Decimal256Array>() {
            let mut s = arr.value_as_string(row_idx);
            if let Some(dot_idx) = s.find('.') {
                // SAFETY: dot_idx is found via s.find('.'), so dot_idx + 1 is a valid char boundary and within bounds for a decimal string.
                let trimmed = s[dot_idx + 1..].trim_end_matches('0');
                if trimmed.is_empty() {
                    s.truncate(dot_idx);
                } else {
                    s.truncate(dot_idx + 1 + trimmed.len());
                }
            }
            return s
                .parse::<rust_decimal::Decimal>()
                .map_err(|e| ConvertError::Convert(Box::new(e)));
        }
        Err(ConvertError::TypeMismatch {
            expected: "decimal",
            got: wkt::Value::String(cell.data_type_str()),
        })
    }
References
  1. Unnecessary clones: Scrutinize expensive uses of clone() (i.e. for Strings, not for Arcs). Look out for subtle copies, e.g. from chunks() or a to_() instead of into_(). Is it necessary to copy the data? Can we move the data instead? (link)
  2. When slicing a collection directly (e.g., buf[len..]) in Rust, if the operation is guaranteed to be safe due to specific invariants, document this safety guarantee with a comment rather than switching to safe but more verbose error-handling methods like .get().

Comment on lines +100 to +105
DataType::List(sub_field) | DataType::LargeList(sub_field) => {
let mut sub = arrow_field_to_table_field(sub_field);
sub.name = field.name().clone();
sub.mode = "REPEATED".to_string();
sub
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Support FixedSizeList in arrow_field_to_table_field to ensure complete consistency with other list array variants.

Suggested change
DataType::List(sub_field) | DataType::LargeList(sub_field) => {
let mut sub = arrow_field_to_table_field(sub_field);
sub.name = field.name().clone();
sub.mode = "REPEATED".to_string();
sub
}
DataType::List(sub_field) | DataType::LargeList(sub_field) | DataType::FixedSizeList(sub_field, _) => {
let mut sub = arrow_field_to_table_field(sub_field);
sub.name = field.name().clone();
sub.mode = "REPEATED".to_string();
sub
}

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

Labels

api: bigquery Issues related to the BigQuery API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant