This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 54423854c7e [fix](lance) Pass namespace-vended storage options through
to the BE (#66805)
54423854c7e is described below
commit 54423854c7e90e2c77f7fe6c5a4aa49935ab6d93
Author: FANNG <[email protected]>
AuthorDate: Tue Aug 25 09:48:20 2026 +0800
[fix](lance) Pass namespace-vended storage options through to the BE
(#66805)
### What problem does this PR solve?
Fixes #66772 (problem 1).
A Lance REST catalog discarded the `storage_options` a namespace vended
for a table, so any scan relying on credential vending failed:
```
open Lance dataset failed: LanceError(IO): ...
Failed to get AWS credentials: CredentialsNotLoaded("no providers in chain
provided credentials")
```
The options were re-encoded twice on the way to the BE, through one
five-entry S3-only table used in both directions:
```
namespace vends access_key_id
-> FE LanceStorageOptions.forBackend aws_access_key_id ->
AWS_ACCESS_KEY
-> TFileScanRangeParams.properties
-> BE kStorageKeys AWS_ACCESS_KEY ->
aws_access_key_id
-> lance-c
```
That table was written to *emit* one canonical spelling, which is
correct outbound. Reading it backwards turns it into a parser that
accepts only the spelling it happens to emit, so everything else was
dropped — credentials under any other accepted alias, and every non-S3
provider's keys, which left the catalog unable to use credential vending
outside S3 at all.
The failure was also split across the two halves, which made it hard to
read: the FE passes the vended map to the Lance Java SDK untouched, so
`SHOW TABLES` and `DESC` succeeded and only the scan failed.
The Lance Namespace specification describes `storage_options` as
configuration *"passed directly to Lance"*, so the protocol defines no
key vocabulary of its own and a client cannot assume one.
### Release note
Fixed a Lance REST catalog failing to scan with `CredentialsNotLoaded`
when it relied on credentials vended by the namespace rather than static
`s3.access_key` / `s3.secret_key`.
### What is changed and how it works?
Stop re-encoding server-supplied options, and resolve the ones Doris
does contribute per storage provider.
**Transport.** `TFileScanRangeParams.lance_storage_options` (new, id 38)
carries the options in Lance's own vocabulary. Set at ScanNode level —
the same pattern as `paimon_options` — so credentials are not serialized
once per fragment split. `properties` is deliberately not reused: it is
copied wholesale into `io::FileSystemProperties` for the shared
filesystem layer. The BE hands that map to lance-c as it arrives, so
`kStorageKeys` is gone. The `s3()` TVF path populates the same field,
including the schema-fetch RPC.
**The option vocabulary belongs to a provider, not to this layer.**
Lance routes a dataset to a provider by URL scheme, and the accepted
spellings overlap without agreeing:
```rust
// object_store-0.13.2 src/azure/builder.rs:448 — note: no aws_endpoint
"azure_storage_endpoint" | "azure_endpoint" | "endpoint" =>
Ok(Self::Endpoint)
// lance-io/src/object_store/providers/oss.rs:71-73, :87
("endpoint", &["oss_endpoint"]), ("access_key_id", &["oss_access_key_id"]),
...
"OSS endpoint is required. Please provide 'oss_endpoint' ..."
```
Rewriting a vended `endpoint` onto `aws_endpoint` therefore loses the
Azure endpoint outright and makes OSS fail before it reads anything.
`LanceStorageProvider` picks by scheme the way lance-io's own registry
does, and each implementation owns both directions of its vocabulary:
- **`LanceS3StorageProvider`** — the `s3` and `s3+ddb` schemes lance-io
registers for its AWS provider. It converts the catalog's typed storage
properties into the canonical `aws_*` spellings, and resolves vended
spellings onto those same keys. `token` is decidable here too: it is a
bearer token to object_store's Azure parser, and only the provider
settles which it is.
- **`LancePassThroughStorageProvider`** — everything else. Both halves
inert, so such a dataset is reachable only through what its namespace
vends, named as the namespace named it.
**Why the two sides have to be resolved against each other.** A catalog
with static `s3.access_key` whose namespace also vends credentials
produces both spellings of one option:
```
aws_access_key_id = <catalog> access_key_id = <vended>
```
Both parse to `AmazonS3ConfigKey::AccessKeyId`, and `as_s3_options()`
collects them into a `HashMap`, so the winner is whichever the iteration
yields last — decided separately for the access key and the secret, and
separately in the FE and in the BE. Two of the four combinations pair
one side's key with the other's secret, which surfaces as
`SignatureDoesNotMatch` rather than as a missing credential. Putting
both sides in the provider's vocabulary first means one key, one value,
and the namespace wins because it just described the table.
The alias set covers only the options Doris itself contributes — those
are the only ones a vended option can collide with.
`aws_endpoint_url_s3` is deliberately left alone: object_store parses it
into a config key of its own and prefers it over the generic endpoint,
so a vended one already wins, and folding it in would replace a defined
precedence with map order.
**Doris's own configuration is read through its typed properties**, not
through the flattened `AWS_*` backend map, which is only a re-encoding
of them (`AbstractS3CompatibleProperties.doBuildS3Configuration`). That
map also mixes in backend-only knobs Lance has no use for, and flattens
every configured storage into one namespace where two S3-compatible ones
overwrite each other. The typed list keeps them apart; the one to read
is chosen the way Iceberg chooses it
(`AbstractIcebergProperties.toFileIOProperties`) — prefer a concrete
provider over the generic `S3Properties`. Selection filters by type
rather than index, because `StorageProperties.createAll` prepends a
default HDFS entry when no `fs.xx.support` flag is set.
**What is rejected rather than resolved.** A NUL truncates a key at
lance-c's `CStr::from_ptr` while the FE keeps reading the whole thing,
so the FE rejects it and the BE returns `InvalidArgument`; dropping it
silently would only move the divergence to an FE that predates the
check. A namespace vending two spellings of one option with different
values is rejected too — picking one would be the coin toss above.
### Not in scope
**Credentials are static for the life of a scan.** `lance-c` opens
datasets with a static option set, so `expires_at_millis` reaches the BE
but is never acted on, and credentials that expire mid-scan are not
re-vended. Renewal needs a channel of its own; this field is not one,
and the thrift comment says so.
**One map, two Lances.** The FE and the BE agree on the options, not on
the library that reads them: both pin `object_store` 0.13.2, but the
Java SDK (9.1.0-beta.3) carries OpenDAL 0.57 and `lance-c` 0.1.6 carries
0.56. An option only the newer one knows — `skip_signature`, say — takes
effect on the FE and is ignored on the BE. This only reaches the OpenDAL
backend, which a namespace has to opt into by vending
`use_opendal=true`, and closing it means aligning the two upstream pins.
**Non-S3 providers are unblocked, not enabled.** Their vended options
now survive instead of being dropped or rewritten, but Doris contributes
no static configuration for them — it models OSS and COS perfectly well,
so writing that translation is possible, but it would mean committing to
a vocabulary per provider with no backend here to test it against, which
is how the rewriting bug above got in. A filesystem catalog's
`warehouse` is still restricted to `file` and `s3`.
**A catalog and a namespace that disagree.** If both name the same
option in spellings the provider treats as distinct config keys, both
survive and Lance decides. Only the spellings Doris itself emits are
resolved.
### Rolling upgrade
An FE upgraded ahead of the BEs no longer puts vended credentials into
`TFileScanRangeParams.properties`, and an older BE reads only that. A
REST catalog with no static credentials therefore cannot be scanned
until the BEs are upgraded too. The Lance catalog is not in a release
yet, so this only affects development clusters.
### How was this patch tested?
**Unit tests.** `LanceStorageOptionsTest` is new — `LanceStorageOptions`
had no test of its own, and the deleted `forBackend` had zero call sites
in any test, which is why the original bug went unnoticed. Its fixtures
are built with `StorageProperties.createAll` from user-facing
properties, so they exercise Doris's real parsing and have to satisfy
its rules. It covers the canonical mapping and anonymous access, vended
credentials superseding the catalog's, the full endpoint alias class,
`token` resolving differently on `s3://` than on `az://`, six non-S3
schemes arriving untouched, S3 credentials not leaking onto another
provider, selection skipping the non-S3 entry Doris prepends, unknown
options passing through, conflicting spellings and embedded NULs being
rejected. `LanceThriftContractTest` gains round-trip coverage of the new
field.
**Regression.** The docker stub only ever vended `aws_`-prefixed keys,
so the alias path had no coverage anywhere. It now serves a second
table, `all_types_unprefixed`, backed by the same dataset but vending
the unprefixed spelling, and `test_lance_rest_catalog` scans it with no
static credentials configured. Both tables scan correctly on the current
revision.
**End to end**, against Apache Gravitino 1.3.0's `lance-rest` service,
which vends the unprefixed spelling. This run validated the original
transport fix, on the first revision of this PR; the docker suite above
is what has been re-run since. Catalog with no `s3.access_key` /
`s3.secret_key`:
```sql
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"
);
SELECT count(*), min(row_id), max(row_id), sum(row_id) FROM
lance_novend.doris_probe.rest_probe;
-- 1034 1 1034 535095
```
against a 1034-row, 2-fragment dataset with an IVF_FLAT index. Predicate
pushdown and vector search both return correct results on the same
catalog, and a catalog with static credentials is unaffected. Before
this change the same statement failed with `CredentialsNotLoaded`.
https://claude.ai/code/session_01M3mYXBKShBonG6Lg3br4Ld
---
be/src/format_v2/table/lance_reader.cpp | 70 +++--
be/src/format_v2/table/lance_reader.h | 5 +-
be/test/format_v2/table/lance_reader_test.cpp | 25 ++
.../docker-compose/iceberg/iceberg.yaml.tpl | 5 +-
.../iceberg/scripts/lance_rest_server.py | 55 +++-
.../datasource/lance/LanceExternalCatalog.java | 61 ++--
.../datasource/lance/LanceMetadataLoader.java | 35 +--
.../lance/LancePassThroughStorageProvider.java | 59 ++++
.../datasource/lance/LanceS3StorageProvider.java | 193 +++++++++++++
.../datasource/lance/LanceStorageOptions.java | 111 +++++---
.../datasource/lance/LanceStorageProvider.java | 84 ++++++
.../doris/datasource/lance/LanceTableMetadata.java | 19 +-
.../datasource/lance/source/LanceScanNode.java | 9 +-
.../metastore/AbstractLanceProperties.java | 9 +
.../LanceFileSystemMetastoreProperties.java | 6 +
.../doris/datasource/tvf/source/TVFScanNode.java | 16 ++
.../ExternalFileTableValuedFunction.java | 17 ++
.../doris/tablefunction/S3TableValuedFunction.java | 5 +-
.../doris/datasource/LanceThriftContractTest.java | 43 +++
.../lance/LanceFilesystemCatalogTest.java | 18 --
.../doris/datasource/lance/LanceSnapshotTest.java | 4 +-
.../datasource/lance/LanceStorageOptionsTest.java | 314 +++++++++++++++++++++
gensrc/thrift/PlanNodes.thrift | 9 +
.../lance/test_lance_rest_catalog.out | 4 +
.../lance/test_lance_rest_catalog.groovy | 8 +
25 files changed, 1012 insertions(+), 172 deletions(-)
diff --git a/be/src/format_v2/table/lance_reader.cpp
b/be/src/format_v2/table/lance_reader.cpp
index 7491f7a8c02..d909aac8e5e 100644
--- a/be/src/format_v2/table/lance_reader.cpp
+++ b/be/src/format_v2/table/lance_reader.cpp
@@ -261,7 +261,8 @@ Status LanceTableReader::fetch_schema(const TFileRangeDesc&
range,
return Status::InvalidArgument("Lance schema output must not be null");
}
const auto& params = range.table_format_params.lance_params;
- const auto storage_options = _storage_options(&scan_params);
+ std::vector<std::string> storage_options;
+ RETURN_IF_ERROR(_storage_options(&scan_params, &storage_options));
std::vector<const char*> storage_option_ptrs;
storage_option_ptrs.reserve(storage_options.size() + 1);
for (const auto& option : storage_options) {
@@ -624,7 +625,8 @@ Status
LanceTableReader::_validate_external_search_request() const {
}
Status LanceTableReader::_ensure_dataset_open(const TFileRangeDesc& range) {
- const auto key = _dataset_key(range);
+ DatasetKey key;
+ RETURN_IF_ERROR(_dataset_key(range, &key));
if (_dataset == nullptr) {
RETURN_IF_ERROR(_open_dataset(key));
_opened_dataset_key = key;
@@ -1025,47 +1027,39 @@ Status LanceTableReader::_fill_block_from_record_batch(
return Status::OK();
}
-std::vector<std::string> LanceTableReader::_storage_options(
- const TFileScanRangeParams* scan_params) {
- if (scan_params == nullptr || !scan_params->__isset.properties) {
- return {};
- }
- static constexpr std::array<std::pair<std::string_view, std::string_view>,
5> kStorageKeys = {
- {{"AWS_ACCESS_KEY", "aws_access_key_id"},
- {"AWS_SECRET_KEY", "aws_secret_access_key"},
- {"AWS_TOKEN", "aws_session_token"},
- {"AWS_ENDPOINT", "aws_endpoint"},
- {"AWS_REGION", "aws_region"}}};
- std::vector<std::string> options;
- options.reserve(kStorageKeys.size() * 2);
- for (const auto& [doris_key, lance_key] : kStorageKeys) {
- const auto it = scan_params->properties.find(std::string(doris_key));
- if (it != scan_params->properties.end() && !it->second.empty()) {
- options.emplace_back(lance_key);
- options.emplace_back(it->second);
- }
- }
- const auto endpoint = scan_params->properties.find("AWS_ENDPOINT");
- if (endpoint != scan_params->properties.end() &&
endpoint->second.rfind("http://", 0) == 0) {
- options.emplace_back("allow_http");
- options.emplace_back("true");
+// The FE sends these already in Lance's own vocabulary, merged from the
catalog properties and
+// from whatever the namespace vended. Re-encoding them here would drop every
option this list did
+// not anticipate, so they are handed to lance-c as they arrive.
+Status LanceTableReader::_storage_options(const TFileScanRangeParams*
scan_params,
+ std::vector<std::string>* options) {
+ options->clear();
+ if (scan_params == nullptr || !scan_params->__isset.lance_storage_options)
{
+ return Status::OK();
}
- const auto path_style = scan_params->properties.find("use_path_style");
- if (path_style != scan_params->properties.end() &&
!path_style->second.empty()) {
- const bool use_path_style = path_style->second == "true" ||
path_style->second == "1";
- options.emplace_back("aws_virtual_hosted_style_request");
- options.emplace_back(use_path_style ? "false" : "true");
+ options->reserve(scan_params->lance_storage_options.size() * 2);
+ for (const auto& [key, value] : scan_params->lance_storage_options) {
+ // These become C strings below, so a NUL would truncate the option
here while the FE went
+ // on using the whole thing, and the two halves would open the dataset
with different
+ // configuration. The FE rejects these on both paths it builds options
from - its own
+ // storage configuration and what a namespace vends - so this is the
last line of defence,
+ // for an FE that predates those checks. Dropping one here instead of
failing would just
+ // recreate the divergence it exists to prevent.
+ if (key.find('\0') != std::string::npos || value.find('\0') !=
std::string::npos) {
+ return Status::InvalidArgument(
+ "Lance storage option '{}' contains a NUL and cannot reach
lance-c",
+ key.substr(0, key.find('\0')));
+ }
+ options->emplace_back(key);
+ options->emplace_back(value);
}
- return options;
+ return Status::OK();
}
-LanceTableReader::DatasetKey LanceTableReader::_dataset_key(const
TFileRangeDesc& range) const {
+Status LanceTableReader::_dataset_key(const TFileRangeDesc& range, DatasetKey*
key) const {
const auto& params = range.table_format_params.lance_params;
- return {
- .uri = params.dataset_uri,
- .version = params.version,
- .storage_options = _storage_options(_scan_params),
- };
+ key->uri = params.dataset_uri;
+ key->version = params.version;
+ return _storage_options(_scan_params, &key->storage_options);
}
Status LanceTableReader::_lance_error(std::string_view operation) {
diff --git a/be/src/format_v2/table/lance_reader.h
b/be/src/format_v2/table/lance_reader.h
index d16acf1c459..bda465185cb 100644
--- a/be/src/format_v2/table/lance_reader.h
+++ b/be/src/format_v2/table/lance_reader.h
@@ -95,8 +95,9 @@ private:
Block* block, size_t* rows);
Status _append_global_row_ids(const std::shared_ptr<arrow::Array>& row_ids,
MutableColumnPtr& output_column) const;
- static std::vector<std::string> _storage_options(const
TFileScanRangeParams* scan_params);
- DatasetKey _dataset_key(const TFileRangeDesc& range) const;
+ static Status _storage_options(const TFileScanRangeParams* scan_params,
+ std::vector<std::string>* options);
+ Status _dataset_key(const TFileRangeDesc& range, DatasetKey* key) const;
static Status _lance_error(std::string_view operation);
LanceDataset* _dataset = nullptr;
diff --git a/be/test/format_v2/table/lance_reader_test.cpp
b/be/test/format_v2/table/lance_reader_test.cpp
index 5d6e8f637b0..9b2943f0ad6 100644
--- a/be/test/format_v2/table/lance_reader_test.cpp
+++ b/be/test/format_v2/table/lance_reader_test.cpp
@@ -948,6 +948,31 @@ TEST(LanceTableReaderScanTest,
ReadsLatestSnapshotWithoutFragmentIds) {
EXPECT_TRUE(reader.close().ok());
}
+TEST(LanceTableReaderScanTest, RejectsStorageOptionWithEmbeddedNul) {
+ const std::filesystem::path dataset_uri =
+ "./be/test/format_v2/table/lance/data/all_types.lance";
+ const Columns columns {projected_column("row_id", TYPE_BIGINT, false)};
+ TQueryGlobals query_globals;
+ RuntimeState state(query_globals);
+ RuntimeProfile profile("lance_storage_option_embedded_nul");
+
+ // lance-c reads these as C strings, so a NUL truncates the option here
while the FE goes on
+ // using the whole thing, leaving the two halves opening the dataset with
different
+ // configuration. Dropping it instead of failing would only move that
divergence.
+ TFileScanRangeParams scan_params;
+ scan_params.__set_lance_storage_options(
+ {{std::string("aws_region\0ignored", 18), "us-east-1"}});
+
+ LanceTableReader reader;
+ ASSERT_TRUE(init_reader(&reader, columns, &state, &profile,
&scan_params).ok());
+
+ const auto status = prepare_range(&reader,
make_latest_lance_range(dataset_uri));
+
+ EXPECT_FALSE(status.ok());
+ EXPECT_NE(status.to_string().find("contains a NUL"), std::string::npos);
+ EXPECT_TRUE(reader.close().ok());
+}
+
TEST(LanceTableReaderTypeTest, ReadsNumericTypesFromAllTypesFixture) {
// The committed fixture contains four rows covering values, nulls, and
boundary cases.
const std::filesystem::path dataset_uri =
diff --git a/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
b/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
index 5fc6af63e77..90b20a444ea 100644
--- a/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
+++ b/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
@@ -118,7 +118,10 @@ services:
- ./scripts/lance_rest_server.py:/opt/lance-rest/server.py:ro
environment:
LANCE_REST_BEARER_TOKEN: doris-lance-rest-test-token
- LANCE_REST_TABLES_JSON:
'{"all_types":"s3://warehouse/lance/all_types.lance"}'
+ LANCE_REST_TABLES_JSON:
'{"all_types":"s3://warehouse/lance/all_types.lance","all_types_unprefixed":"s3://warehouse/lance/all_types.lance"}'
+ # all_types_unprefixed serves the same dataset but vends its credentials
under the
+ # unprefixed object-store spelling, which is what real namespace servers
emit.
+ LANCE_REST_UNPREFIXED_TABLES_JSON: '["all_types_unprefixed"]'
LANCE_S3_ACCESS_KEY: admin
LANCE_S3_SECRET_KEY: password
LANCE_S3_REGION: us-east-1
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/lance_rest_server.py
b/docker/thirdparties/docker-compose/iceberg/scripts/lance_rest_server.py
index 8aef278c024..b8436fa686b 100644
--- a/docker/thirdparties/docker-compose/iceberg/scripts/lance_rest_server.py
+++ b/docker/thirdparties/docker-compose/iceberg/scripts/lance_rest_server.py
@@ -65,6 +65,50 @@ def _load_tables() -> dict[tuple[str, ...], str]:
TABLES = _load_tables()
+def _load_unprefixed_tables() -> set[tuple[str, ...]]:
+ """Tables whose vended credentials use the unprefixed object-store
spelling.
+
+ A namespace may spell credentials with any alias Lance accepts, and real
servers do use the
+ unprefixed one, so at least one table has to exercise it.
+ """
+ raw = os.environ.get("LANCE_REST_UNPREFIXED_TABLES_JSON", "[]")
+ identifiers = json.loads(raw)
+ if not isinstance(identifiers, list):
+ raise ValueError("LANCE_REST_UNPREFIXED_TABLES_JSON must be a JSON
array")
+ return {
+ tuple(part for part in identifier.split(DELIMITER) if part)
+ for identifier in identifiers
+ }
+
+
+UNPREFIXED_TABLES = _load_unprefixed_tables()
+
+
+def _storage_options(identifier: tuple[str, ...]) -> dict[str, str]:
+ access_key = os.environ.get("LANCE_S3_ACCESS_KEY", "admin")
+ secret_key = os.environ.get("LANCE_S3_SECRET_KEY", "password")
+ region = os.environ.get("LANCE_S3_REGION", "us-east-1")
+ if identifier in UNPREFIXED_TABLES:
+ return {
+ "access_key_id": access_key,
+ "secret_access_key": secret_key,
+ "region": region,
+ "virtual_hosted_style_request": "false",
+ # A key Doris assigns no meaning to, kept here so the pass-through
stays covered.
+ # The BE opens datasets with static options and never refreshes
them, so this is
+ # only carried, not acted on; the expiry is far enough out that it
never matters.
+ "expires_at_millis": os.environ.get(
+ "LANCE_S3_EXPIRES_AT_MILLIS", "4102444800000"
+ ),
+ }
+ return {
+ "aws_access_key_id": access_key,
+ "aws_secret_access_key": secret_key,
+ "aws_region": region,
+ "aws_virtual_hosted_style_request": "false",
+ }
+
+
def _decode_identifier(identifier: str) -> tuple[str, ...]:
identifier = unquote(identifier)
if identifier == DELIMITER:
@@ -141,16 +185,7 @@ class LanceRestHandler(BaseHTTPRequestHandler):
"namespace": list(identifier[:-1]),
"location": table_uri,
"table_uri": table_uri,
- "storage_options": {
- "aws_access_key_id": os.environ.get(
- "LANCE_S3_ACCESS_KEY", "admin"
- ),
- "aws_secret_access_key": os.environ.get(
- "LANCE_S3_SECRET_KEY", "password"
- ),
- "aws_region": os.environ.get("LANCE_S3_REGION",
"us-east-1"),
- "aws_virtual_hosted_style_request": "false",
- },
+ "storage_options": _storage_options(identifier),
"managed_versioning": False,
"is_only_declared": False,
},
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalCatalog.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalCatalog.java
index 7629d68f6cc..c2fa3d1a6ee 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalCatalog.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalCatalog.java
@@ -86,8 +86,7 @@ public class LanceExternalCatalog extends ExternalCatalog {
private transient List<String> parentNamespace = Collections.emptyList();
private transient String catalogType;
private transient String rootDatabase;
- private transient Map<String, String> javaStorageOptions =
Collections.emptyMap();
- private transient Map<String, String> backendStorageOptions =
Collections.emptyMap();
+ private transient Map<String, String> namespaceStorageOptions =
Collections.emptyMap();
private transient Object namespaceLock = new Object();
public LanceExternalCatalog(long catalogId, String name, String resource,
Map<String, String> props,
@@ -105,11 +104,12 @@ public class LanceExternalCatalog extends ExternalCatalog
{
rootDatabase = properties.getRootDatabase();
parentNamespace = LanceNamespaceName.parseParentNamespace(
properties.getNamespaceParent(),
properties.getNamespaceDelimiter());
- backendStorageOptions =
catalogProperty.getBackendStorageProperties();
- javaStorageOptions =
LanceStorageOptions.forJavaSdk(backendStorageOptions);
+ namespaceStorageOptions = LanceStorageOptions.forUri(
+ properties.getNamespaceStorageUri(),
+ catalogProperty.getOrderedStoragePropertiesList());
allocator = new RootAllocator(ALLOCATOR_LIMIT);
- namespace = properties.createNamespace(allocator,
javaStorageOptions);
+ namespace = properties.createNamespace(allocator,
namespaceStorageOptions);
} catch (Exception e) {
closeLanceObjects();
throw new RuntimeException("Failed to initialize Lance catalog '"
+ getName()
@@ -127,8 +127,9 @@ public class LanceExternalCatalog extends ExternalCatalog {
}
AbstractLanceProperties properties = getLanceProperties();
- Map<String, String> storageOptions = LanceStorageOptions.forJavaSdk(
- catalogProperty.getBackendStorageProperties());
+ Map<String, String> storageOptions = LanceStorageOptions.forUri(
+ properties.getNamespaceStorageUri(),
+ catalogProperty.getOrderedStoragePropertiesList());
List<String> parent = LanceNamespaceName.parseParentNamespace(
properties.getNamespaceParent(),
properties.getNamespaceDelimiter());
String type = properties.getLanceCatalogType();
@@ -316,16 +317,16 @@ public class LanceExternalCatalog extends ExternalCatalog
{
"Cannot parse Lance FOR TIME AS OF value '" +
snapshot.getValue() + "'");
}
version = LanceSnapshotResolver.getVersionAtOrBefore(
- tableAccess.datasetUri,
tableAccess.javaStorageOptions, timestamp, allocator);
+ tableAccess.datasetUri,
tableAccess.storageOptions, timestamp, allocator);
}
- return LanceMetadataLoader.loadVersion(tableAccess.datasetUri,
tableAccess.javaStorageOptions,
- tableAccess.backendStorageOptions, version, allocator);
+ return LanceMetadataLoader.loadVersion(
+ tableAccess.datasetUri, tableAccess.storageOptions,
version, allocator);
}
return loadIndexSegments
?
LanceMetadataLoader.loadLatestWithIndexSegments(tableAccess.datasetUri,
- tableAccess.javaStorageOptions,
tableAccess.backendStorageOptions, allocator)
- : LanceMetadataLoader.loadLatest(tableAccess.datasetUri,
tableAccess.javaStorageOptions,
- tableAccess.backendStorageOptions, allocator);
+ tableAccess.storageOptions, allocator)
+ : LanceMetadataLoader.loadLatest(
+ tableAccess.datasetUri,
tableAccess.storageOptions, allocator);
} catch (Exception e) {
throw new RuntimeException("Failed to load Lance table metadata
for " + dbName + "." + tableName
+ ": " + sanitizedRootCauseMessage(e), safeCause(e));
@@ -340,7 +341,8 @@ public class LanceExternalCatalog extends ExternalCatalog {
try {
makeSureInitialized();
} catch (Exception e) {
- throw indexMetadataLoadFailure(dbName, tableName, e, null,
javaStorageOptions);
+ throw indexMetadataLoadFailure(
+ dbName, tableName, e, null, namespaceStorageOptions);
}
ResolvedTableAccess tableAccess = null;
@@ -351,7 +353,7 @@ public class LanceExternalCatalog extends ExternalCatalog {
// The deadline below covers the Dataset/JNI index metadata read
itself.
tableAccess = resolveTableAccess(dbName, tableName);
String datasetUri = tableAccess.datasetUri;
- Map<String, String> storageOptions =
tableAccess.javaStorageOptions;
+ Map<String, String> storageOptions = tableAccess.storageOptions;
return LanceMetadataReadExecutor.execute(() -> {
// The caller may return on deadline while JNI is still
running. A task-owned
// allocator prevents catalog close from releasing native
resources prematurely.
@@ -362,7 +364,7 @@ public class LanceExternalCatalog extends ExternalCatalog {
} catch (Exception e) {
String datasetUri = tableAccess == null ? null :
tableAccess.datasetUri;
Map<String, String> runtimeStorageOptions = tableAccess == null
- ? javaStorageOptions : tableAccess.javaStorageOptions;
+ ? namespaceStorageOptions : tableAccess.storageOptions;
throw indexMetadataLoadFailure(
dbName, tableName, e, datasetUri, runtimeStorageOptions);
}
@@ -391,13 +393,12 @@ public class LanceExternalCatalog extends ExternalCatalog
{
throw new RuntimeException("Lance namespace returned no table URI
for " + dbName + "." + tableName);
}
- Map<String, String> tableJavaStorageOptions = new
HashMap<>(javaStorageOptions);
- if (table.getStorageOptions() != null) {
- tableJavaStorageOptions.putAll(table.getStorageOptions());
- }
- Map<String, String> tableBackendStorageOptions =
LanceStorageOptions.forBackend(
- backendStorageOptions, table.getStorageOptions());
- return new ResolvedTableAccess(datasetUri, tableJavaStorageOptions,
tableBackendStorageOptions);
+ // One option map serves both readers: the FE opens the dataset
through the Lance Java SDK
+ // and the BE through lance-c, so neither can end up with credentials
the other lacks. The
+ // dataset URL picks the option vocabulary, the same way Lance picks a
provider from it.
+ Map<String, String> storageOptions =
LanceStorageOptions.forVendedTable(datasetUri,
+ catalogProperty.getOrderedStoragePropertiesList(),
table.getStorageOptions());
+ return new ResolvedTableAccess(datasetUri, storageOptions);
}
private DescribeTableResponse describeTable(String dbName, String
tableName) {
@@ -431,11 +432,6 @@ public class LanceExternalCatalog extends ExternalCatalog {
return result;
}
- public Map<String, String> getBackendStorageOptions() {
- makeSureInitialized();
- return backendStorageOptions;
- }
-
public String getLanceCatalogType() {
makeSureInitialized();
return catalogType;
@@ -538,14 +534,11 @@ public class LanceExternalCatalog extends ExternalCatalog
{
private static final class ResolvedTableAccess {
private final String datasetUri;
- private final Map<String, String> javaStorageOptions;
- private final Map<String, String> backendStorageOptions;
+ private final Map<String, String> storageOptions;
- private ResolvedTableAccess(String datasetUri, Map<String, String>
javaStorageOptions,
- Map<String, String> backendStorageOptions) {
+ private ResolvedTableAccess(String datasetUri, Map<String, String>
storageOptions) {
this.datasetUri = datasetUri;
- this.javaStorageOptions = Collections.unmodifiableMap(new
HashMap<>(javaStorageOptions));
- this.backendStorageOptions = Collections.unmodifiableMap(new
HashMap<>(backendStorageOptions));
+ this.storageOptions = Collections.unmodifiableMap(new
HashMap<>(storageOptions));
}
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceMetadataLoader.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceMetadataLoader.java
index 7c9faf2f0ec..9c4803af710 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceMetadataLoader.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceMetadataLoader.java
@@ -18,6 +18,7 @@
package org.apache.doris.datasource.lance;
import org.apache.doris.common.util.JsonUtil;
+import org.apache.doris.datasource.property.storage.StorageProperties;
import com.fasterxml.jackson.databind.JsonNode;
import org.apache.arrow.memory.BufferAllocator;
@@ -49,11 +50,11 @@ public final class LanceMetadataLoader {
* Lance dataset through an S3 TVF.
*/
public static LanceTableMetadata loadLatestForTvf(
- String datasetUri, Map<String, String> backendStorageOptions)
+ String datasetUri, List<StorageProperties> storageProperties)
throws Exception {
try (BufferAllocator allocator = new RootAllocator(ALLOCATOR_LIMIT)) {
- return loadLatest(datasetUri,
LanceStorageOptions.forJavaSdk(backendStorageOptions),
- backendStorageOptions, allocator);
+ return loadLatest(datasetUri,
+ LanceStorageOptions.forUri(datasetUri, storageProperties),
allocator);
}
}
@@ -65,18 +66,17 @@ public final class LanceMetadataLoader {
* time-travel version is requested. Schema, version, and fragments are
read from the same
* opened dataset snapshot.
*/
- public static LanceTableMetadata loadLatest(String datasetUri, Map<String,
String> javaStorageOptions,
- Map<String, String> backendStorageOptions, BufferAllocator
allocator) throws Exception {
+ public static LanceTableMetadata loadLatest(String datasetUri,
+ Map<String, String> lanceStorageOptions, BufferAllocator
allocator) throws Exception {
return loadInternal(
- datasetUri, javaStorageOptions, backendStorageOptions,
OptionalLong.empty(), allocator, false);
+ datasetUri, lanceStorageOptions, OptionalLong.empty(),
allocator, false);
}
/** Loads the latest fixed snapshot together with vector index segment
coverage. */
public static LanceTableMetadata loadLatestWithIndexSegments(
- String datasetUri, Map<String, String> javaStorageOptions,
- Map<String, String> backendStorageOptions, BufferAllocator
allocator) throws Exception {
+ String datasetUri, Map<String, String> lanceStorageOptions,
BufferAllocator allocator) throws Exception {
return loadInternal(
- datasetUri, javaStorageOptions, backendStorageOptions,
OptionalLong.empty(), allocator, true);
+ datasetUri, lanceStorageOptions, OptionalLong.empty(),
allocator, true);
}
/**
@@ -86,18 +86,19 @@ public final class LanceMetadataLoader {
* {@link LanceExternalCatalog#loadTableMetadata(String, String,
java.util.Optional)} for both
* {@code FOR VERSION AS OF} and the version resolved from {@code FOR TIME
AS OF}.
*/
- public static LanceTableMetadata loadVersion(String datasetUri,
Map<String, String> javaStorageOptions,
- Map<String, String> backendStorageOptions, long version,
BufferAllocator allocator) throws Exception {
+ public static LanceTableMetadata loadVersion(String datasetUri,
+ Map<String, String> lanceStorageOptions, long version,
BufferAllocator allocator)
+ throws Exception {
return loadInternal(
- datasetUri, javaStorageOptions, backendStorageOptions,
OptionalLong.of(version), allocator, false);
+ datasetUri, lanceStorageOptions, OptionalLong.of(version),
allocator, false);
}
/** Shared implementation for the latest-version and explicit-version
public entry points. */
- private static LanceTableMetadata loadInternal(String datasetUri,
Map<String, String> javaStorageOptions,
- Map<String, String> backendStorageOptions, OptionalLong version,
+ private static LanceTableMetadata loadInternal(String datasetUri,
+ Map<String, String> lanceStorageOptions, OptionalLong version,
BufferAllocator allocator, boolean loadIndexSegments) throws
Exception {
try (Dataset dataset =
Dataset.open().allocator(allocator).uri(datasetUri)
- .readOptions(LanceReadOptions.build(javaStorageOptions,
version)).build()) {
+ .readOptions(LanceReadOptions.build(lanceStorageOptions,
version)).build()) {
long resolvedVersion = dataset.version();
List<LanceFragmentInfo> fragments = new ArrayList<>();
for (Fragment fragment : dataset.getFragments()) {
@@ -112,9 +113,9 @@ public final class LanceMetadataLoader {
return loadIndexSegments
? LanceTableMetadata.withIndexSegments(datasetUri,
resolvedVersion,
dataset.getSchema(), fragments, lanceFieldIds,
- indexSegments, backendStorageOptions)
+ indexSegments, lanceStorageOptions)
: LanceTableMetadata.withoutIndexSegments(datasetUri,
resolvedVersion,
- dataset.getSchema(), fragments,
backendStorageOptions);
+ dataset.getSchema(), fragments,
lanceStorageOptions);
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LancePassThroughStorageProvider.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LancePassThroughStorageProvider.java
new file mode 100644
index 00000000000..78cc69ee9be
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LancePassThroughStorageProvider.java
@@ -0,0 +1,59 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.lance;
+
+import org.apache.doris.datasource.property.storage.StorageProperties;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Every provider Lance routes somewhere other than S3 - Azure, GCS, OSS,
Tencent COS, local files,
+ * and anything Lance adds later.
+ *
+ * <p>Both halves are deliberately inert, so such a dataset is reachable only
through what its
+ * namespace vends. Those options are the namespace's to name: Lance's OSS
provider reads
+ * {@code access_key_id} and requires {@code endpoint}, object_store's Azure
parser reads
+ * {@code endpoint} and takes {@code token} as a bearer token. Rewriting any
of that onto the S3
+ * spellings would leave the dataset unreachable, which is what this class
exists to prevent.
+ *
+ * <p>{@link #fromDorisProperties} is empty for want of a translation, not for
want of input: Doris
+ * does model these - {@code OSSProperties} and {@code COSProperties} carry an
endpoint and
+ * credentials like any other. Writing that translation means committing to a
vocabulary per
+ * provider with no way to exercise it here, which is how the rewriting bug
above got in, so it
+ * waits for a backend that can be tested against.
+ */
+final class LancePassThroughStorageProvider implements LanceStorageProvider {
+
+ static final LancePassThroughStorageProvider INSTANCE = new
LancePassThroughStorageProvider();
+
+ private LancePassThroughStorageProvider() {
+ }
+
+ @Override
+ public Map<String, String> fromDorisProperties(List<StorageProperties>
storageProperties) {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public Map<String, String> normalizeVended(Map<String, String>
vendedOptions) {
+ return vendedOptions == null ? new HashMap<>() : new
HashMap<>(vendedOptions);
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceS3StorageProvider.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceS3StorageProvider.java
new file mode 100644
index 00000000000..eb723a0201f
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceS3StorageProvider.java
@@ -0,0 +1,193 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.lance;
+
+import
org.apache.doris.datasource.property.storage.AbstractS3CompatibleProperties;
+import org.apache.doris.datasource.property.storage.S3Properties;
+import org.apache.doris.datasource.property.storage.StorageProperties;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * S3-compatible storage, which Lance reaches through object_store's AWS
provider.
+ *
+ * <p>The emitted spelling is the one object_store reports as canonical,
because that is what
+ * {@code StorageOptions::with_env_s3} looks for before pulling the same
option out of the process
+ * environment:
+ *
+ * <pre>
+ * // lance-io/src/object_store/providers/aws.rs
+ * if let Ok(config_key) =
AmazonS3ConfigKey::from_str(&key.to_ascii_lowercase())
+ * && !self.0.contains_key(config_key.as_ref()) //
"aws_access_key_id"
+ * </pre>
+ *
+ * <p>Any other accepted alias leaves that check unsatisfied, so a stray
{@code AWS_ACCESS_KEY_ID}
+ * in the FE or BE environment is inserted next to the configured value, and
object_store's
+ * {@code as_s3_options()} then folds both onto one config key and keeps
whichever its HashMap
+ * yields last - independently per option and per process. Lance's OpenDAL S3
backend accepts these
+ * same spellings as serde aliases of its own field names.
+ */
+final class LanceS3StorageProvider implements LanceStorageProvider {
+
+ static final LanceS3StorageProvider INSTANCE = new
LanceS3StorageProvider();
+
+ private static final String ACCESS_KEY_ID = "aws_access_key_id";
+ private static final String SECRET_ACCESS_KEY = "aws_secret_access_key";
+ private static final String SESSION_TOKEN = "aws_session_token";
+ private static final String REGION = "aws_region";
+ private static final String ENDPOINT = "aws_endpoint";
+ private static final String VIRTUAL_HOSTED_STYLE =
"aws_virtual_hosted_style_request";
+ /**
+ * Not prefixed: object_store carries this as a shared client option, and
reports it as
+ * canonical under this name. The spelling buys nothing against the
environment here, though -
+ * {@code StorageOptions::new} overwrites this key outright from {@code
AWS_ALLOW_HTTP} before
+ * {@code with_env_s3} runs.
+ */
+ private static final String ALLOW_HTTP = "allow_http";
+
+ /**
+ * Every spelling object_store accepts for the options above, mapped onto
the one emitted.
+ *
+ * <p>Confined to those options on purpose. They are the only ones this
class contributes, so
+ * they are the only ones a vended option can collide with; anything else
a namespace sends is
+ * between the namespace and Lance.
+ *
+ * <p>{@code token} is included because this provider only ever speaks for
an S3 dataset, where
+ * it is unambiguously the session token. It means a bearer token to
object_store's Azure
+ * parser, which is why it can only be resolved once the provider is known.
+ *
+ * <p>{@code aws_endpoint_url_s3} is deliberately absent. object_store
parses it into a config
+ * key of its own and prefers it over the generic endpoint, so a vended
one already wins
+ * without being rewritten, and folding it in would replace a defined
precedence with map order.
+ */
+ private static final Map<String, String> CANONICAL_BY_ALIAS =
ImmutableMap.<String, String>builder()
+ .put("access_key_id", ACCESS_KEY_ID)
+ .put("aws_access_key_id", ACCESS_KEY_ID)
+ .put("secret_access_key", SECRET_ACCESS_KEY)
+ .put("aws_secret_access_key", SECRET_ACCESS_KEY)
+ .put("session_token", SESSION_TOKEN)
+ .put("aws_session_token", SESSION_TOKEN)
+ .put("aws_token", SESSION_TOKEN)
+ .put("token", SESSION_TOKEN)
+ .put("endpoint", ENDPOINT)
+ .put("endpoint_url", ENDPOINT)
+ .put("aws_endpoint", ENDPOINT)
+ .put("aws_endpoint_url", ENDPOINT)
+ .put("region", REGION)
+ .put("aws_region", REGION)
+ .put("virtual_hosted_style_request", VIRTUAL_HOSTED_STYLE)
+ .put("aws_virtual_hosted_style_request", VIRTUAL_HOSTED_STYLE)
+ // OpenDAL's own field name, of which the two above are serde
aliases. All three would
+ // be the same field supplied more than once, which fails the
operator build outright.
+ .put("enable_virtual_host_style", VIRTUAL_HOSTED_STYLE)
+ .put("allow_http", ALLOW_HTTP)
+ .put("aws_allow_http", ALLOW_HTTP)
+ .build();
+
+ private LanceS3StorageProvider() {
+ }
+
+ @Override
+ public Map<String, String> fromDorisProperties(List<StorageProperties>
storageProperties) {
+ Map<String, String> result = new HashMap<>();
+ AbstractS3CompatibleProperties properties =
selectS3Compatible(storageProperties);
+ if (properties == null) {
+ return result;
+ }
+ putIfNotEmpty(result, ACCESS_KEY_ID, properties.getAccessKey());
+ putIfNotEmpty(result, SECRET_ACCESS_KEY, properties.getSecretKey());
+ putIfNotEmpty(result, SESSION_TOKEN, properties.getSessionToken());
+ putIfNotEmpty(result, ENDPOINT, properties.getEndpoint());
+ putIfNotEmpty(result, REGION, properties.getRegion());
+
+ String usePathStyle = properties.getUsePathStyle();
+ if (usePathStyle != null && !usePathStyle.isEmpty()) {
+ result.put(VIRTUAL_HOSTED_STYLE,
String.valueOf(!Boolean.parseBoolean(usePathStyle)));
+ }
+
+ // Lance refuses a plain-HTTP endpoint unless this is set, and Doris
configures one for
+ // MinIO. It describes the endpoint just mapped, so it is derived from
the same properties.
+ String endpoint = properties.getEndpoint();
+ if (endpoint != null && endpoint.startsWith("http://")) {
+ result.put(ALLOW_HTTP, "true");
+ }
+ return result;
+ }
+
+ /**
+ * Picks the one S3-compatible configuration to read, preferring a
concrete provider over the
+ * generic {@link S3Properties}: naming OSS or COS explicitly is a choice,
while S3Properties is
+ * also what a heuristic match lands on. The list is not a user-ordered
one - it follows
+ * {@code StorageProperties.PROVIDERS} and may lead with a default HDFS
entry - so it has to be
+ * filtered by type rather than indexed.
+ *
+ * <p>Same rule as {@code AbstractIcebergProperties.toFileIOProperties},
deliberately copied
+ * rather than shared: hoisting it would mean changing Iceberg in this
patch.
+ */
+ private static AbstractS3CompatibleProperties selectS3Compatible(
+ List<StorageProperties> storageProperties) {
+ if (storageProperties == null) {
+ return null;
+ }
+ AbstractS3CompatibleProperties fallback = null;
+ AbstractS3CompatibleProperties concrete = null;
+ for (StorageProperties candidate : storageProperties) {
+ if (!(candidate instanceof AbstractS3CompatibleProperties)) {
+ continue;
+ }
+ if (fallback == null) {
+ fallback = (AbstractS3CompatibleProperties) candidate;
+ }
+ if (concrete == null && !(candidate instanceof S3Properties)) {
+ concrete = (AbstractS3CompatibleProperties) candidate;
+ }
+ }
+ return concrete != null ? concrete : fallback;
+ }
+
+ @Override
+ public Map<String, String> normalizeVended(Map<String, String>
vendedOptions) {
+ Map<String, String> result = new HashMap<>();
+ if (vendedOptions == null) {
+ return result;
+ }
+ vendedOptions.forEach((key, value) -> {
+ String canonical =
CANONICAL_BY_ALIAS.getOrDefault(key.toLowerCase(Locale.ROOT), key);
+ String previous = result.put(canonical, value);
+ // Two spellings of one option, disagreeing. Picking one would be
the coin toss this
+ // whole class exists to remove, so say so instead.
+ if (previous != null && !previous.equals(value)) {
+ throw new IllegalArgumentException(
+ "Lance namespace vended conflicting values for storage
option '"
+ + canonical + "'");
+ }
+ });
+ return result;
+ }
+
+ private static void putIfNotEmpty(Map<String, String> target, String key,
String value) {
+ if (value != null && !value.isEmpty()) {
+ target.put(key, value);
+ }
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceStorageOptions.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceStorageOptions.java
index 971f461baf8..65d9ba2a8a7 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceStorageOptions.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceStorageOptions.java
@@ -17,61 +17,96 @@
package org.apache.doris.datasource.lance;
+import org.apache.doris.datasource.property.storage.StorageProperties;
+
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
-/** Converts normalized Doris storage properties to Lance object-store
options. */
+/**
+ * Builds the Lance object-store options for one dataset.
+ *
+ * <p>Both the FE, which opens the dataset through the Lance Java SDK, and the
BE, which opens it
+ * through lance-c, consume the map produced here, so neither can reach a
dataset by a
+ * configuration the other never saw.
+ *
+ * <p>The option vocabulary belongs to {@link LanceStorageProvider}, chosen
from the dataset's URL
+ * the same way Lance chooses one. This class only decides what happens when
the catalog and the
+ * namespace both describe a dataset: the namespace wins, because it just
described the table.
+ */
public final class LanceStorageOptions {
- private static final Map<String, String> S3_KEYS = new HashMap<>();
-
- static {
- 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");
- }
private LanceStorageOptions() {
}
- public static Map<String, String> forJavaSdk(Map<String, String>
backendProperties) {
- Map<String, String> result = new HashMap<>();
- S3_KEYS.forEach((dorisKey, lanceKey) -> putIfNotEmpty(result, lanceKey,
- backendProperties.get(dorisKey)));
-
- String endpoint = backendProperties.get("AWS_ENDPOINT");
- if (endpoint != null && endpoint.startsWith("http://")) {
- result.put("allow_http", "true");
- }
- String usePathStyle = backendProperties.get("use_path_style");
- if (usePathStyle != null && !usePathStyle.isEmpty()) {
- result.put("aws_virtual_hosted_style_request",
- String.valueOf(!Boolean.parseBoolean(usePathStyle)));
- }
+ /**
+ * Doris's own storage configuration, in the vocabulary of the provider
Lance routes
+ * {@code uri} to.
+ *
+ * <p>Used wherever no namespace is involved: the storage a namespace
client reads itself, and
+ * the {@code s3()} table-valued function.
+ */
+ public static Map<String, String> forUri(String uri,
List<StorageProperties> storageProperties) {
+ Map<String, String> result = new HashMap<>(
+
LanceStorageProvider.forDataset(uri).fromDorisProperties(storageProperties));
+ result.forEach((key, value) -> rejectUntransportable(key, value,
+ "Doris storage configuration"));
return result;
}
- /** Merge Lance storage options returned by a namespace into properties
understood by Doris BE. */
- public static Map<String, String> forBackend(Map<String, String>
staticBackendProperties,
- Map<String, String> lanceStorageOptions) {
- Map<String, String> result = new HashMap<>(staticBackendProperties);
- if (lanceStorageOptions == null || lanceStorageOptions.isEmpty()) {
+ /**
+ * The same, plus whatever a namespace vended for one table, which wins on
any option both
+ * sides name - it just described the table.
+ *
+ * <p>Putting both halves in one provider's vocabulary is what makes that
possible: otherwise
+ * two spellings of one option reach Lance as separate entries, and
object_store keeps
+ * whichever its HashMap yields last - independently in the FE and in the
BE.
+ *
+ * <p>{@code vendedOptions} may be null or empty; a namespace that
describes a table without
+ * vending storage options is ordinary, and this then degenerates to
{@link #forUri}.
+ */
+ public static Map<String, String> forVendedTable(String datasetUri,
+ List<StorageProperties> storageProperties, Map<String, String>
vendedOptions) {
+ Map<String, String> result = forUri(datasetUri, storageProperties);
+ if (vendedOptions == null || vendedOptions.isEmpty()) {
return result;
}
- S3_KEYS.forEach((dorisKey, lanceKey) -> putIfNotEmpty(result, dorisKey,
- lanceStorageOptions.get(lanceKey)));
+ vendedOptions.forEach(LanceStorageOptions::validateVendedOption);
+ // Safe to validate the vended half before normalizing:
normalizeVended only ever renames a
+ // key to one of this class's own constants or passes it through
unchanged, so it cannot
+ // introduce a NUL that the check above would have missed.
+
result.putAll(LanceStorageProvider.forDataset(datasetUri).normalizeVended(vendedOptions));
+ return result;
+ }
- String virtualHostedStyle =
lanceStorageOptions.get("aws_virtual_hosted_style_request");
- if (virtualHostedStyle != null && !virtualHostedStyle.isEmpty()) {
- result.put("use_path_style",
String.valueOf(!Boolean.parseBoolean(virtualHostedStyle)));
+ /**
+ * Rejects an option a namespace had no business sending.
+ *
+ * <p>Null is not expressible at all, and a NUL cannot survive the
boundary - see
+ * {@link #rejectUntransportable}.
+ */
+ private static void validateVendedOption(String key, String value) {
+ if (key == null || value == null) {
+ throw new IllegalArgumentException(
+ "Lance namespace vended a storage option with a null key
or value");
}
- return result;
+ rejectUntransportable(key, value, "Lance namespace");
}
- private static void putIfNotEmpty(Map<String, String> target, String key,
String value) {
- if (value != null && !value.isEmpty()) {
- target.put(key, value);
+ /**
+ * Rejects what cannot cross into Lance unchanged, whichever side it came
from.
+ *
+ * <p>These options are handed to lance-c and to the Lance Java SDK as C
strings, so a NUL
+ * truncates one there while the FE goes on using the whole thing, and the
two halves open the
+ * dataset with different configuration. That has to fail loudly: dropping
the option instead
+ * only moves the divergence, since a component that drops it and one that
does not disagree in
+ * exactly the same way. The BE repeats this check as its own last line of
defence.
+ */
+ private static void rejectUntransportable(String key, String value, String
source) {
+ if (key.indexOf('\0') >= 0 || value.indexOf('\0') >= 0) {
+ throw new IllegalArgumentException(source + " supplied the storage
option '"
+ + key.replace('\0', '?') + "' with a NUL in its key or
value, which cannot "
+ + "reach Lance intact");
}
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceStorageProvider.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceStorageProvider.java
new file mode 100644
index 00000000000..9b980351af1
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceStorageProvider.java
@@ -0,0 +1,84 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.lance;
+
+import org.apache.doris.datasource.property.storage.StorageProperties;
+
+import com.google.common.collect.ImmutableSet;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * One storage provider's option vocabulary, as Lance reads it.
+ *
+ * <p>Lance routes a dataset to a provider by URL scheme, and each provider
accepts its own set of
+ * option names. Nothing here may be applied without knowing which provider a
dataset uses: the
+ * spellings overlap but do not agree, so rewriting an option for the wrong
provider destroys it.
+ * {@code endpoint}, for instance, is what object_store's Azure parser and
Lance's OSS provider
+ * read, while its S3 parser also accepts {@code aws_endpoint} - renaming onto
the S3 spelling
+ * silently breaks the other two.
+ *
+ * <p>An implementation therefore only ever speaks for datasets {@link
#forDataset} routed to it.
+ */
+public interface LanceStorageProvider {
+
+ /** Schemes lance-io registers for its AWS provider
(rust/lance-io/.../providers.rs). */
+ Set<String> S3_SCHEMES = ImmutableSet.of("s3", "s3+ddb");
+
+ /**
+ * Converts the catalog's own storage configuration into this provider's
Lance options.
+ *
+ * <p>Takes Doris's typed properties rather than the flattened backend
map, which is only ever
+ * a re-encoding of them: {@code
AbstractS3CompatibleProperties.doBuildS3Configuration} builds
+ * that map out of the same getters read here, mixed with BE-only knobs
Lance has no use for,
+ * and flattens every configured storage into one namespace where two
S3-compatible ones would
+ * silently overwrite each other.
+ *
+ * <p>Empty when the list holds nothing this provider can read, which is
every provider but S3
+ * today - those datasets are reachable only through what a namespace
vends.
+ */
+ Map<String, String> fromDorisProperties(List<StorageProperties>
storageProperties);
+
+ /**
+ * Rewrites the options a namespace vended onto the spelling {@link
#fromDorisProperties}
+ * emits, so that the two cannot reach Lance as competing entries for one
config key.
+ *
+ * <p>Only the options Doris itself emits are rewritten. Everything else
is passed through
+ * untouched: the Lance Namespace specification describes {@code
storage_options} as
+ * configuration "passed directly to Lance", so a client cannot assume a
vocabulary beyond the
+ * one it contributes to itself.
+ */
+ Map<String, String> normalizeVended(Map<String, String> vendedOptions);
+
+ /** The provider Lance will route this dataset to. */
+ static LanceStorageProvider forDataset(String datasetUri) {
+ return S3_SCHEMES.contains(schemeOf(datasetUri))
+ ? LanceS3StorageProvider.INSTANCE :
LancePassThroughStorageProvider.INSTANCE;
+ }
+
+ static String schemeOf(String datasetUri) {
+ if (datasetUri == null) {
+ return "";
+ }
+ int separator = datasetUri.indexOf("://");
+ return separator < 0 ? "" : datasetUri.substring(0,
separator).toLowerCase(Locale.ROOT);
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceTableMetadata.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceTableMetadata.java
index 22d0156085c..2e0f413c76b 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceTableMetadata.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceTableMetadata.java
@@ -34,33 +34,33 @@ public class LanceTableMetadata {
private final List<LanceFragmentInfo> fragments;
private final Map<String, Integer> lanceFieldIds;
private final List<LanceIndexSegmentInfo> indexSegments;
- private final Map<String, String> backendStorageOptions;
+ private final Map<String, String> lanceStorageOptions;
public static LanceTableMetadata withoutIndexSegments(String datasetUri,
long version,
Schema schema, List<LanceFragmentInfo> fragments,
- Map<String, String> backendStorageOptions) {
+ Map<String, String> lanceStorageOptions) {
return new LanceTableMetadata(datasetUri, version, schema, fragments,
- Collections.emptyMap(), Collections.emptyList(),
backendStorageOptions);
+ Collections.emptyMap(), Collections.emptyList(),
lanceStorageOptions);
}
public static LanceTableMetadata withIndexSegments(String datasetUri, long
version,
Schema schema, List<LanceFragmentInfo> fragments,
Map<String, Integer> lanceFieldIds, List<LanceIndexSegmentInfo>
indexSegments,
- Map<String, String> backendStorageOptions) {
+ Map<String, String> lanceStorageOptions) {
return new LanceTableMetadata(datasetUri, version, schema, fragments,
- lanceFieldIds, indexSegments, backendStorageOptions);
+ lanceFieldIds, indexSegments, lanceStorageOptions);
}
private LanceTableMetadata(String datasetUri, long version, Schema schema,
List<LanceFragmentInfo> fragments, Map<String, Integer>
lanceFieldIds,
- List<LanceIndexSegmentInfo> indexSegments, Map<String, String>
backendStorageOptions) {
+ List<LanceIndexSegmentInfo> indexSegments, Map<String, String>
lanceStorageOptions) {
this.datasetUri = datasetUri;
this.version = version;
this.schema = schema;
this.fragments = Collections.unmodifiableList(new
ArrayList<>(fragments));
this.lanceFieldIds = Collections.unmodifiableMap(new
HashMap<>(lanceFieldIds));
this.indexSegments = Collections.unmodifiableList(new
ArrayList<>(indexSegments));
- this.backendStorageOptions = Collections.unmodifiableMap(new
HashMap<>(backendStorageOptions));
+ this.lanceStorageOptions = Collections.unmodifiableMap(new
HashMap<>(lanceStorageOptions));
}
public String getDatasetUri() {
@@ -88,8 +88,9 @@ public class LanceTableMetadata {
return fieldId == null ? OptionalInt.empty() : OptionalInt.of(fieldId);
}
- public Map<String, String> getBackendStorageOptions() {
- return backendStorageOptions;
+ /** Lance object-store options, understood as-is by both the FE SDK and
lance-c. */
+ public Map<String, String> getLanceStorageOptions() {
+ return lanceStorageOptions;
}
public long getRowCount() {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
index 69fd70c466b..ccae0570ec9 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java
@@ -164,6 +164,11 @@ public class LanceScanNode extends FileQueryScanNode {
if (lanceSubstraitFilter.length > 0) {
params.setLanceSubstraitFilter(ByteBuffer.wrap(lanceSubstraitFilter));
}
+ // Set at ScanNode level so credentials are not serialized once per
fragment split.
+ Map<String, String> lanceStorageOptions =
plannedMetadata.getLanceStorageOptions();
+ if (!lanceStorageOptions.isEmpty()) {
+ params.setLanceStorageOptions(lanceStorageOptions);
+ }
}
@Override
@@ -409,7 +414,9 @@ public class LanceScanNode extends FileQueryScanNode {
@Override
protected Map<String, String> getLocationProperties() {
- return plannedMetadata.getBackendStorageOptions();
+ // lance-c reads the dataset itself and takes its configuration from
lance_storage_options,
+ // so these serve only the shared file system layer and the file cache
key.
+ return
lanceTable.getCatalog().getCatalogProperty().getBackendStorageProperties();
}
@Override
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractLanceProperties.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractLanceProperties.java
index c82b74fa3b0..9b6806c34e8 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractLanceProperties.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractLanceProperties.java
@@ -78,6 +78,15 @@ public abstract class AbstractLanceProperties extends
MetastoreProperties {
public abstract LanceNamespace createNamespace(
BufferAllocator allocator, Map<String, String> javaStorageOptions);
+ /**
+ * The URL whose storage the namespace client reads itself, which decides
how its options have
+ * to be spelled. Empty when it reads none: a REST namespace is reached
over HTTP and ignores
+ * the options entirely.
+ */
+ public String getNamespaceStorageUri() {
+ return "";
+ }
+
protected abstract void validateCatalogProperties();
public String getNamespaceParent() {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/LanceFileSystemMetastoreProperties.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/LanceFileSystemMetastoreProperties.java
index c6518f07f7e..19aa4afef1f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/LanceFileSystemMetastoreProperties.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/LanceFileSystemMetastoreProperties.java
@@ -64,6 +64,12 @@ public class LanceFileSystemMetastoreProperties extends
AbstractLanceProperties
return warehouse;
}
+ /** A directory namespace opens the warehouse itself, so its options
follow that URL. */
+ @Override
+ public String getNamespaceStorageUri() {
+ return warehouse;
+ }
+
@Override
protected void validateCatalogProperties() {
warehouse = origProps.get(WAREHOUSE);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java
index 2aa7efcb7e2..ba1a47396d6 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/tvf/source/TVFScanNode.java
@@ -32,6 +32,7 @@ import org.apache.doris.datasource.FileSplit.FileSplitCreator;
import org.apache.doris.datasource.FileSplitter;
import org.apache.doris.datasource.TableFormatType;
import org.apache.doris.datasource.lance.LanceFragmentInfo;
+import org.apache.doris.datasource.lance.LanceStorageOptions;
import org.apache.doris.datasource.lance.source.LanceSplit;
import org.apache.doris.planner.PlanNodeId;
import org.apache.doris.planner.ScanContext;
@@ -125,6 +126,21 @@ public class TVFScanNode extends FileQueryScanNode {
return tableValuedFunction.getBackendConnectProperties();
}
+ @Override
+ public void createScanRangeLocations() throws UserException {
+ super.createScanRangeLocations();
+ if (tableValuedFunction.isLanceFormat()) {
+ // lance-c opens the dataset itself and needs the options in
Lance's own vocabulary.
+ // Set at ScanNode level so credentials are not serialized once
per fragment split.
+ Map<String, String> lanceStorageOptions =
LanceStorageOptions.forUri(
+ tableValuedFunction.getFilePath(),
+
Collections.singletonList(tableValuedFunction.getStorageProperties()));
+ if (!lanceStorageOptions.isEmpty()) {
+ params.setLanceStorageOptions(lanceStorageOptions);
+ }
+ }
+ }
+
@Override
public List<String> getPathPartitionKeys() {
return tableValuedFunction.getPathPartitionKeys();
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java
index 5a61c3c3b16..0e80b40eada 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/ExternalFileTableValuedFunction.java
@@ -46,6 +46,7 @@ import org.apache.doris.common.util.S3Util;
import org.apache.doris.common.util.Util;
import org.apache.doris.datasource.TableFormatType;
import org.apache.doris.datasource.lance.LanceFragmentInfo;
+import org.apache.doris.datasource.lance.LanceStorageOptions;
import org.apache.doris.datasource.lance.LanceTableMetadata;
import org.apache.doris.datasource.lance.LanceTypeConverter;
import org.apache.doris.datasource.property.fileformat.CsvFileFormatProperties;
@@ -171,6 +172,14 @@ public abstract class ExternalFileTableValuedFunction
extends TableValuedFunctio
return backendConnectProperties;
}
+ /**
+ * The typed storage configuration this function was analyzed with. Prefer
this over
+ * {@link #getBrokerDesc()}, which builds a fresh {@link
StorageProperties} on every call.
+ */
+ public StorageProperties getStorageProperties() {
+ return storageProperties;
+ }
+
public List<String> getPathPartitionKeys() {
return pathPartitionKeys;
}
@@ -527,6 +536,14 @@ public abstract class ExternalFileTableValuedFunction
extends TableValuedFunctio
Map<String, String> beProperties = new HashMap<>();
beProperties.putAll(backendConnectProperties);
fileScanRangeParams.setProperties(beProperties);
+ if (fileFormatProperties.getFileFormatType() ==
TFileFormatType.FORMAT_LANCE) {
+ // lance-c opens the dataset itself and needs the options in
Lance's own vocabulary.
+ Map<String, String> lanceStorageOptions =
LanceStorageOptions.forUri(
+ filePath, Collections.singletonList(storageProperties));
+ if (!lanceStorageOptions.isEmpty()) {
+
fileScanRangeParams.setLanceStorageOptions(lanceStorageOptions);
+ }
+ }
fileScanRangeParams.setFileAttributes(getFileAttributes());
ConnectContext ctx = ConnectContext.get();
fileScanRangeParams.setLoadId(ctx.queryId());
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/S3TableValuedFunction.java
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/S3TableValuedFunction.java
index 61be8f0af22..2858e90f2b6 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/S3TableValuedFunction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/S3TableValuedFunction.java
@@ -27,6 +27,7 @@ import org.apache.doris.datasource.lance.LanceTableMetadata;
import org.apache.doris.datasource.property.storage.StorageProperties;
import org.apache.doris.thrift.TFileType;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -64,8 +65,8 @@ public class S3TableValuedFunction extends
ExternalFileTableValuedFunction {
}
if (isLanceFormat()) {
try {
- LanceTableMetadata metadata =
- LanceMetadataLoader.loadLatestForTvf(filePath,
backendConnectProperties);
+ LanceTableMetadata metadata =
LanceMetadataLoader.loadLatestForTvf(
+ filePath,
Collections.singletonList(storageProperties));
setLanceTableMetadata(metadata);
} catch (Exception e) {
throw new AnalysisException(
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/LanceThriftContractTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/LanceThriftContractTest.java
index dc21fafa314..c541af25049 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/LanceThriftContractTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/LanceThriftContractTest.java
@@ -18,6 +18,7 @@
package org.apache.doris.datasource;
import org.apache.doris.thrift.TFileFormatType;
+import org.apache.doris.thrift.TFileScanRangeParams;
import org.apache.doris.thrift.TLanceFileDesc;
import org.apache.doris.thrift.TTableFormatFileDesc;
@@ -28,6 +29,8 @@ import org.junit.Assert;
import org.junit.Test;
import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
public class LanceThriftContractTest {
@@ -77,4 +80,44 @@ public class LanceThriftContractTest {
// A scan without a pushable LIMIT must leave the field unset so the
BE reads all rows.
Assert.assertFalse(restored.getLanceParams().isSetLimit());
}
+
+ @Test
+ public void testLanceStorageOptionsSurviveRoundTripUntouched() throws
Exception {
+ Map<String, String> storageOptions = new HashMap<>();
+ storageOptions.put("access_key_id", "ak");
+ storageOptions.put("secret_access_key", "sk");
+ storageOptions.put("endpoint", "http://127.0.0.1:9000");
+ storageOptions.put("expires_at_millis", "1760000000000");
+ storageOptions.put("azure_storage_sas_token", "sas");
+
+ TFileScanRangeParams source = new TFileScanRangeParams()
+ .setFormatType(TFileFormatType.FORMAT_LANCE)
+ .setLanceStorageOptions(storageOptions);
+
+ TSerializer serializer = new TSerializer(new
TCompactProtocol.Factory());
+ byte[] bytes = serializer.serialize(source);
+
+ TFileScanRangeParams restored = new TFileScanRangeParams();
+ new TDeserializer(new
TCompactProtocol.Factory()).deserialize(restored, bytes);
+
+ // Whatever the namespace vended has to reach lance-c unchanged,
including keys Doris
+ // itself assigns no meaning to.
+ Assert.assertTrue(restored.isSetLanceStorageOptions());
+ Assert.assertEquals(storageOptions, restored.getLanceStorageOptions());
+ }
+
+ @Test
+ public void testLanceStorageOptionsAreOptional() throws Exception {
+ TFileScanRangeParams source = new TFileScanRangeParams()
+ .setFormatType(TFileFormatType.FORMAT_LANCE);
+
+ TSerializer serializer = new TSerializer(new
TCompactProtocol.Factory());
+ byte[] bytes = serializer.serialize(source);
+
+ TFileScanRangeParams restored = new TFileScanRangeParams();
+ new TDeserializer(new
TCompactProtocol.Factory()).deserialize(restored, bytes);
+
+ // A local dataset needs no storage configuration at all.
+ Assert.assertFalse(restored.isSetLanceStorageOptions());
+ }
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceFilesystemCatalogTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceFilesystemCatalogTest.java
index 0e1a787bdda..c10de9944d3 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceFilesystemCatalogTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceFilesystemCatalogTest.java
@@ -44,24 +44,6 @@ import java.util.concurrent.atomic.AtomicReference;
public class LanceFilesystemCatalogTest {
- @Test
- public void testMinioStorageOptionMapping() {
- Map<String, String> backendProperties = new HashMap<>();
- backendProperties.put("AWS_ACCESS_KEY", "ak");
- backendProperties.put("AWS_SECRET_KEY", "sk");
- backendProperties.put("AWS_ENDPOINT", "http://minio:9000");
- backendProperties.put("AWS_REGION", "us-east-1");
- backendProperties.put("use_path_style", "true");
-
- Map<String, String> options =
LanceStorageOptions.forJavaSdk(backendProperties);
- Assert.assertEquals("ak", options.get("aws_access_key_id"));
- Assert.assertEquals("sk", options.get("aws_secret_access_key"));
- Assert.assertEquals("http://minio:9000", options.get("aws_endpoint"));
- Assert.assertEquals("us-east-1", options.get("aws_region"));
- Assert.assertEquals("true", options.get("allow_http"));
- Assert.assertEquals("false",
options.get("aws_virtual_hosted_style_request"));
- }
-
@Test
public void testNamespaceNameRoundTrip() throws Exception {
Assert.assertEquals(Collections.emptyList(),
LanceNamespaceName.dorisDatabaseNameToNamespace(
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceSnapshotTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceSnapshotTest.java
index 73842bf0ba6..20014bc04d5 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceSnapshotTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceSnapshotTest.java
@@ -78,7 +78,7 @@ public class LanceSnapshotTest {
Assertions.assertEquals(10, version10.getMetadata().getVersion());
Assertions.assertEquals(10,
version10.getMetadata().getFragments().get(0).getId());
Assertions.assertEquals("http://minio:9000",
-
version10.getMetadata().getBackendStorageOptions().get("s3.endpoint"));
+
version10.getMetadata().getLanceStorageOptions().get("aws_endpoint"));
Assertions.assertTrue(version10.isSameSnapshot(new
LanceMvccSnapshot(metadata(10,
Field.nullable("value", new ArrowType.Int(32, true))))));
Assertions.assertFalse(version10.isSameSnapshot(new
LanceMvccSnapshot(floatMetadata)));
@@ -89,6 +89,6 @@ public class LanceSnapshotTest {
return
LanceTableMetadata.withoutIndexSegments("s3://bucket/table.lance", version,
new Schema(Collections.singletonList(field)),
Collections.singletonList(new LanceFragmentInfo(version, 1,
1)),
- Collections.singletonMap("s3.endpoint", "http://minio:9000"));
+ Collections.singletonMap("aws_endpoint", "http://minio:9000"));
}
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceStorageOptionsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceStorageOptionsTest.java
new file mode 100644
index 00000000000..9e3ca4c5f4a
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LanceStorageOptionsTest.java
@@ -0,0 +1,314 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.lance;
+
+import org.apache.doris.datasource.property.storage.StorageProperties;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeSet;
+
+public class LanceStorageOptionsTest {
+
+ private static final String S3_URI = "s3://warehouse/table.lance";
+
+ /**
+ * Parsed by Doris exactly as a real catalog would, so these fixtures also
have to satisfy its
+ * rules: endpoint and region are required, and the access key and secret
key must be set
+ * together or not at all.
+ */
+ private static List<StorageProperties> createAll(Map<String, String>
properties) {
+ try {
+ return StorageProperties.createAll(properties);
+ } catch (Exception e) {
+ throw new IllegalStateException("failed to parse test storage
properties", e);
+ }
+ }
+
+ private static Map<String, String> minioProperties() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("s3.endpoint", "http://minio:9000");
+ properties.put("s3.region", "us-east-1");
+ properties.put("s3.access_key", "ak");
+ properties.put("s3.secret_key", "sk");
+ properties.put("use_path_style", "true");
+ return properties;
+ }
+
+ private static List<StorageProperties> minioCatalog() {
+ return createAll(minioProperties());
+ }
+
+ @Test
+ public void testCatalogPropertiesMapToTheCanonicalS3Spelling() {
+ Map<String, String> properties = minioProperties();
+ properties.put("s3.session_token", "token");
+
+ Map<String, String> options =
+ LanceStorageOptions.forUri(S3_URI, createAll(properties));
+ Assertions.assertEquals("ak", options.get("aws_access_key_id"));
+ Assertions.assertEquals("sk", options.get("aws_secret_access_key"));
+ Assertions.assertEquals("token", options.get("aws_session_token"));
+ Assertions.assertEquals("http://minio:9000",
options.get("aws_endpoint"));
+ Assertions.assertEquals("us-east-1", options.get("aws_region"));
+ Assertions.assertEquals("false",
options.get("aws_virtual_hosted_style_request"));
+ // Lance refuses a plain-HTTP endpoint without this.
+ Assertions.assertEquals("true", options.get("allow_http"));
+
+ Assertions.assertEquals(
+ new TreeSet<>(Arrays.asList("aws_access_key_id",
"aws_secret_access_key",
+ "aws_session_token", "aws_endpoint", "aws_region",
+ "aws_virtual_hosted_style_request", "allow_http")),
+ new TreeSet<>(options.keySet()));
+ }
+
+ /** Doris allows anonymous access; the credential keys are then absent
rather than empty. */
+ @Test
+ public void testAnonymousAccessEmitsNoCredentials() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("s3.endpoint", "https://s3.amazonaws.com");
+ properties.put("s3.region", "us-east-1");
+
+ Map<String, String> options =
+ LanceStorageOptions.forUri(S3_URI, createAll(properties));
+ Assertions.assertNull(options.get("aws_access_key_id"));
+ Assertions.assertNull(options.get("aws_secret_access_key"));
+ Assertions.assertNull(options.get("aws_session_token"));
+ // allow_http only makes sense for a plain-HTTP endpoint.
+ Assertions.assertNull(options.get("allow_http"));
+ Assertions.assertEquals("https://s3.amazonaws.com",
options.get("aws_endpoint"));
+ }
+
+ /**
+ * The case this exists for: a catalog with static credentials whose
namespace also vends them,
+ * spelled the way real servers spell them. Both must land on one key with
the namespace's
+ * value, or object_store folds them onto one config key and keeps
whichever its HashMap yields
+ * last - independently in the FE and in the BE.
+ */
+ @Test
+ public void testVendedCredentialsSupersedeTheCatalogsOnS3() {
+ Map<String, String> vended = new HashMap<>();
+ vended.put("access_key_id", "vended-ak");
+ vended.put("secret_access_key", "vended-sk");
+ vended.put("session_token", "vended-token");
+ vended.put("region", "eu-west-1");
+ vended.put("virtual_hosted_style_request", "true");
+
+ Map<String, String> merged =
+ LanceStorageOptions.forVendedTable(S3_URI, minioCatalog(),
vended);
+
+ Assertions.assertEquals("vended-ak", merged.get("aws_access_key_id"));
+ Assertions.assertEquals("vended-sk",
merged.get("aws_secret_access_key"));
+ Assertions.assertEquals("vended-token",
merged.get("aws_session_token"));
+ Assertions.assertEquals("eu-west-1", merged.get("aws_region"));
+ Assertions.assertEquals("true",
merged.get("aws_virtual_hosted_style_request"));
+ // The catalog's endpoint is untouched, and no unprefixed twin
survives anywhere.
+ Assertions.assertEquals("http://minio:9000",
merged.get("aws_endpoint"));
+ for (String alias : vended.keySet()) {
+ Assertions.assertNull(merged.get(alias), alias + " must not
survive beside its twin");
+ }
+ }
+
+ /** Every accepted spelling has to collapse, or the race just moves to the
ones missed. */
+ @Test
+ public void testEveryS3AliasCollapsesOntoOneEntry() {
+ for (String alias : new String[] {"endpoint", "endpoint_url",
"aws_endpoint",
+ "aws_endpoint_url", "ENDPOINT", "AWS_Endpoint_Url"}) {
+ Map<String, String> vended = new HashMap<>();
+ vended.put(alias, "http://127.0.0.1:9000");
+
+ Map<String, String> merged =
+ LanceStorageOptions.forVendedTable(S3_URI, minioCatalog(),
vended);
+ long endpoints = merged.keySet().stream().filter(k ->
k.contains("endpoint")).count();
+ Assertions.assertEquals(1, endpoints, alias + " left a competing
entry");
+ Assertions.assertEquals("http://127.0.0.1:9000",
merged.get("aws_endpoint"),
+ alias + " did not win");
+ }
+ }
+
+ /**
+ * {@code token} means an S3 session token to object_store's S3 parser and
a bearer token to
+ * its Azure one. Knowing the provider is what makes it resolvable at all.
+ */
+ @Test
+ public void testAmbiguousTokenResolvesOnceTheProviderIsKnown() {
+ Map<String, String> properties = minioProperties();
+ properties.put("s3.session_token", "static-token");
+ List<StorageProperties> catalog = createAll(properties);
+
+ Map<String, String> vended = new HashMap<>();
+ vended.put("token", "vended-token");
+
+ Map<String, String> onS3 = LanceStorageOptions.forVendedTable(S3_URI,
catalog, vended);
+ Assertions.assertEquals("vended-token", onS3.get("aws_session_token"));
+ Assertions.assertNull(onS3.get("token"));
+
+ Map<String, String> onAzure = LanceStorageOptions.forVendedTable(
+ "az://container/table.lance", catalog, vended);
+ Assertions.assertEquals("vended-token", onAzure.get("token"));
+ Assertions.assertNull(onAzure.get("aws_session_token"));
+ }
+
+ /**
+ * The regression that motivated all of this: object_store's Azure parser
reads
+ * {@code endpoint} but not {@code aws_endpoint}, and Lance's OSS provider
requires
+ * {@code endpoint} and reads {@code access_key_id}. Rewriting those onto
the S3 spellings
+ * leaves the dataset unreachable, so a non-S3 dataset must come through
untouched.
+ */
+ @Test
+ public void testNonS3DatasetsAreNeverRewritten() {
+ Map<String, String> vended = new HashMap<>();
+ vended.put("endpoint", "http://azurite:10000");
+ vended.put("access_key_id", "vended-ak");
+ vended.put("secret_access_key", "vended-sk");
+
+ for (String uri : new String[] {"az://container/table.lance",
"abfss://fs@acct/table",
+ "oss://bucket/table.lance", "gs://bucket/table.lance",
"cos://bucket/table",
+ "file:///tmp/table.lance"}) {
+ Map<String, String> merged =
+ LanceStorageOptions.forVendedTable(uri, minioCatalog(),
vended);
+ Assertions.assertEquals(vended, merged,
+ uri + " must reach Lance exactly as the namespace wrote
it");
+ }
+ }
+
+ /**
+ * A filesystem catalog routes on its warehouse URL, so a local warehouse
must not be handed
+ * S3 options - and a REST namespace, which is reached over HTTP and reads
no storage of its
+ * own, reports no URL at all and gets nothing.
+ */
+ @Test
+ public void testCatalogWithoutAnS3UrlGetsNoS3Options() {
+ for (String uri : new String[] {"file:///warehouse/lance", "", null}) {
+ Assertions.assertTrue(
+ LanceStorageOptions.forUri(uri, minioCatalog()).isEmpty(),
+ "expected no options for " + uri);
+ }
+ }
+
+ /** Doris has no Lance vocabulary for a non-S3 provider yet, so it
contributes none. */
+ @Test
+ public void testCatalogPropertiesAreNotAppliedToANonS3Dataset() {
+ Map<String, String> merged = LanceStorageOptions.forUri(
+ "oss://bucket/table.lance", minioCatalog());
+ Assertions.assertTrue(merged.isEmpty(), "S3 credentials must not leak
onto another provider");
+ }
+
+ /**
+ * The list is not user-ordered - it follows {@code
StorageProperties.PROVIDERS} and leads with
+ * a default HDFS entry - so the S3-compatible one has to be found by
type, not by index.
+ */
+ @Test
+ public void testSelectionSkipsNonS3CompatibleEntries() {
+ List<StorageProperties> catalog = minioCatalog();
+ Assertions.assertTrue(catalog.size() > 1,
+ "expected Doris to add its default non-S3 entry ahead of the
S3 one");
+ Assertions.assertEquals("ak",
+ LanceStorageOptions.forUri(S3_URI,
catalog).get("aws_access_key_id"));
+ }
+
+ @Test
+ public void testUnknownVendedOptionsArePassedThroughOnS3Too() {
+ Map<String, String> vended = new HashMap<>();
+ // Lance reads this on its namespace-backed refresh path; Doris
assigns it no meaning.
+ vended.put("expires_at_millis", "1760000000000");
+ vended.put("azure_storage_sas_token", "sas");
+ // Empty is expressible as a C string, so it is carried rather than
second-guessed.
+ vended.put("deliberately_empty", "");
+
+ Map<String, String> merged =
+ LanceStorageOptions.forVendedTable(S3_URI,
Collections.emptyList(), vended);
+ Assertions.assertEquals(vended, merged);
+ }
+
+ @Test
+ public void testAbsentVendedOptionsLeaveCatalogOptionsIntact() {
+ Map<String, String> catalogOptions =
+ LanceStorageOptions.forUri(S3_URI, minioCatalog());
+ Assertions.assertEquals(catalogOptions,
+ LanceStorageOptions.forUri(S3_URI, minioCatalog()));
+ Assertions.assertEquals(catalogOptions,
+ LanceStorageOptions.forVendedTable(S3_URI, minioCatalog(), new
HashMap<>()));
+ }
+
+ /** A namespace contradicting itself is not something to resolve by coin
toss. */
+ @Test
+ public void testConflictingSpellingsOfOneOptionAreRejected() {
+ Map<String, String> vended = new HashMap<>();
+ vended.put("access_key_id", "one");
+ vended.put("aws_access_key_id", "another");
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> LanceStorageOptions.forVendedTable(S3_URI,
minioCatalog(), vended));
+
+ // Agreeing on the value is not a conflict.
+ Map<String, String> agreeing = new HashMap<>();
+ agreeing.put("access_key_id", "same");
+ agreeing.put("aws_access_key_id", "same");
+ Assertions.assertEquals("same", LanceStorageOptions
+ .forVendedTable(S3_URI, minioCatalog(),
agreeing).get("aws_access_key_id"));
+ }
+
+ /**
+ * The transport boundary is not specific to what a namespace vends:
Doris's own configuration
+ * reaches Lance as C strings too, so a NUL there has to fail on the same
terms.
+ */
+ @Test
+ public void testCatalogConfigurationWithEmbeddedNulIsRejected() {
+ Map<String, String> properties = minioProperties();
+ properties.put("s3.access_key", "ak\0ignored");
+
+ IllegalArgumentException thrown =
Assertions.assertThrows(IllegalArgumentException.class,
+ () -> LanceStorageOptions.forUri(S3_URI,
createAll(properties)));
+ // The message has to say which side to fix - the catalog, not the
namespace.
+ Assertions.assertTrue(thrown.getMessage().contains("Doris storage
configuration"),
+ "unexpected message: " + thrown.getMessage());
+ }
+
+ /**
+ * lance-c reads these as C strings, so a NUL truncates the option there
while the FE keeps
+ * reading the whole key. Dropping it would only move the divergence, so
it has to fail.
+ */
+ @Test
+ public void testOptionsThatCannotReachTheBackendAreRejected() {
+ Map<String, String> withNulKey = new HashMap<>();
+ withNulKey.put("bucket\0ignored", "other-bucket");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
LanceStorageOptions
+ .forVendedTable(S3_URI, Collections.emptyList(), withNulKey));
+
+ Map<String, String> withNulValue = new HashMap<>();
+ withNulValue.put("aws_region", "us-east-1\0ignored");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
LanceStorageOptions
+ .forVendedTable(S3_URI, Collections.emptyList(),
withNulValue));
+
+ Map<String, String> withNullValue = new HashMap<>();
+ withNullValue.put("aws_region", null);
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
LanceStorageOptions
+ .forVendedTable(S3_URI, Collections.emptyList(),
withNullValue));
+
+ Map<String, String> withNullKey = new HashMap<>();
+ withNullKey.put(null, "value");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
LanceStorageOptions
+ .forVendedTable(S3_URI, Collections.emptyList(), withNullKey));
+ }
+}
diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift
index 93273ea75a3..f0c26fa1175 100644
--- a/gensrc/thrift/PlanNodes.thrift
+++ b/gensrc/thrift/PlanNodes.thrift
@@ -639,6 +639,15 @@ struct TFileScanRangeParams {
// query. Lance vector search uses one range per fragment and Doris merges
the split-local
// candidates.
38: optional TExternalSearchRequest external_search_request
+ // Lance-native storage options, handed to lance-c untranslated. The
namespace protocol treats
+ // storage_options as opaque configuration passed directly to Lance, so
any key vocabulary the
+ // BE imposed here would drop options it does not happen to know -
including credentials a
+ // namespace spelled with a different accepted alias, and every non-S3
provider's keys.
+ // Set at ScanNode level so credentials are not serialized once per
fragment split.
+ // These are the initial options for the scan and are never refreshed:
lance-c opens datasets
+ // with a static option set, so credentials that expire mid-scan are not
re-vended. Renewal
+ // needs a refresh channel of its own, which this field is not.
+ 39: optional map<string, string> lance_storage_options
}
struct TFileRangeDesc {
diff --git
a/regression-test/data/external_table_p0/lance/test_lance_rest_catalog.out
b/regression-test/data/external_table_p0/lance/test_lance_rest_catalog.out
index 62a570c8d16..cb8003936c2 100644
--- a/regression-test/data/external_table_p0/lance/test_lance_rest_catalog.out
+++ b/regression-test/data/external_table_p0/lance/test_lance_rest_catalog.out
@@ -6,10 +6,14 @@ mysql
-- !rest_tables --
all_types
+all_types_unprefixed
-- !rest_scan --
12 12 1 12 78
+-- !rest_scan_unprefixed_credentials --
+12 12 1 12 78
+
-- !rest_predicate_pushdown --
8
9
diff --git
a/regression-test/suites/external_table_p0/lance/test_lance_rest_catalog.groovy
b/regression-test/suites/external_table_p0/lance/test_lance_rest_catalog.groovy
index 82cd85858f8..75f2c100793 100644
---
a/regression-test/suites/external_table_p0/lance/test_lance_rest_catalog.groovy
+++
b/regression-test/suites/external_table_p0/lance/test_lance_rest_catalog.groovy
@@ -70,6 +70,14 @@ suite("test_lance_rest_catalog", "p0,external") {
FROM `${catalogName}`.`default`.`${tableName}`
"""
+ // The same dataset, described by a namespace that spells the vended
credentials without
+ // the aws_ prefix. Lance accepts either alias, so both have to reach
the BE; a client that
+ // recognizes only one silently scans with no credentials at all.
+ qt_rest_scan_unprefixed_credentials """
+ SELECT count(*), count(DISTINCT row_id), min(row_id), max(row_id),
sum(row_id)
+ FROM `${catalogName}`.`default`.`all_types_unprefixed`
+ """
+
String pushedQuery =
"""SELECT row_id FROM
`${catalogName}`.`default`.`${tableName}` WHERE int32_col = 10 ORDER BY
row_id"""
explain {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]