Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,4 @@ __debug_bin
/build/kernel-src/
/internal/backend/kernel/lib/
/internal/backend/kernel/include/
/internal/backend/kernel/lib_dyn/
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@ require (
)

require (
github.com/ebitengine/purego v0.10.2
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.28.0
golang.org/x/sys v0.45.0 // indirect
)

// PoC-only: point purego at a local checkout so this branch builds offline.
// The real PR drops this replace and lets the module proxy fetch v0.10.2.
replace github.com/ebitengine/purego => /tmp/purego-src
138 changes: 138 additions & 0 deletions internal/backend/kernel/DYNAMIC_LINK_RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Dynamic linking (.dylib/.so) for the kernel — release design + working PoC

**Decision context:** cgo is acceptable; the Arrow import path stays cgo +
arrow-go `cdata` (zero-copy C-Data, the ADBC driver-manager model). The *only*
open question is how the closed-source kernel binary is packaged, shipped, and
linked. This documents the **dynamic-linking** answer, with a PoC verified live
on pecotesting.

This is NOT the purego/CGO_ENABLED=0 path (that PoC — `dynamic_loader.go`,
`cdata_pure.go` — is moot now that cgo is accepted, and should be parked).

---

## What dynamic linking changes (and what it doesn't)

The kernel is compiled to a **shared library** (`.dylib`/`.so`/`.dll`) instead of
a static archive (`.a`). At build time the Go binary records only a *reference*
to the library; at run time the OS loads the library from a search path (rpath).

- **Unchanged:** the entire Arrow C-Data import path (`rows.go`, arrow-go
`cdata`), all backend/operation logic, the public API, `WithUseKernel`, and the
`CGO_ENABLED=0` pure-Go Thrift fallback for non-kernel users. Dynamic vs static
is invisible above the link layer.
- **Changed:** one per-OS cgo `LDFLAGS` line (link the shared lib + set an rpath),
and the packaging/release flow (ship the `.so` beside the binary).

## PoC results (darwin/arm64, live on pecotesting)

Behind a new build tag `databricks_kernel_dynlib` (added alongside
`databricks_kernel`), `cgo_dynlib_darwin.go` links the `.dylib` instead of the
`.a`. Verified:

- **Binary references the kernel externally**, not baked in:
`otool -L` → `@rpath/libdatabricks_sql_kernel.dylib`.
- **Size drops from ~61 MB (static .a) to ~12 MB** (kernel code is now external).
- **rpath baked in** so the loader finds the lib at run time
(`@loader_path/lib_dyn/darwin_arm64` + an absolute dev path).
- **20/20 `TestKernelE2E*` subtests pass on pecotesting** through the
runtime-loaded dylib (all scalar/decimal/temporal/nested types).
- **Genuinely external (negative proof):** moving the dylib away makes the binary
fail at load with `dyld: Library not loaded: @rpath/libdatabricks_sql_kernel.dylib`
and prints the exact rpath search order; restoring it works again.
- **Isolation:** the static `.a` path and the default pure-Go build both still
build unchanged (the dynlib tag guards `cgo_darwin.go` with
`!databricks_kernel_dynlib`).

## Real code changes needed to ship this

### Kernel repo (databricks-sql-kernel)
1. **Publish the shared library as a release artifact**, per-OS/arch, built with
`crate-type = ["cdylib", ...]` (already declared) and `tls-rustls`. Sign it.
2. **Set a relocatable install name / soname at build:**
- macOS: `install_name_tool -id @rpath/libdatabricks_sql_kernel.dylib` (the
cargo default is the absolute build path — NOT relocatable; the PoC had to
fix this). Better: set it at link time via
`RUSTFLAGS=-Clink-arg=-Wl,-install_name,@rpath/...`.
- Linux: build with `-Wl,-soname,libdatabricks_sql_kernel.so.<MAJOR>`.
3. **Add an ABI-version symbol** `uint32_t kernel_abi_version(void)` so the Go
side can detect a mismatched library at load (see Versioning). Important while
the kernel is pre-1.0 ("ABI may change freely").

### Go driver repo (databricks-sql-go)
1. **New dynamic-link cgo files** per OS (the PoC has darwin; add linux/windows):
- darwin: `-L<dir> -ldatabricks_sql_kernel -Wl,-rpath,@loader_path/...`
- linux: `-L<dir> -ldatabricks_sql_kernel -Wl,-rpath,$ORIGIN/...`
- windows: import lib + ship the `.dll` beside the `.exe` (no rpath concept;
DLL is found via the executable directory).
2. **`make kernel-lib` fetches the `.so`/`.dylib`** (not the `.a`) from the
published release, verifies its checksum, and places it where the rpath points.
3. **Add a load-time ABI check** calling `kernel_abi_version()` and failing with a
clear error on mismatch (turns a silent crash into an actionable message).
4. **Decide static-vs-dynamic exposure:** either replace the static path, or keep
both (static default + `databricks_kernel_dynlib` opt-in, as the PoC does).

## `.so` publishing — how it works

- Kernel CI builds the shared lib per platform, signs it, attaches it to the
**kernel's own release line** (design-doc option **D** — correct owner,
kernel-versioned, and the SAME artifact serves ODBC, which already dlopens a
shared lib).
- The Go driver pins a kernel version (`KERNEL_REV` / release tag). `make
kernel-lib` downloads the matching signed `.so`, verifies it, drops it next to
the build output.
- At run time the OS loads it via the rpath. For distribution, the `.so` ships
**beside the application binary** (rpath `@loader_path` / `$ORIGIN`), so each
deployment carries its own copy — no system-wide install, no cross-app
interference.

## Versioning (the runtime contract dynamic linking introduces)

Static linking freezes the version at build (mismatch = compile error). Dynamic
linking resolves it at run time, so a wrong/missing `.so` is a **runtime**
problem that must be guarded:

1. **soname major** (`libkernel.so.1`): bump the major on any ABI break; the OS
refuses to load a mismatched major (clean failure, not corruption).
2. **`kernel_abi_version()` check** on load: belt-and-suspenders, essential while
pre-1.0. gosnowflake does exactly this with its native core.
3. **Pin** the expected version in the driver source (as today).

**ODBC and Go can run different kernel versions** because they are separate
processes with isolated memory — each ships/loads its own `.so`. (Only a single
process loading *both* would need matching majors + symbol care; not a normal
deployment.)

## Recommendation (phased)

- **Phase 1 (opt-in, now):** keep the static `.a` path (versioning is a build-time
non-problem); ship via release-asset `make kernel-lib`.
- **Phase 2 (toward SEA-default + ODBC sharing):** dynamic `.so` published on the
kernel release line, signed, soname-versioned, with the `kernel_abi_version()`
load check. This keeps the closed-source blob a separate signable/patchable
artifact out of every customer binary, and shares one lib with ODBC.
- The `CGO_ENABLED=0` pure-Go Thrift fallback stays intact throughout, so
non-kernel users are never forced into cgo — no user CUJ change.

## PoC files (this branch)

- `cgo_dynlib_darwin.go` — dynamic-link LDFLAGS (tag: `databricks_kernel_dynlib`)
- `cgo_darwin.go` — guarded with `!databricks_kernel_dynlib` so static/dynamic
never both compile
- `lib_dyn/darwin_arm64/` (gitignored) — the `.dylib` with install_name fixed to
`@rpath/...`

## Build/run the PoC

```sh
# stage the dylib + fix its install name (a packaging step; kernel CI would do this)
cp <kernel>/target/release/libdatabricks_sql_kernel.dylib internal/backend/kernel/lib_dyn/darwin_arm64/
install_name_tool -id @rpath/libdatabricks_sql_kernel.dylib \
internal/backend/kernel/lib_dyn/darwin_arm64/libdatabricks_sql_kernel.dylib

# build + run e2e dynamically linked
export CGO_LDFLAGS_ALLOW='-Wl,-rpath,@loader_path.*|-Wl,-rpath,/.*'
DATABRICKS_PECOTESTING_HTTP_PATH2=/sql/1.0/warehouses/<id> \
CGO_ENABLED=1 go test -tags "databricks_kernel databricks_kernel_dynlib" \
-run TestKernelE2E ./... -v
```
137 changes: 137 additions & 0 deletions internal/backend/kernel/DYNAMIC_LOADER_POC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# PoC: dynamic loading of the kernel (pure-Go, CGO_ENABLED=0)

**Status:** working proof-of-concept — **control plane AND data plane**, verified
live on pecotesting, with a head-to-head latency benchmark showing no regression.
**Build tag:** `databricks_kernel_dynamic` (separate from the shipped
`cgo && databricks_kernel` path — the two never build together).

## Update: data plane now works too (pure Go)

The earlier limitation ("control plane only, because arrow-go's `cdata` importer
is cgo") is **resolved**. `cdata_pure.go` is a pure-Go port of arrow-go v12.0.1's
C-Data *import* path: it reads the flat `ArrowSchema`/`ArrowArray` structs via
`unsafe`, invokes each struct's `release` callback with `purego.SyscallN`, and
builds `arrow.Record`s zero-copy with `array.NewData` — no cgo. `dynRows`
(dynamic_rows.go) pulls batches through the dlopen'd `kernel_result_stream_*`
and scans them with the SAME `arrowscan` scanner the cgo path uses, so values are
identical to the cgo backend by construction.

Verified live on pecotesting, built `CGO_ENABLED=0` (TestDynamicLoaderDataPlane):

```
OK scalars row = [1 2 3.5 hello true 19.99 <nil>] (int/bigint/double/string/bool/decimal/null)
OK fetched 1000 rows in order across batches
OK nested row = [[1,2,3] {"k":1} {"a":1,"b":"x"}] (array/map/struct as JSON)
OK temporal/binary/float row = [2021-07-01 … , [26 191], 3.3, -0.01,
9999999999999999999999999999.99] (exact high-precision decimal)
OK empty result set drained cleanly
OK fetched 100000 rows (multi-batch, likely CloudFetch)
PROOF: pure-Go (CGO_ENABLED=0) fetched + scanned result rows end-to-end.
```

The high-precision decimal returning byte-exact (`…9999.99`, not a float
approximation) is the strongest evidence the buffer import is correct.

## Latency: no regression (head-to-head on pecotesting)

Identical 500k-row × 3-col query, same drain-all-rows loop, same arrowscan
scanner; the only difference is static-cgo+cgo-cdata vs dynamic-purego+pure-import
(`BenchmarkCgoLargeResult` vs `BenchmarkDynLargeResult`):

| Path | ns/op (500k rows) |
| --- | --- |
| cgo static | 7.91 s |
| purego dyn | 6.17 s / 6.61 s / 8.59 s (repeated) |

End-to-end time is dominated by warehouse execution + network, so the two are
statistically indistinguishable — purego is never meaningfully slower, and the
per-cell path has no cgo boundary crossing. **No latency regression.**

Both `.a` and `.dylib` were built from the SAME pinned kernel rev
(`KERNEL_REV`, tls-rustls) for a fair comparison.

## Test matrix (all green)

- Default pure-Go suite (`CGO_ENABLED=0`, no tags): `go test ./...` — pass.
- Dynamic-tagged (`CGO_ENABLED=0 -tags databricks_kernel_dynamic`): pass;
e2e/data-plane pass live on pecotesting.
- cgo-tagged (`CGO_ENABLED=1 -tags databricks_kernel`): unit pass; full
`TestKernelE2E*` suite passes live on pecotesting (0 failures) — confirms the
change does not regress the existing static path.

## Original PoC (control plane)

## What this proves

The shipped kernel backend static-links `libdatabricks_sql_kernel.a` through cgo.
That forces `CGO_ENABLED=1`, a C toolchain on every builder, and it breaks Go's
free cross-compilation — the three things the SEA/kernel release design flags as
blockers to ever making SEA the default backend.

This PoC loads the kernel **shared** library (`.dylib`/`.so`/`.dll`) at run time
with [`ebitengine/purego`](https://github.com/ebitengine/purego) and **no cgo**.
It is the model `gosnowflake` uses for its own closed-source native core.

Verified end-to-end against a live warehouse (pecotesting), built `CGO_ENABLED=0`:

```
OK dlopen: bound kernel C ABI from libdatabricks_sql_kernel.dylib (CGO_ENABLED=0)
OK config: http_path + PAT set
OK session_open: connected to warehouse over SEA via the kernel
OK execute: server queryId="01f19506-ee6a-1f18-813f-4e9156bd2a4e" numModifiedRows=-1
PROOF: pure-Go (CGO_ENABLED=0) drove the closed-source kernel control plane end-to-end.
```

Also verified:
- **No kernel source change.** The kernel's `Cargo.toml` already declares
`crate-type = [..., "cdylib", ...]`, so `cargo build` already emits the
`.dylib` this PoC loads. The C ABI functions are `#[no_mangle] extern "C"`, so
they are exported from the shared library as-is.
- **No user CUJ change.** `WithUseKernel` and the existing build tags are
untouched. The default pure-Go build is unchanged and does **not** pull in
purego (confirmed: `go list -deps ./...` shows no purego in the default build).

## Scope boundary (the honest part)

This PoC covers the **control plane** only: dlopen → config → session open →
execute → query-id / affected-rows → teardown. That is the entire happy path for
DML/DDL and any non-result statement.

It does **not** cover the **data plane** (Arrow result batches), for one concrete
reason: the cgo backend imports result batches via `arrow-go/v12`'s `cdata`
package (`ImportCRecordBatch`), and **`cdata` is itself a cgo package** — every
non-test file in it does `import "C"`. So a fully `CGO_ENABLED=0` result-fetch
path can't just reuse `cdata`. Closing the data plane needs a separate decision:

1. a purego-based Arrow C-Data importer (reimplement the small struct import
without cgo), or
2. move to `arrow-go` v18 and re-evaluate, or
3. accept a hybrid: purego control plane + a thin cgo shim only for C-Data import
(loses full `CGO_ENABLED=0`, keeps dynamic loading).

That decision — plus the run-time library-discovery story (rpath / env var) and
the glibc-vs-musl matrix — is the follow-up, and is why this is a PoC PR rather
than a backend replacement.

## Run it

```sh
CGO_ENABLED=0 go build -tags databricks_kernel_dynamic ./internal/backend/kernel/

DBX_KERNEL_DYLIB=/abs/path/to/libdatabricks_sql_kernel.dylib \
DBX_KERNEL_HOST=<warehouse-hostname> \
DBX_KERNEL_HTTPATH=/sql/1.0/warehouses/<id> \
DBX_KERNEL_TOKEN=<pat> \
CGO_ENABLED=0 go test -tags databricks_kernel_dynamic \
-run TestDynamicLoaderControlPlane ./internal/backend/kernel/ -v
```

## PoC-only shortcuts (not for merge as-is)

- `go.mod` has a `replace github.com/ebitengine/purego => /tmp/purego-src` so the
branch builds offline in a sandbox. A real PR drops the replace and lets the
module proxy fetch the pinned `v0.10.2`.
- The dylib path is passed via env var. Real code resolves it next to the
executable (rpath) or a documented env var.
- `cKernelError` is a hand-mirrored struct layout. A real PR adds
`unsafe.Sizeof`/`Offsetof` assertions (the cgo path has equivalent guards).
Loading
Loading