Skip to content
Open
2 changes: 1 addition & 1 deletion KERNEL_REV
Original file line number Diff line number Diff line change
@@ -1 +1 @@
9b90406
53171cd
29 changes: 19 additions & 10 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,21 +211,30 @@ time zone) connection options. WithTimeout (a server query timeout the kernel C
can't set) and WithRetries used to disable retries (the kernel retries internally)
return a clear error at connect time rather than being silently ignored; token-
provider, external/static, and federated authenticators are likewise not supported
and rejected loudly. Bound query parameters are not yet supported and return a clear
error at execute time (they arrive per-query, not at connect). None of these is
silently ignored. (Metadata is issued as ordinary SQL — SHOW/DESCRIBE/
information_schema — and runs on this backend like any other query.)
and rejected loudly. None of these is silently ignored. Bound query parameters
(positional and named) are supported — bound over the C ABI to match the Thrift
path. (Metadata is issued as ordinary SQL — SHOW/DESCRIBE/information_schema — and
runs on this backend like any other query.)

WithMaxRows and retry tuning (WithRetries with a positive limit) are accepted but
not applied on the kernel path: the kernel manages result fetching and retries
internally, below the C ABI, with no user-facing knob.

Two further kernel-backend caveats. The INTERVAL types (year-month / day-time)
listed in the type table below are not yet handled by the kernel scanner and
return a scan error; use the default (Thrift) backend for interval columns. And
the kernel backend does not yet surface a per-statement server query id, so a
QueryIdCallback (see below) fires with "" and no EXECUTE_STATEMENT telemetry is
emitted for kernel queries.
Features that live above the backend seam are inherited unchanged: the
database/sql connection pool (each connection wraps one kernel session),
per-connection telemetry (CREATE_SESSION, EXECUTE_STATEMENT, and DELETE_SESSION
are recorded for kernel connections just like Thrift), and the telemetry
exporter's circuit breaker are all backend-agnostic. Result types are rendered to
match the Thrift backend byte-for-byte: scalars, DECIMAL (exact string),
TIMESTAMP / TIMESTAMP_NTZ (both shifted into the session time zone, as Thrift
does), INTERVAL year-month and day-time, nested Array/Map/Struct and VARIANT (as
JSON), and GEOMETRY (WKT). The per-statement server query id is surfaced on the
success path, so a QueryIdCallback (see below) fires with the real id and
EXECUTE_STATEMENT telemetry carries it.

One kernel-backend limitation remains on the read path: context cancellation is
honored at result-batch boundaries, not mid-fetch (an in-flight CloudFetch batch
runs to completion before the cancel takes effect).

# Programmatically Retrieving Connection and Query Id

Expand Down
82 changes: 76 additions & 6 deletions internal/arrowscan/arrowscan.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ import (
// matching the Thrift path — a float64 would lose precision beyond ~17 digits;
// see databricks-sql-go#274). Nested types (List/Map/Struct, and VARIANT which
// arrives nested) render to a JSON string byte-identical to the Thrift path;
// GEOMETRY arrives as a WKB/WKT string and is handled by the string arm. NULLs
// map to nil. A genuinely unhandled type (e.g. interval/duration) returns an
// error rather than a silently wrong value. loc renders DATE / TIMESTAMP in the
// session time zone (nil = UTC, arrow's ToTime default).
// GEOMETRY arrives as a WKB/WKT string and is handled by the string arm. INTERVAL
// day-time/year-month arrive as native arrow duration/month-interval and format to
// the same string the Thrift path receives pre-formatted from the server. NULLs
// map to nil. A genuinely unhandled type returns an error rather than a silently
// wrong value. loc renders DATE / TIMESTAMP in the session time zone (nil = UTC,
// arrow's ToTime default).
func ScanCell(col arrow.Array, row int, loc *time.Location) (driver.Value, error) {
return ScanCellCached(col, row, loc, nil)
}
Expand Down Expand Up @@ -148,16 +150,84 @@ func ScanCellCached(col arrow.Array, row int, loc *time.Location, keys *StructKe
case *array.Decimal128:
dt := col.DataType().(*arrow.Decimal128Type)
return decimalfmt.ExactString(c.Value(row), dt.Scale), nil
case *array.Duration:
// INTERVAL DAY TO SECOND arrives as an arrow duration. The kernel returns the
// native arrow value, so we format it Go-side to the same "D HH:MM:SS.nnnnnnnnn"
// string the Thrift path gets pre-formatted from the server (its native-interval
// config is off in prod, so it never scans a duration array — hence there is no
// shared renderer to reuse, and this stays kernel-side).
dt := col.DataType().(*arrow.DurationType)
return formatDayTimeInterval(int64(c.Value(row)), dt.Unit), nil
case *array.MonthInterval:
// INTERVAL YEAR TO MONTH arrives as a month count; Thrift's server string is
// "years-months".
return formatYearMonthInterval(int32(c.Value(row))), nil
case *array.List, *array.LargeList, *array.FixedSizeList, *array.Map, *array.Struct:
// Nested types (and VARIANT, which arrives as a nested value) render to a
// JSON string matching the Thrift path.
return renderJSONString(col, row, loc, keys)
default:
return nil, fmt.Errorf("scanning arrow type %s is not supported "+
"(intervals are not yet handled)", col.DataType())
return nil, fmt.Errorf("scanning arrow type %s is not supported", col.DataType())
}
}

// formatDayTimeInterval renders an arrow duration (in the given time unit) as the
// Thrift path's "D HH:MM:SS.nnnnnnnnn" — days, then zero-padded hours:minutes:seconds
// with 9 fractional digits, negated with a leading '-'.
func formatDayTimeInterval(v int64, unit arrow.TimeUnit) string {
neg := v < 0
if neg {
v = -v
}
// Split into whole seconds + a sub-second nanosecond remainder working in the
// native unit. We must NOT scale the full magnitude up to nanoseconds first:
// Spark day-time intervals run up to ~Long.MaxValue microseconds (~292 years),
// so v*1e3 (or *1e6/*1e9) would overflow int64 and silently produce a wrong
// (often negative) string. Deriving seconds by dividing keeps every
// intermediate in range; only the bounded sub-second remainder is scaled up.
var secs, frac int64
switch unit {
case arrow.Second:
secs = v
case arrow.Millisecond:
secs = v / 1e3
frac = (v % 1e3) * 1e6
case arrow.Microsecond:
secs = v / 1e6
frac = (v % 1e6) * 1e3
default: // Nanosecond
secs = v / 1e9
frac = v % 1e9
}
days := secs / 86400
rem := secs % 86400
h := rem / 3600
rem %= 3600
m := rem / 60
s := rem % 60
sign := ""
if neg {
sign = "-"
}
return fmt.Sprintf("%s%d %02d:%02d:%02d.%09d", sign, days, h, m, s, frac)
}

// formatYearMonthInterval renders a month count as the Thrift path's "years-months",
// negated with a leading '-'.
func formatYearMonthInterval(months int32) string {
neg := months < 0
if neg {
months = -months
}
y := months / 12
mo := months % 12
sign := ""
if neg {
sign = "-"
}
return fmt.Sprintf("%s%d-%d", sign, y, mo)
}

// inLocation renders t in loc, matching the Thrift path's .In(location); a nil
// loc leaves the value in UTC (arrow's ToTime default).
func inLocation(t time.Time, loc *time.Location) time.Time {
Expand Down
77 changes: 73 additions & 4 deletions internal/arrowscan/arrowscan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,19 +100,88 @@ func TestScanCellScalars(t *testing.T) {
})

t.Run("unsupported_type_errors", func(t *testing.T) {
// A duration (INTERVAL) is not yet handled: must error, not return a
// wrong value.
b := array.NewDurationBuilder(pool, &arrow.DurationType{Unit: arrow.Microsecond})
// An unhandled arrow type must error, not return a silently wrong value.
// Duration/MonthInterval are now handled (see TestScanCellInterval); use a
// type with no scan arm.
b := array.NewTime32Builder(pool, &arrow.Time32Type{Unit: arrow.Second})
defer b.Release()
b.Append(1000)
arr := b.NewArray()
defer arr.Release()
if _, err := ScanCell(arr, 0, nil); err == nil {
t.Error("scanning a Duration should return an unsupported-type error")
t.Error("scanning an unhandled arrow type should return an error")
}
})
}

// INTERVAL day-time (arrow duration) and year-month (arrow month-interval) arrive
// as native arrow values on the kernel path and must format to the exact string the
// Thrift path receives pre-formatted from the server: "D HH:MM:SS.nnnnnnnnn" and
// "years-months", with negatives signed. (These formatters were validated live
// kernel==Thrift in the PuPr POC; this is the regression guard.)
func TestScanCellInterval(t *testing.T) {
pool := memory.NewGoAllocator()

dayTime := []struct {
name string
unit arrow.TimeUnit
v int64
want string
}{
{"one_day_us", arrow.Microsecond, 86400 * 1_000_000, "1 00:00:00.000000000"},
{"day_to_sec_us", arrow.Microsecond, 90061_500000, "1 01:01:01.500000000"},
{"seconds_unit", arrow.Second, 3661, "0 01:01:01.000000000"},
{"negative_us", arrow.Microsecond, -90061_500000, "-1 01:01:01.500000000"},
// A large microsecond magnitude (~106.75M days, near Long.MaxValue μs) must
// NOT overflow int64 while scaling to nanoseconds — regression guard for the
// prior multiply-first bug that produced a wrong/negative string here.
{"large_us_no_overflow", arrow.Microsecond, 9223372036854775807, "106751991 04:00:54.775807000"},
}
for _, tc := range dayTime {
t.Run("daytime_"+tc.name, func(t *testing.T) {
b := array.NewDurationBuilder(pool, &arrow.DurationType{Unit: tc.unit})
defer b.Release()
b.Append(arrow.Duration(tc.v))
arr := b.NewArray()
defer arr.Release()
v, err := ScanCell(arr, 0, nil)
if err != nil {
t.Fatal(err)
}
if v.(string) != tc.want {
t.Errorf("got %q, want %q", v, tc.want)
}
})
}

yearMonth := []struct {
name string
months int32
want string
}{
{"two_years", 24, "2-0"},
{"year_and_month", 13, "1-1"},
{"months_only", 5, "0-5"},
{"negative", -13, "-1-1"},
}
for _, tc := range yearMonth {
t.Run("yearmonth_"+tc.name, func(t *testing.T) {
b := array.NewMonthIntervalBuilder(pool)
defer b.Release()
b.Append(arrow.MonthInterval(tc.months))
arr := b.NewArray()
defer arr.Release()
v, err := ScanCell(arr, 0, nil)
if err != nil {
t.Fatal(err)
}
if v.(string) != tc.want {
t.Errorf("got %q, want %q", v, tc.want)
}
})
}
}

// ScanCell renders DATE / TIMESTAMP in the requested location, matching the
// Thrift path's .In(location); a nil location leaves the value in UTC.
func TestScanCellTimestampLocation(t *testing.T) {
Expand Down
14 changes: 3 additions & 11 deletions internal/backend/kernel/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import "C"

import (
"context"
"errors"
"fmt"
"strings"
"time"
Expand Down Expand Up @@ -343,16 +342,9 @@ func (k *KernelBackend) SessionID() string { return k.sessionID }

// Execute runs a statement to a terminal state via the blocking execute path.
// Per the Backend contract it returns a non-nil Operation even on error so the
// caller can read StatementID / wrap the error / Close uniformly.
// caller can read StatementID / wrap the error / Close uniformly. Bound
// parameters (req.Params) are bound onto the statement in execute (see
// bindParams).
func (k *KernelBackend) Execute(ctx context.Context, req backend.ExecRequest) (backend.Operation, error) {
// Bound parameters are not yet wired for the kernel backend. Reject them with
// a clear error rather than silently shipping the query with unbound
// placeholders (which would behave differently than Thrift). Parameters arrive
// per-query, so this is an execute-time error, not a connect-time one. Return a
// non-nil Operation per the Backend contract.
if len(req.Params) > 0 {
return &kernelOp{}, errors.New("databricks: query parameters are not yet supported by the kernel backend; " +
"inline the values or use the default (Thrift) backend")
}
return k.execute(ctx, req)
}
15 changes: 11 additions & 4 deletions internal/backend/kernel/kernel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,19 @@ func TestEvictIfSessionFatal(t *testing.T) {
// Operation must be non-nil (Backend contract) and its Close must report
// closed=false, since no server statement was ever created (a phantom
// CLOSE_STATEMENT would otherwise be recorded for it).
func TestExecuteRejectsParams(t *testing.T) {
k := &KernelBackend{}
// When Execute fails before it acquires a statement handle (here: a nil session
// makes new_statement fail), it must still honor the Backend contract — a non-nil,
// handle-less Operation that Closes as a no-op (closed=false, no CLOSE_STATEMENT)
// and reports zero AffectedRows. (Parameter binding is exercised live in
// TestKernelE2EParams; a nil-session unit test can't reach the bind path.)
func TestExecuteHandleLessOpContract(t *testing.T) {
k := &KernelBackend{} // nil session → new_statement fails
op, err := k.Execute(context.Background(), backend.ExecRequest{
Query: "SELECT ?",
Params: []backend.Param{{Name: "x"}},
Params: []backend.Param{{Name: "x", Type: "STRING", Value: strPtr("v")}},
})
if err == nil {
t.Fatal("expected an error for bound parameters, got nil")
t.Fatal("expected an error from Execute on a nil-session backend, got nil")
}
if op == nil {
t.Fatal("Execute must return a non-nil Operation per the Backend contract")
Expand All @@ -99,6 +104,8 @@ func TestExecuteRejectsParams(t *testing.T) {
}
}

func strPtr(s string) *string { return &s }

// The cell/nested rendering (ScanCell and the JSON grammar) now lives in the
// untagged internal/arrowscan package, where its tests run in the default
// CGO_ENABLED=0 build; see arrowscan_test.go. The decimal formatter lives in
Expand Down
Loading