Skip to content

fix(spanner): ResultSet::merge panic for chunked nested ListValues#439

Merged
yoshidan merged 4 commits into
yoshidan:mainfrom
tuantran0910:fix/spanner-list-merge-chunking
May 15, 2026
Merged

fix(spanner): ResultSet::merge panic for chunked nested ListValues#439
yoshidan merged 4 commits into
yoshidan:mainfrom
tuantran0910:fix/spanner-list-merge-chunking

Conversation

@tuantran0910

Copy link
Copy Markdown
Contributor

Problem

When ExecuteStreamingSql streams a large nested ARRAY<STRUCT<...>> value split across multiple PartialResultSets with chunked_value=true, ResultSet::merge fails with:

Internal: previous_last kind mismatch: only StringValue and ListValue can be chunked

The root cause is in spanner/src/reader.rs: when merging two ListValues, the code unconditionally popped the last element of the previous chunk and recursed into ResultSet::merge with the first element of the current chunk. If the boundary fell between two complete elements whose kinds are not chunkable (NullValue, BoolValue, NumberValue), the recursive call hit the catch-all arm and returned an error.

Additionally, the code did not handle the case where either inner list was empty.

Fix

Mirrors the reference SDKs:

Changes:

  1. Mergeability gate: only recurse when both the tail of the previous list and the head of the current list are StringValue or ListValue. Otherwise push both elements as-is and concatenate the remaining values.
  2. Empty list short-circuits: if either inner list is empty, return the other unchanged without attempting any pop/push.

Impact

Any caller streaming ARRAY<STRUCT<...>> columns large enough to trigger chunking can hit this. Spanner change-stream readers (SELECT ChangeRecord FROM READ_<stream>(...)) are particularly exposed because the column type mixes strings, lists, bools, nulls, and numerics.

tuantran0910 and others added 2 commits May 8, 2026 13:46
)

`ResultSet::merge` previously recursed unconditionally on the
inner-last/inner-first pair when merging two `ListValues`, causing
`Internal: previous_last kind mismatch` when the chunk boundary fell
between complete elements whose kinds are not chunkable (Null, Bool,
Number). Mirrors the Go SDK (isMergeable/merge) and the official Rust
SDK (merge_values): only recurse when both sides are StringValue or
ListValue, otherwise push both elements and concatenate. Also adds
short-circuit handling for empty inner lists.

Closes yoshidan#438

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes ResultSet::merge behavior when Spanner streams chunked nested ListValues (e.g., ARRAY<STRUCT<...>>) across multiple PartialResultSets, avoiding erroneous kind-mismatch failures by only recursively merging truly chunk-split elements and correctly handling empty inner lists.

Changes:

  • Add a mergeability gate for ListValue merges: recurse only for (StringValue, StringValue) and (ListValue, ListValue) boundaries; otherwise concatenate.
  • Add short-circuit handling when either side’s inner list is empty.
  • Add regression tests covering non-mergeable boundaries and empty-list edge cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread spanner/src/reader.rs Outdated
Comment on lines +216 to +217
let first_value_of_current = first.values.remove(0);
let merged = match last.values.pop() {
Some(last_value_of_previous) => {
ResultSet::merge(last_value_of_previous, first_value_of_current)?
}
// last record can be empty
None => first_value_of_current,
};
last.values.push(merged);
let last_value_of_previous = last.values.pop().unwrap();
Comment thread spanner/src/reader.rs Outdated
// mismatch ...`. After the fix it must concatenate without merging.
#[test]
fn test_rs_merge_list_value_with_non_mergeable_tail() {
let null = || Value { kind: Some(Kind::NullValue(0)) };
@yoshidan yoshidan added the safe to test safe to test label May 9, 2026
tuantran0910 and others added 2 commits May 9, 2026 22:16
- Replace `first.values.remove(0)` + `extend(first.values)` with
  `into_iter()` to avoid the O(n) shift and reduce to a single pass.
- Use `prost_types::NullValue::NullValue.into()` instead of the magic
  literal `0` in the test helper, consistent with statement.rs:263.
- Remove now-unnecessary `mut` binding on `first`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes CI fmt.sh failures caused by formatting drift in the merge
function and test helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tuantran0910

Copy link
Copy Markdown
Contributor Author

Hi @yoshidan, I have just pushed the two new commits where I considered and applied the Copilot suggestions. I also format the file to avoid failed CI. Can you enable the CI again and review my PR? Thanks a lot.

@yoshidan yoshidan added safe to test safe to test and removed safe to test safe to test labels May 15, 2026
@yoshidan yoshidan requested a review from Copilot May 15, 2026 04:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

@yoshidan yoshidan merged commit fc55846 into yoshidan:main May 15, 2026
12 of 13 checks passed
@yoshidan

Copy link
Copy Markdown
Owner

LGTM!

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

Labels

safe to test safe to test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants