Skip to content

lance_fdw: add a foreign-data wrapper that scans Lance datasets - #2140

Open
MisterRaindrop wants to merge 2 commits into
apache:mainfrom
MisterRaindrop:feature/lance-fdw-1-scan
Open

MisterRaindrop wants to merge 2 commits into
apache:mainfrom
MisterRaindrop:feature/lance-fdw-1-scan

Conversation

@MisterRaindrop

Copy link
Copy Markdown
Contributor

Part of #2139 (1 of 4 for contrib/lance_fdw).

What does this PR do?

Adds contrib/lance_fdw, a read-only foreign-data wrapper that reads Lance
datasets, local or on S3, in place, with the scan spread over every segment
by fragment and pinned to one dataset version per statement. Every A-tier
Arrow type is converted; anything else is refused by name. Arrow batches,
which lance allocates outside palloc, are charged to the vmem tracker.

It is built only with configure --with-lance (or with PGXS and
LANCE_C_PREFIX) and links an installed liblance_c. No Rust code, crates or
cargo invocation enter this tree; contrib/lance_fdw/ci/build-lance-c.sh
builds the tested lance-c revision for CI. The only bundled third-party code
is Apache Arrow nanoarrow 0.7.0 (Apache-2.0).

CI: a new ic-lance-fdw entry builds lance-c (cached), builds the extension
with PGXS, generates the test datasets with pylance, serves them from a local
SeaweedFS S3 gateway, and runs the 15 regression suites.

Type of Change

  • New feature (non-breaking change)

Test Plan

  • Integration tests added/updated (15 pg_regress suites)
  • Passed make installcheck (contrib/lance_fdw, Rocky 8, 9 and 10 in CI)

Impact

Dependencies: an installed liblance_c when configured with --with-lance;
nothing otherwise.

Checklist

  • Followed contribution guide
  • Added/updated documentation (contrib/lance_fdw/README.md)
  • Reviewed code for security implications
  • This PR contains AI-assisted code generation
  • Requested review from cloudberry committers

@tuhaihe

tuhaihe commented Nov 4, 2026

Copy link
Copy Markdown
Member

Review by Claude Code, FYI.

pr-2140.md

Comment thread contrib/lance_fdw/ci/build-lance-c.sh Outdated
Comment on lines +50 to +59
case $(uname -m) in
x86_64) protoc_arch=x86_64 ;;
aarch64) protoc_arch=aarch_64 ;;
*) echo "build-lance-c: no protoc build for $(uname -m)" >&2; exit 1 ;;
esac
curl -fsSL --retry 5 --retry-connrefused -o "$work/protoc.zip" \
"https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-${protoc_arch}.zip"
unzip -q -o "$work/protoc.zip" -d "$work/protoc" bin/protoc 'include/*'
export PROTOC=$work/protoc/bin/protoc
"$PROTOC" --version

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should already ship the protoc in our development images.

# Rocky 8
[gpadmin@cdw ~]$ protoc --version
libprotoc 3.5.0

# Rocky 9
[gpadmin@cdw ~]$ protoc --version
libprotoc 3.14.0

# Rocky 10
[gpadmin@cdw ~]$ protoc --version
libprotoc 3.19.6

Comment on lines +67 to +71
git init -q "$work/lance-c"
git -C "$work/lance-c" fetch -q --depth 1 "$LANCE_C_REPO" "$LANCE_C_COMMIT"
git -C "$work/lance-c" checkout -q FETCH_HEAD
echo "build-lance-c: building lance-c $LANCE_C_COMMIT"
(cd "$work/lance-c" && cargo build --release --locked)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can also ship the liblance in the future. FYI.

Comment thread contrib/lance_fdw/src/lance_fdw.h Outdated
* leave the flags undefined and make nanoarrow.h declare ArrowArrayStream a
* second time, so the order below is not cosmetic.
*
* Copyright (c) 2026, Apache Cloudberry (incubating) contributors

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
* Copyright (c) 2026, Apache Cloudberry (incubating) contributors

There is no need. Let us remove this line.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in e09e2b0. It was the only line like it in lance_fdw.

Comment thread configure.ac
if test "$with_lance" = yes ; then
AC_CHECK_HEADER(lance/lance.h, [], [AC_MSG_ERROR([header file <lance/lance.h> is required for Lance support])])
fi

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If other dependencies, like libprotoc, are also required dependencies, we can add their checks too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think there are any. liblance_c is lance_fdw's only external dependency; the link check also passes -lpthread -ldl -lm, which it needs. protoc is only used to build lance-c itself: neither lance.h nor lance_fdw uses protobuf, and the module doesn't link it. So once a liblance_c is installed, configure has nothing else to look for. The protoc requirement is checked where it applies, in ci/build-lance-c.sh, which uses the image's protoc if it is 3.15 or newer and otherwise downloads a pinned, checksummed one.

@MisterRaindrop
MisterRaindrop force-pushed the feature/lance-fdw-1-scan branch from 440606a to 6283427 Compare November 5, 2026 03:42
@MisterRaindrop

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Everything below is folded into the two commits (now a34953b8d08 and 62834278016, rebased onto main). I reproduced each finding on the old code first, on a 3-primary demo cluster with the PR's fixtures, then checked the fix against the same reproduction. The suites now pass 16/16 under the planner, and 15/15 under ORCA with the new installcheck-orca target.

Must fix

1. base_uri did not confine non-superusers. Reproduced, and it was wider than the two cases you listed. A plain role with only USAGE on a file:///tmp/confined_empty server read the 15 rows of frag_3.lance in seven ways:

  • an absolute path or a file:// uri;
  • ../../…;
  • %2e%2e/%2E%2e/…, because the Rust uri parser decodes %2e before it resolves the path;
  • IMPORT FOREIGN SCHEMA … LIMIT TO ("../../…");
  • its own server with a local base_uri, once it had USAGE on the wrapper;
  • the host-credential fallback: with keys in gpadmin's ~/.aws/credentials, a user mapping without keys read an S3 table.

Creating a server with aws_endpoint 'http://169.254.169.254' also succeeded. Now, following file_fdw and postgres_fdw:

  • For a non-superuser, the uri must stay inside base_uri. That rules out an absolute uri, a .. component (also spelled %2e), a backslash, %2f and %5c. A member of pg_read_server_files may still give an absolute local path. IMPORT checks each LIMIT TO name before it opens the dataset, because the generated table's validator would only run after the open.
  • A local base_uri needs pg_read_server_files. aws_endpoint and allow_http need a superuser.
  • A non-superuser reading an object store needs aws_access_key_id and aws_secret_access_key in its user mapping, and only s3:// takes them, so any other scheme is refused as well. A superuser may set the new user mapping option credentials_required 'false' (postgres_fdw's password_required) for instance roles or public buckets. This is checked whenever a dataset is opened, against the user mapping in use (checkAsUser, as before).
  • The uri and server checks run when the option is set, as in file_fdw, so a superuser can still create a table on any path and grant SELECT on it.

A . component and dots inside a name (a..b) are still accepted. %252e%252e is decoded only once and comes back as not found inside the base. The new privileges suite covers all of this, and it fails on the old code.

2. mpp_execute 'any' broke one-version-per-statement. Reproduced with an 'any' table hash-joined to a 1M-row distributed table while pylance appended 1000 ids per version. Every correct answer is a multiple of 1000.

Code Torn counts QEs logging different versions in one statement
Old 1 of 12 queries (78340) 3 of 10 queries (e.g. 4 / 7 / 8)
New 0 of 33 queries 0 of 9 queries

The QD now pins the version under 'any' and publishes it with a width of 0, which the QEs read as "every fragment". This is the same plan slot the all-segments path uses, and cached plans republish. A QE under 'any' with no published units is an error, as it is under all segments. The README no longer says "one process reads everything". snapshot gains 'any'-joined-to-distributed cases at the latest and at a pinned version, and a prepared statement run twice. The race itself can't be staged in pg_regress, so the numbers above come from the repro script.

Suggestions

3. Threads. Worse than it looked: cpu_threads and io_threads did not bound the threads you counted. Thread names showed 11 tokio-rt-worker threads per backend on 10 cores whatever the two GUCs said. Those belong to lance-c's own runtime (Builder::new_multi_thread() with no worker count), which only TOKIO_WORKER_THREADS sizes. LANCE_CPU_THREADS sizes lance's separate lance-cpu pool, and LANCE_IO_THREADS is not a thread count at all; it is the number of I/O requests in flight.

So there is a new lance_fdw.runtime_threads (sets TOKIO_WORKER_THREADS). It and cpu_threads default to 2, and io_threads stays 0 (lance's per-store default; 4 would throttle S3). With 2/2 on 10 cores and 3 segments, a segment backend went from 13 threads to 5. A 5M-row scan went from 4.2 to 4.5 s locally and from 2.5 to 2.8 s over S3. The README's deploy section is rewritten around these numbers.

4. Signals. Synchronous signals (SEGV, BUS, FPE, ILL, TRAP, SYS) are no longer blocked. On latency, I don't think the mask is what defers a cancel. A backend's handlers only set a flag for the next CHECK_FOR_INTERRUPTS(), and none runs inside a lance-c call anyway, so a cancel waits for the call to return either way. That wait is the real problem, and measuring it was sobering: against an endpoint that never answers, SELECT … with statement_timeout = '1s' returned after 125 s, spent in object_store retries.

connect_timeout, timeout, client_max_retries and client_retry_timeout are now server options passed to object_store and lance. With 1s / 2s / 0 / 5 the same statement failed after 8 s. The two retry options are validated at DDL time, because lance silently keeps its defaults for an unparsable value.

I didn't take the other two options:

  • Warming the pool once wouldn't hold, because tokio keeps adding blocking threads later.
  • on_thread_start would need a lance-c change.

A real fix for latency would be to read plain scans through lance_scanner_poll_next with the latch, as #2142 already does for vector search. I'd rather do that as a follow-up than grow this PR further.

5. ORCA. ORCA does plan these scans itself (Optimizer: GPORCA). The new make installcheck-orca runs every suite except scan_core under optimizer=on, into test/feature/orca/, and ic-lance-fdw now runs both targets. All 15 pass. scan_core is left out only because one check forces a nested loop through enable_hashjoin, which ORCA does not read.

6. In-tree build. All three reproduced and fixed:

  • readelf showed RUNPATH [/usr/local/cloudberry-db/lib] only. The module now gets a run path to the --with-libraries directories, and is […/cloudberry-db/lib:/usr/local/lance-c/lib].
  • Without test/regress.mk, an in-tree installcheck (and installcheck-orca) prints why it is skipping and succeeds. PGXS, which is what CI uses, still fails, so a broken fixture step can't pass silently.
  • configure now probes lance_scanner_set_batch_size_bytes, the newest function the code calls (lance-c 6db08fd).

7. Downloads.

  • protoc and rustup-init (now downloaded directly instead of curl | sh; its sha256 matches the one rustup publishes) in build-lance-c.sh, and SeaweedFS in prepare-tests.sh, are pinned and checked with sha256 for both arches.
  • The pylance and friends in requirements.txt were already pinned exactly.

On the protoc inline comment, I tried it. lance's build scripts pass --experimental_allow_proto3_optional, but the substrait crate's doesn't, and with Rocky 9's 3.14 the build fails with substrait/type.proto: This file contains proto3 optional fields, but --experimental_allow_proto3_optional was not set. The script now uses the image's protoc when it is 3.15 or newer, which today means Rocky 10's 3.19, and downloads the pinned 24.3 otherwise.

On shipping liblance in the images: agreed. build-lance-c.sh already keeps a prefix that holds the library, so an image that installs it under /usr/local/lance-c would just skip the build.

8. LICENSE. nanoarrow is now listed in the top-level LICENSE, next to the other vendored Apache-2.0 code.

9. Stale references. Fixed test/README.md → test/fixtures/README.md and the two in the signal comment. While doing that I found more leftovers from the standalone repository: creds.sql, import.sql, test/fixtures/Makefile and conftest.py referred to test/run/run.sh and env.sh, which aren't in this tree. The creds comment now says the log grep is a manual check.

10. Nits.

  • The converters now also check each stream column's name against the one asked for.
  • lance_fdw_cache_stats() no longer starts lance-c in a backend that hasn't opened a dataset. It returns a row of NULLs, and install checks that no tokio or lance thread appears afterwards.

#2141–#2143 will be rebased onto this, together with their own review fixes.

lance_fdw reads Lance datasets, local or on S3, in place.  Servers, user
mappings, foreign tables and IMPORT FOREIGN SCHEMA work as for any
wrapper.  The scan runs on every segment: the coordinator lists the
dataset's fragments at a pinned version and gives each segment a
disjoint share, so one statement sees one snapshot.  Under mpp_execute
'any', where every process that runs the scan reads the whole dataset,
the coordinator pins the version for them as well.  Only the columns a
query refers to are read.  Every A-tier Arrow type is converted -
booleans, the integer and float families, strings, binaries, date32,
timestamps in all four units, decimal128, fixed_size_list<float>
embeddings, one-dimensional lists, structs as composite types and
map<utf8, ...> as jsonb - and anything else is refused by name rather
than guessed at.

Who may read what follows file_fdw and postgres_fdw.  For a role that
is not a superuser, a table's uri has to stay inside the server's
base_uri - no absolute uri, and no ".." component however it is
spelled - unless the role is a member of pg_read_server_files and the
path is local; IMPORT FOREIGN SCHEMA checks each name the same way.  A
local base_uri needs pg_read_server_files, and aws_endpoint and
allow_http need a superuser.  Reading an object store needs keys in the
role's user mapping, since lance would otherwise sign with whatever
credentials the host has; a superuser may waive that with
credentials_required 'false', as postgres_fdw's password_required.

Arrow batches are allocated by lance on the Rust side, out of sight of
gp_vmem_protect_limit and the resource group, so each batch is charged
to the vmem tracker once it is bound, and batch_size_bytes bounds what
one batch may hold.  lance-c's threads block the asynchronous signals a
backend is driven by, and every dataset, scanner and stream is closed
through the resource owner on the error path as well.  A host runs one
backend per segment, slice and session, so lance_fdw.runtime_threads
and lance_fdw.cpu_threads size lance-c's tokio runtime and lance's
compute pool to two threads each rather than one per core.  A cancel
takes effect when the current call into lance-c returns; the server
options connect_timeout, timeout, client_max_retries and
client_retry_timeout bound how long that can take against an object
store.

It links liblance_c, the C binding of Lance.  lance-c is written in
Rust and is not part of this tree: configure --with-lance finds an
installed one through --with-includes and --with-libraries, checks it
has the newest function lance_fdw calls, and gives the module a run
path to those directories; a PGXS build takes LANCE_C_PREFIX.  Only
contrib/lance_fdw links it.  ci/build-lance-c.sh builds the tested
revision from source, with every download pinned and checksummed.  The
Arrow batches are decoded with a vendored copy of Apache Arrow nanoarrow
0.7.0, its symbols renamed into a namespace of their own.

The regression suites read datasets generated with pylance from a local
directory and from an S3 bucket, named in test/regress.mk;
ci/prepare-tests.sh generates them, starts a SeaweedFS S3 gateway and
writes that file.  Without it, an in-tree installcheck says so and
passes, so that installcheck-world does not need the datasets.  The
suites create their own C.UTF-8 database, whatever the cluster's locale,
and installcheck-orca runs all but scan_core again under ORCA.
Add ic-lance-fdw.  Like datalake_fdw it is built through PGXS against the
installed server.  liblance_c is built from source first, which takes
about a quarter of an hour, so it is cached per image and per revision.
After the demo cluster is up, ci/prepare-tests.sh generates the
datasets and serves them from a local S3 gateway.  The suites run under
the planner, and all but scan_core again under ORCA.
@MisterRaindrop
MisterRaindrop force-pushed the feature/lance-fdw-1-scan branch from 6283427 to 7a1aacc Compare November 5, 2026 06:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants