Search before asking
Version
branch-4.1 @ 0e53b31f586. The FE Lance catalog currently exists only on branch-4.1 — master has no fe/fe-core/src/main/java/org/apache/doris/datasource/lance/. LanceStorageOptions came in with e3289c1 (#65730); the REST properties were reshaped by 8375559 (#66581).
Server side: Apache Gravitino 1.3.0, native lance-rest auxiliary service (gravitino.auxService.names = lance-rest, gravitino.lance-rest.httpPort = 9101, namespace-backend = gravitino), base path /lance.
Namespace library versions differ across the wire but are not the cause of either problem below: Gravitino bundles lance-namespace-core 0.4.5, while Doris FE pulls lance-namespace-apache-client 0.7.7 transitively from org.lance:lance-core:9.1.0-beta.3.
What's Wrong?
I ran the Lance REST catalog against a real Lance Namespace server (Gravitino) rather than the Python stub in docker/thirdparties/docker-compose/iceberg/scripts/lance_rest_server.py, and hit two independent problems.
The protocol itself lines up — endpoint paths, the $ delimiter, pagination, managed_versioning, and the table_uri/location fallback all match, and once both problems are worked around every read path passes (see Anything else?). These are integration defects, not a protocol mismatch.
Problem 1 — namespace-vended storage credentials are silently dropped (blocking)
Credential vending is the main reason to prefer a REST catalog over a filesystem one, and Doris explicitly asks for it — LanceExternalCatalog.describeTable sends .vendCredentials(true) for REST catalogs.
Gravitino answers with unprefixed object-store option names (it stores them as lance.storage.<key> and strips that prefix in LancePropertiesUtils.resolveLanceStorageOptions):
"storage_options": {
"access_key_id": "minioadmin",
"secret_access_key": "minioadmin",
"endpoint": "http://127.0.0.1:9000",
"region": "us-east-1",
"allow_http": "true"
}
But LanceStorageOptions.forBackend only ever looks up five aws_-prefixed names:
// fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceStorageOptions.java
S3_KEYS.put("AWS_ACCESS_KEY", "aws_access_key_id");
S3_KEYS.put("AWS_SECRET_KEY", "aws_secret_access_key");
S3_KEYS.put("AWS_TOKEN", "aws_session_token");
S3_KEYS.put("AWS_ENDPOINT", "aws_endpoint");
S3_KEYS.put("AWS_REGION", "aws_region");
...
S3_KEYS.forEach((dorisKey, lanceKey) -> putIfNotEmpty(result, dorisKey,
lanceStorageOptions.get(lanceKey)));
Nothing matches, so every vended credential is discarded before it reaches the BE.
The resulting failure is split across FE and BE, which makes it hard to read. The FE merges the vended map in raw and hands it to the Lance Java SDK, whose object-store layer accepts the unprefixed spelling — so all metadata operations succeed. Only the BE, which goes through forBackend, ends up with nothing:
DESC lance_novend.doris_probe.rest_probe; -- OK, 5 columns
SELECT count(*) FROM lance_novend.doris_probe.rest_probe;
ERROR 1105 (HY000): errCode = 2, detailMessage = (127.0.0.1)[INTERNAL_ERROR]
open Lance dataset failed: LanceError(IO): Generic N/A error: ...
Failed to get AWS credentials: CredentialsNotLoaded(CredentialsNotLoaded {
source: Some("no providers in chain provided credentials") }),
.../lance-io/src/object_store/providers/aws.rs:401:21
Positive control isolating the key spelling. I created a second Gravitino catalog whose properties spell the same values lance.storage.aws_access_key_id, lance.storage.aws_secret_access_key, lance.storage.aws_endpoint, lance.storage.aws_region, pointed it at the same physical dataset, and read it through the same Doris configuration (still no static credentials). The scan returned all 1024 rows. Key spelling was the only variable.
Both spellings are valid for the Rust object_store config that ultimately consumes them, and the FE's success is direct evidence that the unprefixed form works — only Doris's own BE-side converter insists on one of the two.
Problem 2 — the parent namespace is published as a database that cannot be listed
LanceExternalCatalog.listDatabaseNames unconditionally publishes the configured root database:
// The configured root database represents the empty relative Lance namespace.
LinkedHashSet<String> databases = new LinkedHashSet<>();
databases.add(rootDatabase);
so default always appears in SHOW DATABASES. It maps to the parent namespace itself, which is one level deep — but Gravitino's ListTables requires an identifier of exactly two levels (catalog + schema), so listing it fails:
SHOW TABLES FROM lance_gvt.`default`;
ERROR 1105 (HY000): errCode = 2, detailMessage = Invalid input: Expected 2-level namespace but got: 1
The same collision makes "test_connection" = "true" unusable, because the probe lists tables directly under the parent:
testNamespace.listTables(new ListTablesRequest().id(parent).limit(1));
testNamespace.listNamespaces(new ListNamespacesRequest().id(parent).limit(1));
With test_connection = true, CREATE CATALOG fails outright with the same message. Leaving it at its default of false is the workaround.
This is not a data-correctness problem — Doris loads databases lazily, so default is just an entry that must be routed around — but it does mean SHOW DATABASES advertises something unusable, and it silently rules out the connectivity check.
What You Expected?
- Vended
storage_options should reach the BE regardless of which accepted alias the namespace server uses for a key. A server that vends access_key_id should work exactly like one that vends aws_access_key_id — as it already does on the FE side.
- A database returned by
SHOW DATABASES should be listable, and test_connection = true should not require the parent namespace itself to be table-listable.
How to Reproduce?
1. Gravitino 1.3.0 with the Lance REST service, and a lakehouse-generic catalog carrying unprefixed storage options:
# conf/gravitino.conf
gravitino.auxService.names = lance-rest
gravitino.lance-rest.classpath = lance-rest-server/libs
gravitino.lance-rest.httpPort = 9101
gravitino.lance-rest.namespace-backend = gravitino
gravitino.lance-rest.gravitino-uri = http://127.0.0.1:8090
gravitino.lance-rest.gravitino-metalake = test
curl -X POST http://127.0.0.1:8090/api/metalakes/test/catalogs \
-H 'Accept: application/vnd.gravitino.v1+json' -H 'Content-Type: application/json' \
-d '{"name":"lance_catalog","type":"relational","provider":"lakehouse-generic","properties":{
"location":"s3://contacts/raw/lance",
"lance.storage.access_key_id":"minioadmin",
"lance.storage.secret_access_key":"minioadmin",
"lance.storage.endpoint":"http://127.0.0.1:9000",
"lance.storage.region":"us-east-1",
"lance.storage.allow_http":"true"}}'
curl -X POST 'http://127.0.0.1:9101/lance/v1/namespace/lance_catalog%24doris_probe/create?delimiter=%24' \
-H 'Content-Type: application/json' \
-d '{"id":["lance_catalog","doris_probe"],"mode":"CREATE","properties":{}}'
2. Write a dataset and register it (pylance 7.0.0):
import lance, numpy as np, pyarrow as pa
URI = "s3://contacts/raw/lance/doris_probe/rest_probe/"
SO = {"access_key_id": "minioadmin", "secret_access_key": "minioadmin",
"endpoint": "http://127.0.0.1:9000", "region": "us-east-1",
"allow_http": "true", "virtual_hosted_style_request": "false"}
rid = np.arange(1, 1025, dtype=np.int64)
emb = (rid[:, None] - 1).astype(np.float32) + np.arange(16, dtype=np.float32)[None, :]
t = pa.table({
"row_id": pa.array(rid, type=pa.int64()),
"int32_value": pa.array((rid % 100).astype(np.int32), type=pa.int32()),
"embedding": pa.FixedSizeListArray.from_arrays(
pa.array(emb.reshape(-1), type=pa.float32()), 16)})
lance.write_dataset(t, URI, mode="overwrite", storage_options=SO, max_rows_per_file=512)
lance.dataset(URI, storage_options=SO).create_index(
"embedding", index_type="IVF_FLAT", metric="l2", num_partitions=4)
curl -X POST 'http://127.0.0.1:9101/lance/v1/table/lance_catalog%24doris_probe%24rest_probe/register?delimiter=%24' \
-H 'Content-Type: application/json' \
-d '{"id":["lance_catalog","doris_probe","rest_probe"],
"location":"s3://contacts/raw/lance/doris_probe/rest_probe/"}'
3. Problem 1 — a catalog with no static credentials, relying entirely on vending:
CREATE CATALOG lance_novend PROPERTIES (
"type" = "lance",
"lance.catalog.type" = "rest",
"lance.rest.uri" = "http://127.0.0.1:9101/lance",
"lance.namespace.parent" = "lance_catalog"
);
DESC lance_novend.doris_probe.rest_probe; -- OK
SELECT count(*) FROM lance_novend.doris_probe.rest_probe; -- CredentialsNotLoaded
4. Problem 2 — against the same catalog:
SHOW DATABASES FROM lance_novend; -- lists `default`
SHOW TABLES FROM lance_novend.`default`; -- Expected 2-level namespace but got: 1
and adding "test_connection" = "true" to the properties above makes CREATE CATALOG itself fail.
Anything Else?
Everything else works. With both problems worked around — static s3.access_key/s3.secret_key, and test_connection left at its default — the REST catalog reads Gravitino-managed Lance tables correctly. Verified against a deterministic fixture (1024 rows in 2 fragments, 16-dim float32 embedding where row n is [n-1, n, …, n+14], so the exact squared L2 distance between rows n and r is 16·(n−r)², plus an IVF_FLAT index over 4 partitions), cross-checked row-for-row against pylance:
| Layer |
Result |
CREATE CATALOG, database/table discovery |
ok |
Type mapping (int64→bigint, string→text, int32→int, double→double, fixed_size_list<float,16>→array<float>) |
ok |
Full scan — 1024 rows, sum(row_id) = 524800 |
ok |
Substrait predicate pushdown (lancePushdownPredicate), non-convertible expression left as BE residual |
ok |
| Projection pruning |
ok |
IVF_FLAT vector search — full probe reproduces flat search row-for-row on the exact 16·d² ladder |
ok |
| Global Top-K across fragments (row 1024 lives in the second fragment) |
ok |
FOR VERSION AS OF — after appending 10 rows: latest 1034, v2 1024, v1 1024 |
ok |
Working configuration, for reference:
CREATE CATALOG lance_gvt PROPERTIES (
"type" = "lance",
"lance.catalog.type" = "rest",
"lance.rest.uri" = "http://127.0.0.1:9101/lance", -- Gravitino's /lance base path
"lance.namespace.parent" = "lance_catalog", -- Doris database == Gravitino schema
"s3.endpoint" = "http://127.0.0.1:9000",
"s3.access_key" = "minioadmin",
"s3.secret_key" = "minioadmin",
"s3.region" = "us-east-1",
"use_path_style" = "true"
);
Two notes for whoever picks this up:
lance.rest.uri has to carry the server's base path (/lance here). LanceRestMetastoreProperties.validateRestUri already permits a path component, and the client appends /v1/… to it, so this works — worth a line in the docs, since the in-repo stub is mounted at the root and never exercises it.
- Test coverage has a hole shaped exactly like Problem 1.
test_lance_rest_catalog.groovy does cover credential vending, but the stub it talks to vends aws_-prefixed keys (lance_rest_server.py returns aws_access_key_id, aws_secret_access_key, aws_region), so the unprefixed alias path — which is what a real server emits — is never exercised. Teaching the stub to vend the unprefixed spelling in one case would pin this.
Are you willing to submit PR?
Code of Conduct
Search before asking
Version
branch-4.1@0e53b31f586. The FE Lance catalog currently exists only onbranch-4.1—masterhas nofe/fe-core/src/main/java/org/apache/doris/datasource/lance/.LanceStorageOptionscame in with e3289c1 (#65730); the REST properties were reshaped by 8375559 (#66581).Server side: Apache Gravitino 1.3.0, native
lance-restauxiliary service (gravitino.auxService.names = lance-rest,gravitino.lance-rest.httpPort = 9101,namespace-backend = gravitino), base path/lance.Namespace library versions differ across the wire but are not the cause of either problem below: Gravitino bundles
lance-namespace-core0.4.5, while Doris FE pullslance-namespace-apache-client0.7.7 transitively fromorg.lance:lance-core:9.1.0-beta.3.What's Wrong?
I ran the Lance REST catalog against a real Lance Namespace server (Gravitino) rather than the Python stub in
docker/thirdparties/docker-compose/iceberg/scripts/lance_rest_server.py, and hit two independent problems.The protocol itself lines up — endpoint paths, the
$delimiter, pagination,managed_versioning, and thetable_uri/locationfallback all match, and once both problems are worked around every read path passes (see Anything else?). These are integration defects, not a protocol mismatch.Problem 1 — namespace-vended storage credentials are silently dropped (blocking)
Credential vending is the main reason to prefer a REST catalog over a filesystem one, and Doris explicitly asks for it —
LanceExternalCatalog.describeTablesends.vendCredentials(true)for REST catalogs.Gravitino answers with unprefixed object-store option names (it stores them as
lance.storage.<key>and strips that prefix inLancePropertiesUtils.resolveLanceStorageOptions):But
LanceStorageOptions.forBackendonly ever looks up fiveaws_-prefixed names:Nothing matches, so every vended credential is discarded before it reaches the BE.
The resulting failure is split across FE and BE, which makes it hard to read. The FE merges the vended map in raw and hands it to the Lance Java SDK, whose object-store layer accepts the unprefixed spelling — so all metadata operations succeed. Only the BE, which goes through
forBackend, ends up with nothing:Positive control isolating the key spelling. I created a second Gravitino catalog whose properties spell the same values
lance.storage.aws_access_key_id,lance.storage.aws_secret_access_key,lance.storage.aws_endpoint,lance.storage.aws_region, pointed it at the same physical dataset, and read it through the same Doris configuration (still no static credentials). The scan returned all 1024 rows. Key spelling was the only variable.Both spellings are valid for the Rust
object_storeconfig that ultimately consumes them, and the FE's success is direct evidence that the unprefixed form works — only Doris's own BE-side converter insists on one of the two.Problem 2 — the parent namespace is published as a database that cannot be listed
LanceExternalCatalog.listDatabaseNamesunconditionally publishes the configured root database:so
defaultalways appears inSHOW DATABASES. It maps to the parent namespace itself, which is one level deep — but Gravitino'sListTablesrequires an identifier of exactly two levels (catalog + schema), so listing it fails:The same collision makes
"test_connection" = "true"unusable, because the probe lists tables directly under the parent:With
test_connection = true,CREATE CATALOGfails outright with the same message. Leaving it at its default offalseis the workaround.This is not a data-correctness problem — Doris loads databases lazily, so
defaultis just an entry that must be routed around — but it does meanSHOW DATABASESadvertises something unusable, and it silently rules out the connectivity check.What You Expected?
storage_optionsshould reach the BE regardless of which accepted alias the namespace server uses for a key. A server that vendsaccess_key_idshould work exactly like one that vendsaws_access_key_id— as it already does on the FE side.SHOW DATABASESshould be listable, andtest_connection = trueshould not require the parent namespace itself to be table-listable.How to Reproduce?
1. Gravitino 1.3.0 with the Lance REST service, and a
lakehouse-genericcatalog carrying unprefixed storage options:2. Write a dataset and register it (pylance 7.0.0):
3. Problem 1 — a catalog with no static credentials, relying entirely on vending:
CREATE CATALOG lance_novend PROPERTIES ( "type" = "lance", "lance.catalog.type" = "rest", "lance.rest.uri" = "http://127.0.0.1:9101/lance", "lance.namespace.parent" = "lance_catalog" ); DESC lance_novend.doris_probe.rest_probe; -- OK SELECT count(*) FROM lance_novend.doris_probe.rest_probe; -- CredentialsNotLoaded4. Problem 2 — against the same catalog:
and adding
"test_connection" = "true"to the properties above makesCREATE CATALOGitself fail.Anything Else?
Everything else works. With both problems worked around — static
s3.access_key/s3.secret_key, andtest_connectionleft at its default — the REST catalog reads Gravitino-managed Lance tables correctly. Verified against a deterministic fixture (1024 rows in 2 fragments, 16-dim float32embeddingwhere row n is[n-1, n, …, n+14], so the exact squared L2 distance between rows n and r is16·(n−r)², plus an IVF_FLAT index over 4 partitions), cross-checked row-for-row against pylance:CREATE CATALOG, database/table discoveryfixed_size_list<float,16>→array<float>)sum(row_id) = 524800lancePushdownPredicate), non-convertible expression left as BE residual16·d²ladderFOR VERSION AS OF— after appending 10 rows: latest 1034, v2 1024, v1 1024Working configuration, for reference:
CREATE CATALOG lance_gvt PROPERTIES ( "type" = "lance", "lance.catalog.type" = "rest", "lance.rest.uri" = "http://127.0.0.1:9101/lance", -- Gravitino's /lance base path "lance.namespace.parent" = "lance_catalog", -- Doris database == Gravitino schema "s3.endpoint" = "http://127.0.0.1:9000", "s3.access_key" = "minioadmin", "s3.secret_key" = "minioadmin", "s3.region" = "us-east-1", "use_path_style" = "true" );Two notes for whoever picks this up:
lance.rest.urihas to carry the server's base path (/lancehere).LanceRestMetastoreProperties.validateRestUrialready permits a path component, and the client appends/v1/…to it, so this works — worth a line in the docs, since the in-repo stub is mounted at the root and never exercises it.test_lance_rest_catalog.groovydoes cover credential vending, but the stub it talks to vendsaws_-prefixed keys (lance_rest_server.pyreturnsaws_access_key_id,aws_secret_access_key,aws_region), so the unprefixed alias path — which is what a real server emits — is never exercised. Teaching the stub to vend the unprefixed spelling in one case would pin this.Are you willing to submit PR?
Code of Conduct