Skip to content
Merged
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
40 changes: 39 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ concurrency:
env:
# pg_client requires libpq (PostgreSQL) at build time and a running
# PostgreSQL instance at test time (provided by run_pgembed_fixture.py).
EXTENSION_LIST: adbc;azure;delta;duckdb;ducklake;fts;httpfs;iceberg;json;llm;neo4j;pg_client;postgres;sqlite;unity_catalog;vector;algo;gql
EXTENSION_LIST: adbc;azure;delta;duckdb;ducklake;fts;httpfs;iceberg;json;llm;neo4j;pg_client;postgres;sqlite;unity_catalog;vector;algo;gql;lance

jobs:
# ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -228,6 +228,44 @@ jobs:
test -e /usr/lib64/libarrow.so.2600
ldconfig -p | grep -q 'libarrow.so.2600'

# The lance extension builds a native reader (lance_ladybug_ffi) via
# Cargo. The manylinux container ships no Rust toolchain and no
# protoc (needed by prost-build through lance-core), so install both
# here before the CMake configure step.
- name: Install Rust toolchain and protoc (Linux)
if: runner.os == 'Linux'
run: |
set -e
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain stable
fi
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cargo/bin:$PATH"
cargo --version
# CMake maps matrix.arch to the Rust target triple; make sure the
# matching std target is installed (default toolchain already
# carries the native triple).
case "${{ matrix.arch }}" in
x86_64) rustup target add x86_64-unknown-linux-gnu ;;
aarch64|arm64) rustup target add aarch64-unknown-linux-gnu ;;
esac
if ! command -v protoc >/dev/null 2>&1; then
if ! dnf install -y protobuf-compiler; then
# Fallback: official protoc release (covers repos without the
# compiler package).
PROTOC_VERSION="29.3"
case "${{ matrix.arch }}" in
x86_64) PROTOC_ARCH="x86_64" ;;
aarch64|arm64) PROTOC_ARCH="aarch_64" ;;
esac
curl -LsSf -o /tmp/protoc.zip "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip"
unzip -o /tmp/protoc.zip -d /usr/local bin/protoc 'include/*'
rm -f /tmp/protoc.zip
fi
fi
protoc --version

- name: Shim libomp.so.5 -> installed libomp (Linux)
if: runner.os == 'Linux'
run: |
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ add_extension_if_enabled_and_skip_32bit("iceberg")
add_extension_if_enabled_and_skip_32bit("azure")
add_extension_if_enabled_and_skip_32bit("unity_catalog")
add_extension_if_enabled_and_skip_32bit("ducklake")
add_extension_if_enabled_and_skip_32bit("lance")
add_extension_if_enabled("json")
add_extension_if_enabled("fts")
add_extension_if_enabled("vector")
Expand Down
2 changes: 1 addition & 1 deletion extension_config.cmake
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
set(EXTENSION_LIST adbc azure delta duckdb ducklake fts httpfs iceberg json llm pg_client postgres sqlite unity_catalog vector neo4j algo gql)
set(EXTENSION_LIST adbc azure delta duckdb ducklake fts httpfs iceberg json lance llm pg_client postgres sqlite unity_catalog vector neo4j algo gql)

#set(EXTENSION_STATIC_LINK_LIST fts)
foreach(extension IN LISTS EXTENSION_STATIC_LINK_LIST)
Expand Down
183 changes: 183 additions & 0 deletions lance/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Set extension name here
set(TARGET_NAME lance)

set(EXTENSION_NAME ${TARGET_NAME}_extension)

# NOTE: no project() call here (unlike a standalone build): defining a new
# project would redefine PROJECT_SOURCE_DIR to this directory, and the
# shared build_extension_lib() helper locates the extension output at
# ${PROJECT_SOURCE_DIR}/extension/<name>/build. With project() the artifact
# would land at extension/lance/extension/lance/build instead of
# extension/lance/build, where the test runner's LOAD_DYNAMIC_EXTENSION
# expects it.
include_directories(src/include)

# Build and link the Rust staticlib (lance_ladybug_ffi), following the
# lance-duckdb CMake/Cargo integration: native Lance reads without DuckDB.
set(RUST_PLATFORM_TARGET "")

set(LANCE_PLATFORM "$ENV{LANCE_PLATFORM}")
if(LANCE_PLATFORM STREQUAL "linux_amd64_musl")
set(RUST_PLATFORM_TARGET "x86_64-unknown-linux-musl")
elseif(LANCE_PLATFORM STREQUAL "linux_arm64_musl")
set(RUST_PLATFORM_TARGET "aarch64-unknown-linux-musl")
elseif(LANCE_PLATFORM STREQUAL "windows_amd64_mingw")
set(RUST_PLATFORM_TARGET "x86_64-pc-windows-gnu")
endif()

if(RUST_PLATFORM_TARGET STREQUAL "")
if("${OS_NAME}" STREQUAL "linux")
if("${OS_ARCH}" STREQUAL "arm64")
set(RUST_PLATFORM_TARGET "aarch64-unknown-linux-gnu")
else()
set(RUST_PLATFORM_TARGET "x86_64-unknown-linux-gnu")
endif()
elseif("${OS_NAME}" STREQUAL "osx")
if("${OSX_BUILD_ARCH}" STREQUAL "arm64")
set(RUST_PLATFORM_TARGET "aarch64-apple-darwin")
elseif("${OSX_BUILD_ARCH}" STREQUAL "x86_64")
set(RUST_PLATFORM_TARGET "x86_64-apple-darwin")
elseif("${OS_ARCH}" STREQUAL "arm64")
set(RUST_PLATFORM_TARGET "aarch64-apple-darwin")
else()
set(RUST_PLATFORM_TARGET "x86_64-apple-darwin")
endif()
elseif("${OS_NAME}" STREQUAL "windows")
if("${OS_ARCH}" STREQUAL "arm64")
set(RUST_PLATFORM_TARGET "aarch64-pc-windows-msvc")
else()
set(RUST_PLATFORM_TARGET "x86_64-pc-windows-msvc")
endif()
endif()
endif()

if(RUST_PLATFORM_TARGET STREQUAL "")
if(APPLE)
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "arm64|aarch64")
set(RUST_PLATFORM_TARGET "aarch64-apple-darwin")
else()
set(RUST_PLATFORM_TARGET "x86_64-apple-darwin")
endif()
elseif(UNIX)
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "arm64|aarch64")
set(RUST_PLATFORM_TARGET "aarch64-unknown-linux-gnu")
else()
set(RUST_PLATFORM_TARGET "x86_64-unknown-linux-gnu")
endif()
elseif(WIN32)
set(RUST_PLATFORM_TARGET "x86_64-pc-windows-msvc")
endif()
endif()

if(WIN32)
if(RUST_PLATFORM_TARGET MATCHES "windows-gnu")
set(RUST_STATICLIB_NAME "liblance_ladybug_ffi.a")
else()
set(RUST_STATICLIB_NAME "lance_ladybug_ffi.lib")
endif()
else()
set(RUST_STATICLIB_NAME "liblance_ladybug_ffi.a")
endif()

set(RUST_TARGET_DIR "${CMAKE_BINARY_DIR}/cargo")
set(RUST_DEBUG_LIB
"${RUST_TARGET_DIR}/${RUST_PLATFORM_TARGET}/debug/${RUST_STATICLIB_NAME}")
set(RUST_RELEASE_LIB
"${RUST_TARGET_DIR}/${RUST_PLATFORM_TARGET}/release/${RUST_STATICLIB_NAME}")

# Cargo discovers Rust modules; CMake must notice changes to every module before
# deciding whether to invoke Cargo, including newly added FFI modules.
file(GLOB_RECURSE RUST_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/rust/src/*.rs")
set(RUST_FFI_DEPENDS ${CMAKE_CURRENT_LIST_DIR}/rust/Cargo.toml
${CMAKE_CURRENT_LIST_DIR}/rust/Cargo.lock ${RUST_SOURCES})

# Fail fast at configure time when cargo is unavailable instead of dying
# mid-build with a bare "no such file or directory" from the custom command.
find_program(CARGO_EXECUTABLE cargo)
if(NOT CARGO_EXECUTABLE)
message(FATAL_ERROR "cargo is required to build the lance extension (native Lance reader). Install the Rust toolchain, e.g. via rustup: https://rustup.rs")
endif()

set(RUST_CARGO_ENV CARGO_TARGET_DIR=${RUST_TARGET_DIR}
MACOSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET})
if(NOT "$ENV{PROTOC}" STREQUAL "")
list(APPEND RUST_CARGO_ENV "PROTOC=$ENV{PROTOC}")
endif()

# Platform libs required by Rust deps (e.g., TLS, timezone).
if(APPLE)
if(NOT DEFINED CMAKE_OSX_DEPLOYMENT_TARGET OR CMAKE_OSX_DEPLOYMENT_TARGET
STREQUAL "")
set(CMAKE_OSX_DEPLOYMENT_TARGET
13.3
CACHE STRING "Minimum OS X deployment version" FORCE)
endif()
set(PLATFORM_LIBS "-framework CoreFoundation" "-framework IOKit"
"-framework SystemConfiguration" "-framework Security")
elseif(UNIX)
set(PLATFORM_LIBS m c)
elseif(WIN32)
set(PLATFORM_LIBS
ws2_32
userenv
advapi32
crypt32
ntdll
bcrypt
ncrypt
secur32)
endif()

# Building both debug and release Rust artifacts is expensive and can trigger
# OOMs in CI. For single-config generators (Ninja/Makefiles), only build the
# active configuration's artifact.
if(CMAKE_CONFIGURATION_TYPES)
add_custom_command(
OUTPUT "${RUST_DEBUG_LIB}" "${RUST_RELEASE_LIB}"
COMMAND ${CMAKE_COMMAND} -E env ${RUST_CARGO_ENV} ${CARGO_EXECUTABLE} build --manifest-path=${CMAKE_CURRENT_LIST_DIR}/rust/Cargo.toml --target=${RUST_PLATFORM_TARGET}
COMMAND ${CMAKE_COMMAND} -E env ${RUST_CARGO_ENV} ${CARGO_EXECUTABLE} build --manifest-path=${CMAKE_CURRENT_LIST_DIR}/rust/Cargo.toml --release --target=${RUST_PLATFORM_TARGET}
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
DEPENDS ${RUST_FFI_DEPENDS}
VERBATIM)
add_custom_target(lance_ladybug_ffi_build DEPENDS "${RUST_DEBUG_LIB}" "${RUST_RELEASE_LIB}")
else()
if(NOT DEFINED CMAKE_BUILD_TYPE OR CMAKE_BUILD_TYPE STREQUAL "")
set(CMAKE_BUILD_TYPE Release)
endif()
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
add_custom_command(
OUTPUT "${RUST_DEBUG_LIB}"
COMMAND ${CMAKE_COMMAND} -E env ${RUST_CARGO_ENV} ${CARGO_EXECUTABLE} build --manifest-path=${CMAKE_CURRENT_LIST_DIR}/rust/Cargo.toml --target=${RUST_PLATFORM_TARGET}
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
DEPENDS ${RUST_FFI_DEPENDS}
VERBATIM)
add_custom_target(lance_ladybug_ffi_build DEPENDS "${RUST_DEBUG_LIB}")
else()
add_custom_command(
OUTPUT "${RUST_RELEASE_LIB}"
COMMAND ${CMAKE_COMMAND} -E env ${RUST_CARGO_ENV} ${CARGO_EXECUTABLE} build --manifest-path=${CMAKE_CURRENT_LIST_DIR}/rust/Cargo.toml --release --target=${RUST_PLATFORM_TARGET}
WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR}
DEPENDS ${RUST_FFI_DEPENDS}
VERBATIM)
add_custom_target(lance_ladybug_ffi_build DEPENDS "${RUST_RELEASE_LIB}")
endif()
endif()

include_directories(
${PROJECT_SOURCE_DIR}/src/include
${CMAKE_BINARY_DIR}/src/include
src/include
)

add_subdirectory(src/function)
add_subdirectory(src/storage)
add_subdirectory(src/main)
add_subdirectory(test)

build_extension_lib(${BUILD_STATIC_EXTENSION} "lance")

target_link_libraries(lbug_${EXTENSION_LIB_NAME}_extension
PRIVATE
debug "${RUST_DEBUG_LIB}" optimized "${RUST_RELEASE_LIB}"
${PLATFORM_LIBS})
add_dependencies(lbug_${EXTENSION_LIB_NAME}_extension lance_ladybug_ffi_build)
126 changes: 126 additions & 0 deletions lance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Lance Extension

Lance-backed read-only graph storage for Ladybug: node tables and CSR (or flat)
relationship tables whose data lives in [Lance](https://lancedb.github.io/lance/)
datasets instead of Ladybug's native storage engine.

It is the Lance counterpart of the **icebug-disk** format (`docs/icebug-disk.md`):
same table layout and Cypher semantics, but every backing file is a Lance
dataset instead of a Parquet file.

## Quick start

```cypher
LOAD EXTENSION 'extension/lance/build/liblance.lbug_extension';

CREATE NODE TABLE user(id INT32 PRIMARY KEY, name STRING, age INT64)
WITH (storage = 'graph/', format = 'lance');
CREATE NODE TABLE city(id INT32 PRIMARY KEY, name STRING, population INT64)
WITH (storage = 'graph/', format = 'lance');
CREATE REL TABLE follows(FROM user TO user, since INT32)
WITH (storage = 'graph/', format = 'lance');
CREATE REL TABLE livesin(FROM user TO city)
WITH (storage = 'graph/', format = 'lance');

MATCH (a:user {id: 100})-[:follows]->(b)-[:livesin]->(c:city)
RETURN b.name, c.name ORDER BY b.name;
```

Expected files (see `docs/lance.md` for the full layout):

```
graph/nodes_user.lance graph/nodes_city.lance
graph/indices_follows.lance graph/indptr_follows.lance
graph/indices_livesin.lance graph/indptr_livesin.lance
```

A relationship table whose `storage` points directly at a `<name>.lance`
dataset uses the FLAT layout (`src, dst, <props...>` rows) instead of CSR.

Ad-hoc reads without creating tables:

```cypher
CALL LANCE_SCAN('graph/nodes_user.lance') RETURN *;
LOAD FROM 'graph/nodes_user.lance' (file_format = 'lance') RETURN *;
COPY person FROM 'graph/nodes_user.lance' (file_format = 'lance');
```

## How it works

**Lance I/O is native.** The extension links the Lance Rust crates directly
(same versions as [lance-duckdb](https://github.com/lance-duckdb) `main`:
Lance 13, Arrow 58) through a small C FFI (`rust/`: open/count/schema/scan
to Arrow batches), with no DuckDB layer in between. Reads are dataset scans
with projection + limit/offset pushed into Lance, so morsel and CSR range
scans stay efficient; batches cross the FFI via the Arrow C data interface
and decode with core's `ArrowConverter` (the same machinery backing
`arrow://` tables). Building needs a Rust toolchain, protoc, and crates.io
access (see `rust/`); the CMake build drives cargo automatically.

**Storage layout mirrors icebug-disk** (`IceDiskNodeTable`/`IceDiskRelTable`
are the templates for `LanceNodeTable`/`LanceRelTable`): positional node
offsets, `indices` (edge targets + properties, CSR-sorted by source) plus a
single-column `indptr`, indptr cached in memory and range-limiting each
bound-node batch from O(edges) to O(matching edges) on forward scans — the
same optimization icebug-disk applies to Parquet row groups. Like icebug-disk
v1, tables are **immutable** (inserts/updates/deletes are rejected) and
edge properties match **by name** (column order is insignificant; missing
properties read as NULL).

**Plugging into core.** Storage table types cannot live behind a plain table
function (graph traversal needs CSR extend, degree summaries, PK lookup), so
core provides a small extension hook surface (see
`src/include/storage/table/external_table_hooks.h`, LadybugDB/ladybug#1445):

- `ColumnarTableHookRegistry` (factories + scan hooks, registered under the
`"lance"` external format name and keyed by table);
- the scan operators (`ScanNodeTable`, `ScanRelTable`, `ScanMultiRelTable`,
`OnDiskGraph` for recursive traversal) create scan states and count morsels
through the hooks, falling back to the legacy paths otherwise;
- `LanceRelTableScanState` extends `ColumnarRelTableScanState` so one shared
state can scan lance, icebug-disk, Arrow, and native tables in multi-rel
scans (`supportsMixedColumnarScan`).

No core query path changed behavior: all pre-existing icebug-disk/Arrow/native
tests pass unmodified.

## Relationship to lance-csr

[lance-csr](https://github.com/hyperdimensionalcomputingai/lance-csr) is a
Cypher engine over Lance datasets with an **in-memory** CSR index built from
edge tables at query time. This extension takes the complementary,
storage-first approach: the CSR (`indices` + `indptr`) is **materialized as
Lance datasets on disk** (the icebug-disk layout translated to Lance), so
graphs are portable files that any Lance reader can open, while traversal
still resolves hops through CSR range lookups instead of joins. Reads go
through the extension's own native reader (`rust/` + `LanceReader`); a
Rust-free build is not a goal.

## Regenerating test fixtures

```bash
./extension/lance/scripts/gen_test_data.sh
```

regenerates `extension/lance/test/lance_data/` with the extension's own Rust
generator (`rust/examples/gen_fixtures.rs`, same Lance version as the reader).
Content mirrors `dataset/ice-disk-test` row-for-row; every dataset is stamped
with `lance_version = v1` schema metadata (plus a `v999` dataset for the
version-mismatch error test).

## Limitations (v1)

- Read-only tables; no `COPY ... TO ... (FORMAT lance)` write path yet (write
Lance datasets with pylance/DuckDB, or vote for the native writer).
- Declared property types must match the dataset exactly (no silent coercion;
endpoint offset columns must be `INT64`). Whatever core's Arrow converter
rejects (float16, time32/64, maps, ...) fails fast at `CREATE`; timezone-aware
timestamps and durations are rejected in the FFI for the same reason.
- Object-store datasets skip validation at `CREATE` (credentials may be
configured later) and fail at first scan, mirroring icebug-disk.
- Semi-mask morsel skipping is not implemented (masks still filter); GDS
vertex scans (`scanVertices`) over Lance tables are not supported.
- Scan batches are sized for the default engine vector capacity (2048 rows);
custom `LBUG_VECTOR_CAPACITY_LOG2` builds below 11 are not supported.
- `LOAD` the extension before creating Lance tables; the "load the extension"
error otherwise points at the fix.
1 change: 1 addition & 0 deletions lance/rust/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/target
Loading
Loading