Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 69 additions & 11 deletions internal/backend/kernel/batchiterator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,85 @@
package kernel

import (
"bytes"
"context"
"errors"
"io"
"testing"

"github.com/apache/arrow/go/v12/arrow"
dbsqlerr "github.com/databricks/databricks-sql-go/errors"
"github.com/apache/arrow/go/v12/arrow/array"
"github.com/apache/arrow/go/v12/arrow/ipc"
"github.com/apache/arrow/go/v12/arrow/memory"
dbsqlrows "github.com/databricks/databricks-sql-go/internal/rows"
dbrows "github.com/databricks/databricks-sql-go/rows"
)

// TestGetArrowIPCStreamsRejected pins the discoverable sentinel so callers can
// branch on it: the kernel C ABI exports Arrow C Data, not IPC bytes.
func TestGetArrowIPCStreamsRejected(t *testing.T) {
r := &kernelRows{}
it, err := r.GetArrowIPCStreams(context.Background())
if it != nil {
t.Errorf("GetArrowIPCStreams = %v iterator, want nil", it)
}
if !errors.Is(err, dbsqlerr.ErrNotSupportedByKernel) {
t.Errorf("GetArrowIPCStreams err = %v, want wrapping ErrNotSupportedByKernel", err)
func TestGetArrowIPCStreamsRequiresSchema(t *testing.T) {
it, err := (&kernelRows{}).GetArrowIPCStreams(context.Background())
if it != nil || err == nil {
t.Errorf("GetArrowIPCStreams without schema = (%v, %v), want (nil, error)", it, err)
}
}

func TestKernelIPCStreamIterator(t *testing.T) {
schema := arrow.NewSchema([]arrow.Field{{Name: "value", Type: arrow.PrimitiveTypes.Int64}}, nil)
builder := array.NewInt64Builder(memory.DefaultAllocator)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Low — The new iterator test builds its record with memory.DefaultAllocator, so it can't catch a leaked or double-released batch in kernelIPCStreamIterator.Next, and buffer ownership is the main risk in this adapter. Use memory.NewCheckedAllocator(memory.NewGoAllocator()) and call defer mem.AssertSize(t, 0) after it.Close()/reader.Release() so the test fails if record.Release() is ever dropped or moved.

It would also help to add a case where batches already holds a sticky err (e.g. &kernelBatchIterator{r: rows, err: someErr}). That case should check that Next returns the error once and that HasNext is false afterwards, which pins the forwarding contract for the ADBC consumer.

builder.AppendValues([]int64{1, 2}, nil)
values := builder.NewArray()
builder.Release()
record := array.NewRecord(schema, []arrow.Array{values}, 2)
values.Release()

rows := &kernelRows{schema: schema, eof: true}
batches := &kernelBatchIterator{r: rows, pending: record}
schemaBytes, err := arrowSchemaIPCBytes(schema)
if err != nil {
t.Fatalf("arrowSchemaIPCBytes: %v", err)
}
it := &kernelIPCStreamIterator{batches: batches, schemaBytes: schemaBytes}

if !it.HasNext() {
t.Fatal("HasNext before Next = false, want true")
}
stream, err := it.Next()
if err != nil {
t.Fatalf("Next: %v", err)
}
reader, err := ipc.NewReader(stream)
if err != nil {
t.Fatalf("ipc.NewReader: %v", err)
}
defer reader.Release()
if !reader.Next() {
t.Fatalf("IPC reader did not yield a record: %v", reader.Err())
}
got := reader.Record()
if got.NumRows() != 2 || got.NumCols() != 1 {
t.Errorf("decoded record shape = %dx%d, want 2x1", got.NumRows(), got.NumCols())
}
gotValues := got.Column(0).(*array.Int64)
if gotValues.Value(0) != 1 || gotValues.Value(1) != 2 {
t.Errorf("decoded values = [%d, %d], want [1, 2]", gotValues.Value(0), gotValues.Value(1))
}
if it.HasNext() {
t.Error("HasNext after the only batch = true, want false")
}
if next, err := it.Next(); next != nil || err != io.EOF {
t.Errorf("Next after drain = (%v, %v), want (nil, io.EOF)", next, err)
}

returnedSchema, err := it.SchemaBytes()
if err != nil {
t.Fatalf("SchemaBytes: %v", err)
}
if !bytes.Equal(returnedSchema, schemaBytes) {
t.Error("SchemaBytes did not return the encoded result schema")
}
returnedSchema[0] ^= 0xff
secondSchema, _ := it.SchemaBytes()
if bytes.Equal(returnedSchema, secondSchema) {
t.Error("SchemaBytes returned mutable internal storage")
}
}

Expand Down
78 changes: 70 additions & 8 deletions internal/backend/kernel/rows.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct ArrowArray;
import "C"

import (
"bytes"
"context"
"database/sql/driver"
"fmt"
Expand All @@ -23,7 +24,7 @@ import (

"github.com/apache/arrow/go/v12/arrow"
"github.com/apache/arrow/go/v12/arrow/cdata"
dbsqlerr "github.com/databricks/databricks-sql-go/errors"
"github.com/apache/arrow/go/v12/arrow/ipc"
"github.com/databricks/databricks-sql-go/internal/arrowscan"
context2 "github.com/databricks/databricks-sql-go/internal/compat/context"
dbsqlrows "github.com/databricks/databricks-sql-go/internal/rows"
Expand All @@ -40,8 +41,7 @@ var (
_ driver.RowsColumnTypeDatabaseTypeName = (*kernelRows)(nil)
_ driver.RowsColumnTypeNullable = (*kernelRows)(nil)
_ driver.RowsColumnTypeLength = (*kernelRows)(nil)
// Public batch API, matching the Thrift path (rows.rows). GetArrowBatches is
// real; GetArrowIPCStreams rejects (kernel C ABI exports C Data, not IPC bytes).
// Public batch and IPC APIs, matching the Thrift path (rows.rows).
_ dbrows.Rows = (*kernelRows)(nil)
)

Expand Down Expand Up @@ -312,12 +312,34 @@ func (r *kernelRows) GetArrowBatches(context.Context) (dbrows.ArrowBatchIterator
return &kernelBatchIterator{r: r}, nil
}

// GetArrowIPCStreams is rejected on the kernel path: the C ABI exports Arrow via
// the C Data Interface (next_batch), not IPC bytes. Use GetArrowBatches, or the
// Thrift backend for IPC streams.
// GetArrowIPCStreams serializes the kernel's post-processed batches to IPC. This
// provides a version-neutral boundary for consumers such as ADBC that use a
// different Arrow Go major version.
func (r *kernelRows) GetArrowIPCStreams(context.Context) (dbrows.ArrowIPCStreamIterator, error) {
return nil, fmt.Errorf("databricks: GetArrowIPCStreams is %w (kernel exports Arrow C Data, not IPC bytes); use GetArrowBatches",
dbsqlerr.ErrNotSupportedByKernel)
batches := &kernelBatchIterator{r: r}
schemaBytes, err := arrowSchemaIPCBytes(r.schema)
if err != nil {
return nil, err
}
return &kernelIPCStreamIterator{batches: batches, schemaBytes: schemaBytes}, nil
}

// arrowSchemaIPCBytes matches the Thrift path's SchemaBytes contract: an IPC
// stream containing the schema message without the end-of-stream marker.
func arrowSchemaIPCBytes(schema *arrow.Schema) ([]byte, error) {
if schema == nil {
return nil, fmt.Errorf("kernel: no schema available")
}
var output bytes.Buffer
writer := ipc.NewWriter(&output, ipc.WithSchema(schema))
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("kernel: encode IPC schema: %w", err)
}
encoded := output.Bytes()
if len(encoded) < 8 {
return nil, fmt.Errorf("kernel: encoded IPC schema is truncated")
}
return append([]byte(nil), encoded[:len(encoded)-8]...), nil
}

// kernelBatchIterator adapts the kernelRows pull loop to the public
Expand Down Expand Up @@ -402,3 +424,43 @@ func (it *kernelBatchIterator) Schema() (*arrow.Schema, error) {
}
return it.r.schema, nil
}

// kernelIPCStreamIterator adapts processed kernel batches to the existing IPC
// iterator API. Each returned reader owns an independent, complete IPC stream.
type kernelIPCStreamIterator struct {
batches *kernelBatchIterator
schemaBytes []byte
}

var _ dbrows.ArrowIPCStreamIterator = (*kernelIPCStreamIterator)(nil)

func (it *kernelIPCStreamIterator) Next() (io.Reader, error) {
record, err := it.batches.Next()
if err != nil {
return nil, err
}
defer record.Release()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Low — Each per-batch stream is written with record.Schema(), which comes from the per-batch C Data import in nextBatch (cdata.ImportCRecordBatch(&carr, &csch)). SchemaBytes() is built from r.schema, which comes from the separate kernel_result_stream_get_schema call. Nothing checks that the two match. If the kernel ever sends batch schemas that differ from the result schema (different field metadata, nullability, or a later type promotion), the bytes from SchemaBytes() won't describe the streams, which breaks what the interface promises ("schema bytes for the streams"). An ADBC consumer that builds its reader schema from SchemaBytes() would then fail or decode the data wrong.

Suggest checking record.Schema().Equal(it.batches.r.schema), either returning an error or logging when they differ, or picking one schema and using it everywhere. Note that ipc.Writer.Write already rejects a record whose schema differs from the writer's, so simply switching to r.schema would turn a silent mismatch into a hard error. That may be the right call, but it should be a deliberate choice.

var output bytes.Buffer
writer := ipc.NewWriter(&output, ipc.WithSchema(record.Schema()))
if err := writer.Write(record); err != nil {
_ = writer.Close()
return nil, fmt.Errorf("kernel: encode IPC batch: %w", err)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("kernel: finish IPC batch: %w", err)
}
return bytes.NewReader(output.Bytes()), nil
}

func (it *kernelIPCStreamIterator) HasNext() bool {
return it.batches.HasNext()
}

func (it *kernelIPCStreamIterator) Close() {
it.batches.Close()
}

func (it *kernelIPCStreamIterator) SchemaBytes() ([]byte, error) {
return append([]byte(nil), it.schemaBytes...), nil
}
Loading