Skip to content

Vortex can't combine two files it has previous written, when one of the blocks in the rewritten file contains a sliced sparse array of UUIDs alongside a dense one. #10417

Open

Description

@umanwizard

What happened?

AI usage disclosure: this bug was discovered by Claude, which wrote the example repro below. However, all the prose in this issue was written by me after some effort to understand what is going on.

Consider this test case:

use std::process::ExitCode;
use std::sync::Arc;

use arrow::array::{FixedSizeBinaryArray, RecordBatch};
use arrow::datatypes::{DataType, Field, Schema};
use futures::StreamExt;
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
use vortex::VortexSessionDefault;
use vortex::array::arrays::ChunkedArray;
use vortex::array::{ArrayRef, IntoArray};
use vortex::arrow::ArrowSessionExt;
use vortex::buffer::ByteBuffer;
use vortex::error::VortexResult;
use vortex::file::{OpenOptionsSessionExt, WriteOptionsSessionExt};
use vortex::io::session::RuntimeSessionExt;
use vortex::session::VortexSession;

const DENSE_ROWS: usize = 10_000;
const DENSE_SEED: u64 = 1;
const SPARSE_ROWS: usize = 8_000;
const SPARSE_UUID_EVERY: usize = 20;
const SPARSE_SEED: u64 = 2;

fn uuid_batch(rows: usize, seed: u64, has_uuid: impl Fn(usize) -> bool) -> RecordBatch {
    let mut rng = SmallRng::seed_from_u64(seed);
    let uuids = FixedSizeBinaryArray::try_from_sparse_iter_with_size(
        (0..rows)
            .map(|row| has_uuid(row).then(|| rng.random::<[u8; 16]>()))
            .collect::<Vec<_>>()
            .into_iter(),
        16,
    )
    .unwrap();
    let schema = Schema::new(vec![
        Field::new("id", DataType::FixedSizeBinary(16), true)
            .with_extension_type(arrow_schema::extension::Uuid),
    ]);
    RecordBatch::try_new(Arc::new(schema), vec![Arc::new(uuids)]).unwrap()
}

async fn write(session: &VortexSession, chunks: Vec<ArrayRef>) -> VortexResult<Vec<u8>> {
    let dtype = chunks[0].dtype().clone();
    let array = ChunkedArray::try_new(chunks, dtype)?.into_array();
    let mut bytes = Vec::new();
    session
        .write_options()
        .write(&mut bytes, array.to_array_stream())
        .await?;
    Ok(bytes)
}

async fn read(session: &VortexSession, bytes: Vec<u8>) -> VortexResult<Vec<ArrayRef>> {
    let file = session
        .open_options()
        .open_buffer(ByteBuffer::from(bytes))?;
    let mut stream = file.scan()?.into_array_stream()?;
    let mut chunks = Vec::new();
    while let Some(chunk) = stream.next().await {
        chunks.push(chunk?);
    }
    Ok(chunks)
}

async fn write_vortex_file(session: &VortexSession, batch: RecordBatch) -> VortexResult<Vec<u8>> {
    let schema = batch.schema();
    let array = session.arrow().from_arrow_record_batch(batch, &schema)?;
    write(session, vec![array]).await
}

#[tokio::main]
async fn main() -> ExitCode {
    let session = VortexSession::default().with_tokio();

    let dense = uuid_batch(DENSE_ROWS, DENSE_SEED, |_| true);
    let sparse = uuid_batch(SPARSE_ROWS, SPARSE_SEED, |row| row % SPARSE_UUID_EVERY == 0);

    let mut chunks = Vec::new();
    for (name, batch) in [("dense", dense), ("sparse", sparse)] {
        let file = write_vortex_file(&session, batch).await.unwrap();
        println!("wrote {name} file ({} bytes)", file.len());
        chunks.extend(read(&session, file).await.unwrap());
    }

    println!(
        "rewriting {} chunks read back from both files into one file",
        chunks.len()
    );
    match write(&session, chunks).await {
        Ok(file) => {
            println!(
                "rewrite succeeded ({} bytes): bug not reproduced",
                file.len()
            );
            ExitCode::SUCCESS
        }
        Err(err) => {
            eprintln!("rewrite failed: {err}");
            ExitCode::FAILURE
        }
    }
}

It will exit with the following output (followed by a backtrace):

wrote dense file (162336 bytes)
wrote sparse file (25296 bytes)
rewriting 2 chunks read back from both files into one file
rewrite failed: Other error: Array vortex.slice is not registered for serialization

My understanding of the issue is:

  • The "dense" file's UUIDs are incompressible, so they are just stored as a primitive
  • The "sparse" file's UUIDs are mostly NULL, so they are stored as vortex.sparse
  • When we re-read the two files into a ChunkedArray and then try to write it, the write is split into three blocks of at most 8192 rows each.
  • Each block is then canonicalized before compression. Since feat(array): builders skip child canonicalization and append nested values in bulk [builders-child-stack] #9064, the fixed-size-list and extension builders keep appended child arrays as-is instead of decoding them, so the lazy slice survives canonicalization
  • The second of these blocks contains a slice of both the dense array as well as the sparse array. A slice of a primitive can get optimized into just a primitive array with a view into the original array, but the slice of the sparse array can't (because executing a slice of a sparse array requires actually reading the data), so it is left as a lazy vortex.splice; so the full block looks like this:
root: vortex.ext(vortex.uuid(fixed_size_list(u8)[16]?), len=8192)
  storage: vortex.chunked(fixed_size_list(u8)[16]?, len=8192)
    chunk_offsets: vortex.primitive(u64, len=3)
    chunks[0]: vortex.fixed_size_list(fixed_size_list(u8)[16]?, len=1808)
      elements: vortex.primitive(u8, len=28928)
    chunks[1]: vortex.fixed_size_list(fixed_size_list(u8)[16]?, len=6384)
      elements: vortex.slice(u8, len=102144)
        child: vortex.sparse(u8, len=128000)
          patch_indices: fastlanes.bitpacked(u32, len=6382)
          patch_values: vortex.primitive(u8, len=6382)
      validity: vortex.bool(bool, len=6384)
  • When we try to write that out, we get the Array vortex.slice is not registered for serialization error.

Steps to reproduce

  1. Run the mentioned code and observe that it exits with an error.

Environment

  • Vortex version: v0.88.0

Additional context

No response

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugA bug issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions