Skip to content

[Bug](lance) REST catalog against Gravitino: namespace-vended credentials are dropped, and the root database is not listable #66772

Description

@FANNG1

Search before asking

  • I had searched in the issues and found no similar issues.

Version

branch-4.1 @ 0e53b31f586. The FE Lance catalog currently exists only on branch-4.1master 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?

  1. 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.
  2. 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:

  1. 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.
  2. 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?

  • Yes I am willing to submit a PR!

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions