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 a00f8c59e6e [feature](lance) Support OSS storage provider (#67157)
a00f8c59e6e is described below
commit a00f8c59e6ed91a71ae957ce35092a7e2e1f93bf
Author: FANNG <[email protected]>
AuthorDate: Mon Sep 7 10:55:13 2026 +0800
[feature](lance) Support OSS storage provider (#67157)
### What problem does this PR solve?
Issue Number: close #67131
Related PR: #66805
Problem Summary:
Lance catalogs already forward provider-native storage options, but
Doris did not translate its typed OSS properties, and filesystem
catalogs rejected `oss://` warehouses.
This change:
- maps Doris OSS properties to Lance's public OSS options, including
anonymous access and addressing style;
- normalizes Namespace-vended aliases so the two spellings of one option
cannot reach Lance as competing entries, rejecting conflicting values
and passing unknown options through untouched;
- reads a vended option the way the store will: aliases are matched
case-insensitively, since OpenDAL lower-cases every config key before
deserializing, and the anonymous flag is parsed with OpenDAL's own
boolean grammar (`true|on` / `false|off`) rather than Java's, so a
vended `ACCESS_KEY_ID` or `allow_anonymous=on` cannot end up with a
credential configured and every request sent unsigned;
- separates translation from inference, so options derived from another
option - `allow_http` from the effective S3 endpoint, the OSS anonymous
flag from the effective credential - are computed once from the final
merged configuration rather than from the catalog's half alone;
- accepts `oss://` filesystem warehouses, requiring one that names a
bucket, and refuses OSS-HDFS - recognized with the same predicate Doris
routes on - since Doris reads that form through its HDFS-compatible
properties, which carry no Lance OSS storage options; and
- redacts OSS credentials from every provider-facing failure.
It reuses the FE-to-BE storage-option transport introduced by #66805 and
changes no Thrift or BE code.
### Release note
Support native Alibaba Cloud OSS storage options for Lance catalogs.
Two derived options now follow the final merged configuration instead of
the catalog's half alone. For S3, `allow_http` is decided by the
endpoint actually in effect, so a namespace that vends a plain-HTTP
endpoint no longer fails to reach it, and one that vends an HTTPS
endpoint no longer carries a stale permission. For OSS, a dataset the
catalog holds no storage configuration for is now explicitly marked
anonymous rather than leaving that key absent; a deployment that relied
on an exported `OSS_ALLOW_ANONYMOUS` to decide it for such a catalog
should configure the catalog instead.
### Check List (For Author)
- Test:
- Unit Test: Official Doris build image `run-fe-ut.sh`; locally 94 tests
across the Lance and storage-property suites.
- Manual test: End-to-end scans against a real Alibaba Cloud OSS bucket
on a native arm64 cluster. Every case returns `count(*) = 4, sum(amount)
= 100` with matching full rows, type mapping (`bigint`/`text`/`int`) and
predicate pushdown:
- **dir namespace** (`lance.catalog.type=filesystem` →
`LanceNamespace.connect("dir")`) over an `oss://` warehouse with Doris
OSS properties;
- **REST namespace** against a real **Apache Gravitino 1.3.0**
`lance-rest` service with **no static OSS credentials on the Doris
catalog at all** — Gravitino vends the bare OSS-native spellings
`endpoint` / `access_key_id` / `access_key_secret` / `region`, which
this PR normalizes before handing them to the BE, so the scan succeeding
is what shows the vended path works;
- a warehouse with no bucket (`oss:/path`) is rejected at `CREATE
CATALOG`, and the qualified `oss://bucket.<endpoint>/path` form is
passed to the engine as written rather than rewritten - the store then
reports the host it actually tried, `bucket.<endpoint>.<endpoint>`;
- **STS temporary credentials**: an `AssumeRole` triple scans, and the
same temporary key pair *without* `oss.session_token` fails with OSS's
own `InvalidAccessKeyId` / "The Security Token may be lost to specify
that it is a STS Access Id" — the negative control that shows the token
reaches the BE rather than the scan succeeding by another route;
- **anonymous flag vs. vended credentials**: a catalog with
`oss.endpoint`/`oss.region` but no key pair reads as anonymous on its
own, and still scans once Gravitino vends a credential. This is the case
the plain Gravitino test cannot reach, because a catalog with no `oss.*`
at all never builds an OSSProperties and so never claims anonymity.
Two more negative controls: a Gravitino catalog vending both `endpoint`
and `oss_endpoint` with different values is rejected; a vended half key
pair is rejected rather than silently paired with the catalog's other
half.
Query profiles confirm the scans ran on the BE, not the FE: splits
assigned to the backend and `FILE_SCAN_OPERATOR(table_name=all_types)`
reporting `RowsProduced: sum 4`. The BE is a stock arm64 build of the
same `branch-4.1` base — this PR changes no BE code, and `gensrc/thrift`
and `gensrc/proto` are byte-identical between the two trees.
- Build: Full FE build and Checkstyle passed.
- Behavior changed: Yes. Lance catalogs can use `oss://` with Doris OSS
properties and with OSS options vended by a Namespace.
- Does this need documentation: Yes — apache/doris-website#4094 adds an
OSS section to the lance-catalog page, in both the English and zh-CN
copies.
### Scope
Not included: role-based credential vending, STS credential refresh
during a scan, a new Thrift envelope, or static support for other
object-store providers.
Namespace-vended options overlay the catalog's key by key, so a
namespace is expected to vend a complete credential rather than part of
one. An earlier revision of this PR replaced the credential as a unit;
it was removed as more machinery than the cases it guarded against
warranted. Where a vended anonymous flag meets a configured credential
the two are reported as a conflict rather than one side quietly winning.
`LanceS3StorageProvider` is left alone. Its comparable defects predate
this work (#66805) and are better addressed for all providers at once
than widened into an OSS change.
Anonymous OSS access itself is covered by unit tests only — how the flag
is inferred is exercised end to end, but issuing a genuinely unsigned
read needs an anonymously readable bucket, which I do not have. Its
correctness rests on lance forwarding unrecognized options to OpenDAL
plus OpenDAL's OSS config accepting the flag (`OssCore::sign` returns
the request unsigned whenever it is set).
---
.../datasource/lance/LanceExternalCatalog.java | 12 +-
.../datasource/lance/LanceMetadataLoader.java | 2 +-
.../datasource/lance/LanceOssStorageProvider.java | 185 ++++++++
.../lance/LancePassThroughStorageProvider.java | 22 +-
.../datasource/lance/LanceS3StorageProvider.java | 26 +-
.../datasource/lance/LanceStorageOptions.java | 42 +-
.../datasource/lance/LanceStorageProvider.java | 31 +-
.../LanceFileSystemMetastoreProperties.java | 35 +-
.../doris/datasource/tvf/source/TVFScanNode.java | 2 +-
.../ExternalFileTableValuedFunction.java | 2 +-
.../lance/LanceFilesystemCatalogTest.java | 12 +-
.../datasource/lance/LanceStorageOptionsTest.java | 504 +++++++++++++++++++--
.../property/metastore/LancePropertiesTest.java | 46 ++
13 files changed, 842 insertions(+), 79 deletions(-)
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 cef2b064b98..4a4a8111287 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
@@ -78,7 +78,11 @@ public class LanceExternalCatalog extends ExternalCatalog {
private static final long ALLOCATOR_LIMIT = 256L * 1024 * 1024;
private static final int MAX_PROVIDER_MESSAGE_BYTES = 1024;
private static final String[] RUNTIME_SENSITIVE_OPTION_KEYS = {
- "aws_access_key_id", "aws_secret_access_key", "aws_session_token"
+ "aws_access_key_id", "aws_secret_access_key", "aws_session_token",
+ // OSS credentials only reach these options because of this
change, so they have to be
+ // recognized here too. The emitted spelling is the only one that
occurs: the map read
+ // below is the merged one, where a vended alias has already been
normalized onto it.
+ "oss_access_key_id", "oss_secret_access_key", "oss_security_token"
};
private transient LanceNamespace namespace;
@@ -104,7 +108,7 @@ public class LanceExternalCatalog extends ExternalCatalog {
rootDatabase = properties.getRootDatabase();
parentNamespace = LanceNamespaceName.parseParentNamespace(
properties.getNamespaceParent(),
properties.getNamespaceDelimiter());
- namespaceStorageOptions = LanceStorageOptions.forUri(
+ namespaceStorageOptions =
LanceStorageOptions.fromDorisStorageProperties(
properties.getNamespaceStorageUri(),
catalogProperty.getOrderedStoragePropertiesList());
@@ -127,7 +131,7 @@ public class LanceExternalCatalog extends ExternalCatalog {
}
AbstractLanceProperties properties = getLanceProperties();
- Map<String, String> storageOptions = LanceStorageOptions.forUri(
+ Map<String, String> storageOptions =
LanceStorageOptions.fromDorisStorageProperties(
properties.getNamespaceStorageUri(),
catalogProperty.getOrderedStoragePropertiesList());
List<String> parent = LanceNamespaceName.parseParentNamespace(
@@ -434,7 +438,7 @@ public class LanceExternalCatalog extends ExternalCatalog {
// 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,
+ Map<String, String> storageOptions =
LanceStorageOptions.fromDorisAndVendedStorageOptions(datasetUri,
catalogProperty.getOrderedStoragePropertiesList(),
table.getStorageOptions());
return new ResolvedTableAccess(datasetUri, 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 9c4803af710..38c32c3de35 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
@@ -54,7 +54,7 @@ public final class LanceMetadataLoader {
throws Exception {
try (BufferAllocator allocator = new RootAllocator(ALLOCATOR_LIMIT)) {
return loadLatest(datasetUri,
- LanceStorageOptions.forUri(datasetUri, storageProperties),
allocator);
+ LanceStorageOptions.fromDorisStorageProperties(datasetUri,
storageProperties), allocator);
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceOssStorageProvider.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceOssStorageProvider.java
new file mode 100644
index 00000000000..d51f9765bfd
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceOssStorageProvider.java
@@ -0,0 +1,185 @@
+// 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.OSSProperties;
+import org.apache.doris.datasource.property.storage.StorageProperties;
+
+import com.google.common.collect.ImmutableMap;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/** Alibaba Cloud OSS storage, which Lance reaches through its OpenDAL OSS
provider. */
+final class LanceOssStorageProvider implements LanceStorageProvider {
+
+ static final LanceOssStorageProvider INSTANCE = new
LanceOssStorageProvider();
+
+ private static final String ENDPOINT = "oss_endpoint";
+ private static final String ACCESS_KEY_ID = "oss_access_key_id";
+ private static final String SECRET_ACCESS_KEY = "oss_secret_access_key";
+ private static final String REGION = "oss_region";
+ private static final String SECURITY_TOKEN = "oss_security_token";
+ private static final String ADDRESSING_STYLE = "addressing_style";
+ private static final String SKIP_SIGNATURE = "skip_signature";
+ private static final String ALLOW_ANONYMOUS = "allow_anonymous";
+
+ /**
+ * Lance exposes the {@code oss_*} names as its public storage-option
vocabulary and normalizes
+ * them to OpenDAL's field names before constructing the operator. Both
spellings are accepted,
+ * so collapse only these known pairs before merging static and
namespace-vended options.
+ */
+ private static final Map<String, String> PUBLIC_BY_ALIAS =
ImmutableMap.<String, String>builder()
+ .put("endpoint", ENDPOINT)
+ .put(ENDPOINT, ENDPOINT)
+ .put("access_key_id", ACCESS_KEY_ID)
+ .put(ACCESS_KEY_ID, ACCESS_KEY_ID)
+ .put("access_key_secret", SECRET_ACCESS_KEY)
+ .put(SECRET_ACCESS_KEY, SECRET_ACCESS_KEY)
+ .put("region", REGION)
+ .put(REGION, REGION)
+ .put("security_token", SECURITY_TOKEN)
+ .put(SECURITY_TOKEN, SECURITY_TOKEN)
+ // OpenDAL 0.57 renamed this option to skip_signature, but lance-c
0.1.6 still uses
+ // OpenDAL 0.56. Emit the old spelling, which newer OpenDAL keeps
as an alias.
+ .put(SKIP_SIGNATURE, ALLOW_ANONYMOUS)
+ .put(ALLOW_ANONYMOUS, ALLOW_ANONYMOUS)
+ .build();
+
+ private LanceOssStorageProvider() {
+ }
+
+ @Override
+ public Map<String, String> normalizeDorisStorageOptions(
+ List<StorageProperties> storageProperties) {
+ Map<String, String> result = new HashMap<>();
+ OSSProperties properties = selectOss(storageProperties);
+ if (properties == null) {
+ return result;
+ }
+ putIfNotEmpty(result, ENDPOINT, properties.getEndpoint());
+ putIfNotEmpty(result, REGION, properties.getRegion());
+ putIfNotEmpty(result, ACCESS_KEY_ID, properties.getAccessKey());
+ putIfNotEmpty(result, SECRET_ACCESS_KEY, properties.getSecretKey());
+ putIfNotEmpty(result, SECURITY_TOKEN, properties.getSessionToken());
+
+ // Lance snapshots the host's OSS_*/AWS_*/ALIBABA_CLOUD_* environment
into the same config
+ // map before storage options are applied, so state both addressing
styles explicitly the
+ // way the S3 provider does. Leaving the default implicit would let an
exported
+ // OSS_ADDRESSING_STYLE outrank an explicit oss.use_path_style=false.
+ String usePathStyle = properties.getUsePathStyle();
+ if (StringUtils.isNotEmpty(usePathStyle)) {
+ result.put(ADDRESSING_STYLE, Boolean.parseBoolean(usePathStyle) ?
"path" : "virtual");
+ }
+ return result;
+ }
+
+ @Override
+ public Map<String, String> normalizeVendedStorageOptions(
+ Map<String, String> vendedOptions) {
+ Map<String, String> result = new HashMap<>();
+ if (vendedOptions == null) {
+ return result;
+ }
+ vendedOptions.forEach((key, value) -> {
+ // Look the alias up in lower case, the way the S3 adapter does.
OpenDAL lower-cases
+ // every key before deserializing its config, so a vended
ACCESS_KEY_ID reaches the
+ // store as a credential either way - but left unrecognized here
it would not count as
+ // signing configuration, and the anonymous flag would be inferred
as true beside it.
+ // An unrecognized key keeps its original spelling: only the store
knows what it means.
+ String publicKey =
PUBLIC_BY_ALIAS.getOrDefault(key.toLowerCase(Locale.ROOT), key);
+ String previous = result.put(publicKey, value);
+ if (previous != null && !previous.equals(value)) {
+ throw new IllegalArgumentException(
+ "Lance namespace vended conflicting values for storage
option '"
+ + publicKey + "'");
+ }
+ });
+ return result;
+ }
+
+ @Override
+ public Map<String, String> inferStorageOptions(Map<String, String>
effectiveOptions) {
+ Map<String, String> inferred = new HashMap<>();
+ boolean hasSigningConfiguration =
hasSigningConfiguration(effectiveOptions);
+ String allowAnonymous = effectiveOptions.get(ALLOW_ANONYMOUS);
+ if (allowAnonymous != null) {
+ Boolean anonymous = parseOpenDalBoolean(allowAnonymous);
+ if (anonymous == null) {
+ throw new IllegalArgumentException("Unrecognized value for OSS
storage option '"
+ + ALLOW_ANONYMOUS + "': '" + allowAnonymous
+ + "'. Expected one of true, on, false, off");
+ }
+ if (anonymous && hasSigningConfiguration) {
+ throw new IllegalArgumentException(
+ "Conflicting OSS authentication: anonymous access is
enabled but signing "
+ + "credentials are also configured");
+ }
+ return inferred;
+ }
+ inferred.put(ALLOW_ANONYMOUS,
String.valueOf(!hasSigningConfiguration));
+ return inferred;
+ }
+
+ /**
+ * Reads a flag the way the store will. OpenDAL deserializes its config
with a boolean grammar
+ * of its own - {@code true|on} and {@code false|off}, anything else
refused - so judging a
+ * vended value by Java's rules would let {@code on} through as "not
anonymous" while the store
+ * reads it as anonymous and stops signing. Null for a value OpenDAL would
reject, so it can be
+ * refused here rather than deep inside the operator build.
+ *
+ * <p>See {@code opendal-core/src/raw/serde_util.rs}, {@code
Pair::deserialize_bool}.
+ */
+ private static Boolean parseOpenDalBoolean(String value) {
+ switch (value.toLowerCase(Locale.ROOT)) {
+ case "true":
+ case "on":
+ return Boolean.TRUE;
+ case "false":
+ case "off":
+ return Boolean.FALSE;
+ default:
+ return null;
+ }
+ }
+
+ private static boolean hasSigningConfiguration(Map<String, String>
options) {
+ return StringUtils.isNotEmpty(options.get(ACCESS_KEY_ID));
+ }
+
+ private static OSSProperties selectOss(List<StorageProperties>
storageProperties) {
+ if (storageProperties == null) {
+ return null;
+ }
+ for (StorageProperties candidate : storageProperties) {
+ if (candidate instanceof OSSProperties) {
+ return (OSSProperties) candidate;
+ }
+ }
+ return null;
+ }
+
+ 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/LancePassThroughStorageProvider.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LancePassThroughStorageProvider.java
index 78cc69ee9be..f17f7673f28 100644
---
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
@@ -25,17 +25,16 @@ import java.util.List;
import java.util.Map;
/**
- * Every provider Lance routes somewhere other than S3 - Azure, GCS, OSS,
Tencent COS, local files,
+ * Every provider Lance routes somewhere other than S3 or OSS - Azure, GCS,
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
+ * namespace vends. Those options are the namespace's to name: object_store's
Azure parser reads
+ * {@code endpoint} and takes {@code token} as a bearer token. Rewriting
either 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
+ * <p>{@link #normalizeDorisStorageOptions} is empty for want of a
translation, not for want of
+ * input: Doris does model these - {@code COSProperties} carries 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.
@@ -48,12 +47,19 @@ final class LancePassThroughStorageProvider implements
LanceStorageProvider {
}
@Override
- public Map<String, String> fromDorisProperties(List<StorageProperties>
storageProperties) {
+ public Map<String, String> normalizeDorisStorageOptions(
+ List<StorageProperties> storageProperties) {
return Collections.emptyMap();
}
@Override
- public Map<String, String> normalizeVended(Map<String, String>
vendedOptions) {
+ public Map<String, String> normalizeVendedStorageOptions(
+ Map<String, String> vendedOptions) {
return vendedOptions == null ? new HashMap<>() : new
HashMap<>(vendedOptions);
}
+
+ @Override
+ public Map<String, String> inferStorageOptions(Map<String, String>
effectiveOptions) {
+ return Collections.emptyMap();
+ }
}
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
index eb723a0201f..91d011be156 100644
---
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
@@ -108,7 +108,8 @@ final class LanceS3StorageProvider implements
LanceStorageProvider {
}
@Override
- public Map<String, String> fromDorisProperties(List<StorageProperties>
storageProperties) {
+ public Map<String, String> normalizeDorisStorageOptions(
+ List<StorageProperties> storageProperties) {
Map<String, String> result = new HashMap<>();
AbstractS3CompatibleProperties properties =
selectS3Compatible(storageProperties);
if (properties == null) {
@@ -125,12 +126,6 @@ final class LanceS3StorageProvider implements
LanceStorageProvider {
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;
}
@@ -166,7 +161,8 @@ final class LanceS3StorageProvider implements
LanceStorageProvider {
}
@Override
- public Map<String, String> normalizeVended(Map<String, String>
vendedOptions) {
+ public Map<String, String> normalizeVendedStorageOptions(
+ Map<String, String> vendedOptions) {
Map<String, String> result = new HashMap<>();
if (vendedOptions == null) {
return result;
@@ -185,6 +181,20 @@ final class LanceS3StorageProvider implements
LanceStorageProvider {
return result;
}
+ @Override
+ public Map<String, String> inferStorageOptions(Map<String, String>
effectiveOptions) {
+ Map<String, String> inferred = new HashMap<>();
+ if (effectiveOptions.containsKey(ALLOW_HTTP)) {
+ return inferred;
+ }
+ String endpoint = effectiveOptions.get(ENDPOINT);
+ if (endpoint != null && endpoint.startsWith("http://")) {
+ // object_store rejects a plain-HTTP endpoint unless this client
option is enabled.
+ inferred.put(ALLOW_HTTP, "true");
+ }
+ return inferred;
+ }
+
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 65d9ba2a8a7..c13acc8c5ea 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
@@ -46,12 +46,9 @@ public final class LanceStorageOptions {
* <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;
+ public static Map<String, String> fromDorisStorageProperties(
+ String uri, List<StorageProperties> storageProperties) {
+ return buildStorageOptions(uri, storageProperties, null);
}
/**
@@ -63,19 +60,32 @@ public final class LanceStorageOptions {
* 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}.
+ * vending storage options is ordinary, and this then degenerates to
+ * {@link #fromDorisStorageProperties}.
*/
- public static Map<String, String> forVendedTable(String datasetUri,
+ public static Map<String, String> fromDorisAndVendedStorageOptions(String
datasetUri,
List<StorageProperties> storageProperties, Map<String, String>
vendedOptions) {
- Map<String, String> result = forUri(datasetUri, storageProperties);
- if (vendedOptions == null || vendedOptions.isEmpty()) {
- return result;
+ return buildStorageOptions(datasetUri, storageProperties,
vendedOptions);
+ }
+
+ private static Map<String, String> buildStorageOptions(String datasetUri,
+ List<StorageProperties> storageProperties, Map<String, String>
vendedOptions) {
+ LanceStorageProvider provider =
LanceStorageProvider.forDataset(datasetUri);
+ Map<String, String> result = new HashMap<>(
+ provider.normalizeDorisStorageOptions(storageProperties));
+ result.forEach((key, value) -> rejectUntransportable(key, value,
+ "Doris storage configuration"));
+ Map<String, String> normalizedVended = new HashMap<>();
+ if (vendedOptions != null && !vendedOptions.isEmpty()) {
+ vendedOptions.forEach(LanceStorageOptions::validateVendedOption);
+ // Safe to validate before normalizing: normalization only renames
a key to a provider
+ // constant or passes it through, so it cannot introduce a NUL
missed above.
+ normalizedVended =
provider.normalizeVendedStorageOptions(vendedOptions);
}
- 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));
+ result.putAll(normalizedVended);
+ provider.inferStorageOptions(result).forEach(result::putIfAbsent);
+ result.forEach((key, value) -> rejectUntransportable(key, value,
+ "Lance storage configuration"));
return result;
}
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
index 9b980351af1..b5edfcf5587 100644
---
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
@@ -52,26 +52,41 @@ public interface LanceStorageProvider {
* 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.
+ * <p>Empty when the list holds nothing this provider can read. Providers
without a Doris
+ * adapter are reachable only through what a namespace vends.
*/
- Map<String, String> fromDorisProperties(List<StorageProperties>
storageProperties);
+ Map<String, String> normalizeDorisStorageOptions(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.
+ * Rewrites the options a namespace vended onto the spelling
+ * {@link #normalizeDorisStorageOptions} 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);
+ Map<String, String> normalizeVendedStorageOptions(Map<String, String>
vendedOptions);
+
+ /**
+ * Derives provider options from the fully normalized and merged option
map.
+ *
+ * <p>The returned entries are defaults: an explicitly supplied option
always wins. Inference
+ * therefore runs only after catalog and namespace options have been
reconciled.
+ */
+ Map<String, String> inferStorageOptions(Map<String, String>
effectiveOptions);
/** The provider Lance will route this dataset to. */
static LanceStorageProvider forDataset(String datasetUri) {
- return S3_SCHEMES.contains(schemeOf(datasetUri))
- ? LanceS3StorageProvider.INSTANCE :
LancePassThroughStorageProvider.INSTANCE;
+ String scheme = schemeOf(datasetUri);
+ if (S3_SCHEMES.contains(scheme)) {
+ return LanceS3StorageProvider.INSTANCE;
+ }
+ if ("oss".equals(scheme)) {
+ return LanceOssStorageProvider.INSTANCE;
+ }
+ return LancePassThroughStorageProvider.INSTANCE;
}
static String schemeOf(String datasetUri) {
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 19aa4afef1f..0bbb1548906 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
@@ -17,6 +17,7 @@
package org.apache.doris.datasource.property.metastore;
+import org.apache.doris.datasource.property.storage.OSSHdfsProperties;
import org.apache.doris.foundation.property.ConnectorProperty;
import org.apache.arrow.memory.BufferAllocator;
@@ -37,7 +38,7 @@ public class LanceFileSystemMetastoreProperties extends
AbstractLanceProperties
@ConnectorProperty(
names = {WAREHOUSE},
required = false,
- description = "The local, file, or S3 warehouse containing Lance
datasets."
+ description = "The local, file, S3, or OSS warehouse containing
Lance datasets."
)
private String warehouse;
@@ -77,6 +78,7 @@ public class LanceFileSystemMetastoreProperties extends
AbstractLanceProperties
throw new IllegalArgumentException(
"Missing required property 'warehouse' for Lance
filesystem catalog");
}
+ rejectOssHdfs();
validateWarehouse(warehouse);
for (String key : origProps.keySet()) {
if (key.startsWith("lance.rest.")) {
@@ -102,9 +104,36 @@ public class LanceFileSystemMetastoreProperties extends
AbstractLanceProperties
return;
}
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
- if (!"file".equals(scheme) && !"s3".equals(scheme)) {
+ if (!"file".equals(scheme) && !"s3".equals(scheme) &&
!"oss".equals(scheme)) {
throw new IllegalArgumentException("Unsupported Lance filesystem
warehouse scheme '" + scheme
- + "'; first phase supports local/file and s3");
+ + "'; supported schemes are local/file, s3, and oss");
+ }
+ // An object-store root names its bucket in the authority. Lance reads
that authority as the
+ // bucket and fails deep inside the store when it is absent, so reject
the no-authority form
+ // here where the message can still name the property.
+ if (("s3".equals(scheme) || "oss".equals(scheme)) &&
StringUtils.isBlank(uri.getAuthority())) {
+ throw new IllegalArgumentException(
+ "Lance " + scheme + " warehouse must name a bucket, as in
" + scheme
+ + "://bucket/path, but was: " + warehouse);
}
}
+
+ /**
+ * Doris routes an OSS-HDFS configuration to {@code OSSHdfsProperties},
which the Lance OSS
+ * provider cannot read - it accepts only {@code OSSProperties} - so the
namespace would be
+ * handed no endpoint and no credentials and could not open at all.
+ *
+ * <p>Checks every property rather than only the warehouse: {@code
OSSHdfsProperties.guessIsMe}
+ * selects on the endpoint, so {@code oss://bucket/path} with an {@code
oss.endpoint} ending in
+ * the OSS-HDFS suffix routes there just the same, with a clean-looking
warehouse.
+ */
+ private void rejectOssHdfs() {
+ if (OSSHdfsProperties.guessIsMe(origProps)) {
+ throw new IllegalArgumentException(
+ "OSS-HDFS is not supported by the Lance catalog. Doris
reads this form through "
+ + "its HDFS-compatible properties, which carry no
Lance OSS storage "
+ + "options.");
+ }
+ }
+
}
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 f7e0e86c5b0..99da0093513 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
@@ -133,7 +133,7 @@ public class TVFScanNode extends FileQueryScanNode {
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(
+ Map<String, String> lanceStorageOptions =
LanceStorageOptions.fromDorisStorageProperties(
tableValuedFunction.getFilePath(),
Collections.singletonList(tableValuedFunction.getStorageProperties()));
if (!lanceStorageOptions.isEmpty()) {
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 8955deba76f..120156b54c7 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
@@ -539,7 +539,7 @@ public abstract class ExternalFileTableValuedFunction
extends TableValuedFunctio
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(
+ Map<String, String> lanceStorageOptions =
LanceStorageOptions.fromDorisStorageProperties(
filePath, Collections.singletonList(storageProperties));
if (!lanceStorageOptions.isEmpty()) {
fileScanRangeParams.setLanceScanParams(
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 df008abe564..95acd580b22 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
@@ -146,6 +146,9 @@ public class LanceFilesystemCatalogTest {
String accessKey = "sentinel-access-key";
String secretKey = "sentinel-secret-key";
String sessionToken = "sentinel-session-token";
+ String ossAccessKey = "sentinel-oss-access-key";
+ String ossSecretKey = "sentinel-oss-secret-key";
+ String ossSecurityToken = "sentinel-oss-security-token";
String datasetUri =
"s3://sentinel-user:sentinel-password@bucket/private/table.lance";
Map<String, String> catalogProperties = new HashMap<>();
@@ -158,9 +161,14 @@ public class LanceFilesystemCatalogTest {
runtimeStorageOptions.put("aws_access_key_id", accessKey);
runtimeStorageOptions.put("aws_secret_access_key", secretKey);
runtimeStorageOptions.put("aws_session_token", sessionToken);
+ runtimeStorageOptions.put("oss_access_key_id", ossAccessKey);
+ runtimeStorageOptions.put("oss_secret_access_key", ossSecretKey);
+ runtimeStorageOptions.put("oss_security_token", ossSecurityToken);
String providerMessage = "provider failure\nuri=" + datasetUri
+ " bearer=" + bearerToken + " api-key=" + apiKey
- + " access=" + accessKey + " secret=" + secretKey + "
session=" + sessionToken;
+ + " access=" + accessKey + " secret=" + secretKey + "
session=" + sessionToken
+ + " oss-access=" + ossAccessKey + " oss-secret=" + ossSecretKey
+ + " oss-token=" + ossSecurityToken;
RuntimeException providerFailure = new
RuntimeException(providerMessage);
RuntimeException exposed = catalog.indexMetadataLoadFailure(
@@ -169,7 +177,7 @@ public class LanceFilesystemCatalogTest {
exposed.printStackTrace(new PrintWriter(stackTrace));
for (String sentinel : Arrays.asList(bearerToken, apiKey, accessKey,
secretKey,
- sessionToken, datasetUri)) {
+ sessionToken, ossAccessKey, ossSecretKey, ossSecurityToken,
datasetUri)) {
Assert.assertFalse(exposed.getMessage().contains(sentinel));
Assert.assertFalse(exposed.getCause().getMessage().contains(sentinel));
Assert.assertFalse(stackTrace.toString().contains(sentinel));
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
index 9e3ca4c5f4a..be25f115ae9 100644
---
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
@@ -32,6 +32,7 @@ import java.util.TreeSet;
public class LanceStorageOptionsTest {
private static final String S3_URI = "s3://warehouse/table.lance";
+ private static final String OSS_URI = "oss://warehouse/table.lance";
/**
* Parsed by Doris exactly as a real catalog would, so these fixtures also
have to satisfy its
@@ -60,13 +61,23 @@ public class LanceStorageOptionsTest {
return createAll(minioProperties());
}
+ private static Map<String, String> ossProperties() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("fs.oss.support", "true");
+ properties.put("oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com");
+ properties.put("oss.region", "cn-hangzhou");
+ properties.put("oss.access_key", "oss-ak");
+ properties.put("oss.secret_key", "oss-sk");
+ return properties;
+ }
+
@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));
+ LanceStorageOptions.fromDorisStorageProperties(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"));
@@ -91,7 +102,7 @@ public class LanceStorageOptionsTest {
properties.put("s3.region", "us-east-1");
Map<String, String> options =
- LanceStorageOptions.forUri(S3_URI, createAll(properties));
+ LanceStorageOptions.fromDorisStorageProperties(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"));
@@ -100,6 +111,103 @@ public class LanceStorageOptionsTest {
Assertions.assertEquals("https://s3.amazonaws.com",
options.get("aws_endpoint"));
}
+ @Test
+ public void testOssCatalogPropertiesMapToThePublicSpelling() {
+ Map<String, String> properties = ossProperties();
+ properties.put("oss.session_token", "oss-token");
+
+ Map<String, String> options =
LanceStorageOptions.fromDorisStorageProperties(
+ OSS_URI, createAll(properties));
+
+ Assertions.assertEquals("https://oss-cn-hangzhou.aliyuncs.com",
+ options.get("oss_endpoint"));
+ Assertions.assertEquals("oss-ak", options.get("oss_access_key_id"));
+ Assertions.assertEquals("oss-sk",
options.get("oss_secret_access_key"));
+ Assertions.assertEquals("cn-hangzhou", options.get("oss_region"));
+ Assertions.assertEquals("oss-token",
options.get("oss_security_token"));
+ // Stated rather than implied, so the host environment cannot decide
it; see
+ // testOssVirtualHostAddressingIsStatedExplicitly.
+ Assertions.assertEquals("virtual", options.get("addressing_style"));
+ Assertions.assertEquals(
+ new TreeSet<>(Arrays.asList("oss_endpoint",
"oss_access_key_id",
+ "oss_secret_access_key", "oss_region",
"oss_security_token",
+ "addressing_style", "allow_anonymous")),
+ new TreeSet<>(options.keySet()));
+ Assertions.assertEquals("false", options.get("allow_anonymous"));
+ }
+
+ @Test
+ public void testOssAnonymousAccessEmitsNoCredentials() {
+ Map<String, String> properties = ossProperties();
+ properties.remove("oss.access_key");
+ properties.remove("oss.secret_key");
+
+ Map<String, String> options =
LanceStorageOptions.fromDorisStorageProperties(
+ OSS_URI, createAll(properties));
+
+ Assertions.assertEquals("https://oss-cn-hangzhou.aliyuncs.com",
+ options.get("oss_endpoint"));
+ Assertions.assertEquals("cn-hangzhou", options.get("oss_region"));
+ Assertions.assertNull(options.get("oss_access_key_id"));
+ Assertions.assertNull(options.get("oss_secret_access_key"));
+ Assertions.assertNull(options.get("oss_security_token"));
+ }
+
+ @Test
+ public void testVendedOssOptionsSupersedeTheCatalog() {
+ Map<String, String> properties = ossProperties();
+ properties.put("oss.session_token", "static-token");
+ Map<String, String> vended = new HashMap<>();
+ vended.put("endpoint", "https://oss-cn-shanghai.aliyuncs.com");
+ vended.put("access_key_id", "vended-ak");
+ vended.put("access_key_secret", "vended-sk");
+ vended.put("region", "cn-shanghai");
+ vended.put("security_token", "vended-token");
+
+ Map<String, String> merged =
LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, createAll(properties), vended);
+
+ Assertions.assertEquals("https://oss-cn-shanghai.aliyuncs.com",
+ merged.get("oss_endpoint"));
+ Assertions.assertEquals("vended-ak", merged.get("oss_access_key_id"));
+ Assertions.assertEquals("vended-sk",
merged.get("oss_secret_access_key"));
+ Assertions.assertEquals("cn-shanghai", merged.get("oss_region"));
+ Assertions.assertEquals("vended-token",
merged.get("oss_security_token"));
+ for (String alias : vended.keySet()) {
+ Assertions.assertNull(merged.get(alias), alias + " must not
survive beside its twin");
+ }
+ }
+
+ @Test
+ public void testOssAliasesCollapseAndConflictsAreRejected() {
+ // Carries a whole credential: a lone secret is a one-sided pair,
which reconciliation
+ // now rejects on its own account, and that is not what this case is
about.
+ Map<String, String> agreeing = new HashMap<>();
+ agreeing.put("access_key_id", "ak");
+ agreeing.put("access_key_secret", "same");
+ agreeing.put("oss_secret_access_key", "same");
+ Assertions.assertEquals("same",
LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, Collections.emptyList(),
agreeing).get("oss_secret_access_key"));
+
+ Map<String, String> conflicting = new HashMap<>();
+ conflicting.put("endpoint", "https://one.example.com");
+ conflicting.put("oss_endpoint", "https://another.example.com");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
LanceStorageOptions
+ .fromDorisAndVendedStorageOptions(OSS_URI,
Collections.emptyList(), conflicting));
+ }
+
+ @Test
+ public void testUnknownVendedOssOptionsPassThrough() {
+ Map<String, String> vended = new HashMap<>();
+ vended.put("unknown_provider_option", "value");
+
+ Map<String, String> merged =
LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, Collections.emptyList(), vended);
+
+ Assertions.assertEquals("value",
merged.get("unknown_provider_option"));
+ Assertions.assertEquals("true", merged.get("allow_anonymous"));
+ }
+
/**
* 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
@@ -116,7 +224,7 @@ public class LanceStorageOptionsTest {
vended.put("virtual_hosted_style_request", "true");
Map<String, String> merged =
- LanceStorageOptions.forVendedTable(S3_URI, minioCatalog(),
vended);
+ LanceStorageOptions.fromDorisAndVendedStorageOptions(S3_URI,
minioCatalog(), vended);
Assertions.assertEquals("vended-ak", merged.get("aws_access_key_id"));
Assertions.assertEquals("vended-sk",
merged.get("aws_secret_access_key"));
@@ -139,7 +247,7 @@ public class LanceStorageOptionsTest {
vended.put(alias, "http://127.0.0.1:9000");
Map<String, String> merged =
- LanceStorageOptions.forVendedTable(S3_URI, minioCatalog(),
vended);
+
LanceStorageOptions.fromDorisAndVendedStorageOptions(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"),
@@ -147,6 +255,37 @@ public class LanceStorageOptionsTest {
}
}
+ @Test
+ public void testS3AllowHttpIsInferredAfterVendedEndpointWins() {
+ Map<String, String> httpsEndpoint = Collections.singletonMap(
+ "endpoint", "https://s3.example.com");
+ Map<String, String> httpsOptions = LanceStorageOptions
+ .fromDorisAndVendedStorageOptions(S3_URI, minioCatalog(),
httpsEndpoint);
+ Assertions.assertEquals("https://s3.example.com",
httpsOptions.get("aws_endpoint"));
+ Assertions.assertNull(httpsOptions.get("allow_http"));
+
+ Map<String, String> explicitFalse = new HashMap<>();
+ explicitFalse.put("endpoint", "http://127.0.0.1:9000");
+ explicitFalse.put("allow_http", "false");
+ Map<String, String> explicitOptions = LanceStorageOptions
+ .fromDorisAndVendedStorageOptions(S3_URI, minioCatalog(),
explicitFalse);
+ Assertions.assertEquals("false", explicitOptions.get("allow_http"));
+ }
+
+ @Test
+ public void testNormalizationDoesNotInferProviderOptions() {
+ Map<String, String> s3 = LanceS3StorageProvider.INSTANCE
+ .normalizeDorisStorageOptions(minioCatalog());
+ Assertions.assertEquals("http://minio:9000", s3.get("aws_endpoint"));
+ Assertions.assertFalse(s3.containsKey("allow_http"));
+
+ Map<String, String> oss = LanceOssStorageProvider.INSTANCE
+ .normalizeDorisStorageOptions(createAll(ossProperties()));
+ Assertions.assertEquals("oss-ak", oss.get("oss_access_key_id"));
+ Assertions.assertFalse(oss.containsKey("skip_signature"));
+ Assertions.assertFalse(oss.containsKey("allow_anonymous"));
+ }
+
/**
* {@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.
@@ -160,11 +299,11 @@ public class LanceStorageOptionsTest {
Map<String, String> vended = new HashMap<>();
vended.put("token", "vended-token");
- Map<String, String> onS3 = LanceStorageOptions.forVendedTable(S3_URI,
catalog, vended);
+ Map<String, String> onS3 =
LanceStorageOptions.fromDorisAndVendedStorageOptions(S3_URI, catalog, vended);
Assertions.assertEquals("vended-token", onS3.get("aws_session_token"));
Assertions.assertNull(onS3.get("token"));
- Map<String, String> onAzure = LanceStorageOptions.forVendedTable(
+ Map<String, String> onAzure =
LanceStorageOptions.fromDorisAndVendedStorageOptions(
"az://container/table.lance", catalog, vended);
Assertions.assertEquals("vended-token", onAzure.get("token"));
Assertions.assertNull(onAzure.get("aws_session_token"));
@@ -172,9 +311,8 @@ public class LanceStorageOptionsTest {
/**
* 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.
+ * {@code endpoint} but not {@code aws_endpoint}. Rewriting it onto the S3
spelling leaves the
+ * dataset unreachable, so a provider with no Doris adapter must come
through untouched.
*/
@Test
public void testNonS3DatasetsAreNeverRewritten() {
@@ -184,10 +322,10 @@ public class LanceStorageOptionsTest {
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",
+ "gs://bucket/table.lance", "cos://bucket/table",
"file:///tmp/table.lance"}) {
Map<String, String> merged =
- LanceStorageOptions.forVendedTable(uri, minioCatalog(),
vended);
+ LanceStorageOptions.fromDorisAndVendedStorageOptions(uri,
minioCatalog(), vended);
Assertions.assertEquals(vended, merged,
uri + " must reach Lance exactly as the namespace wrote
it");
}
@@ -202,17 +340,18 @@ public class LanceStorageOptionsTest {
public void testCatalogWithoutAnS3UrlGetsNoS3Options() {
for (String uri : new String[] {"file:///warehouse/lance", "", null}) {
Assertions.assertTrue(
- LanceStorageOptions.forUri(uri, minioCatalog()).isEmpty(),
+ LanceStorageOptions.fromDorisStorageProperties(uri,
minioCatalog()).isEmpty(),
"expected no options for " + uri);
}
}
- /** Doris has no Lance vocabulary for a non-S3 provider yet, so it
contributes none. */
+ /** A provider never receives another provider's static configuration. */
@Test
public void testCatalogPropertiesAreNotAppliedToANonS3Dataset() {
- Map<String, String> merged = LanceStorageOptions.forUri(
+ Map<String, String> merged =
LanceStorageOptions.fromDorisStorageProperties(
"oss://bucket/table.lance", minioCatalog());
- Assertions.assertTrue(merged.isEmpty(), "S3 credentials must not leak
onto another provider");
+ Assertions.assertEquals(Collections.singletonMap("allow_anonymous",
"true"), merged,
+ "S3 credentials must not leak onto another provider");
}
/**
@@ -225,7 +364,7 @@ public class LanceStorageOptionsTest {
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"));
+ LanceStorageOptions.fromDorisStorageProperties(S3_URI,
catalog).get("aws_access_key_id"));
}
@Test
@@ -238,18 +377,18 @@ public class LanceStorageOptionsTest {
vended.put("deliberately_empty", "");
Map<String, String> merged =
- LanceStorageOptions.forVendedTable(S3_URI,
Collections.emptyList(), vended);
+ LanceStorageOptions.fromDorisAndVendedStorageOptions(S3_URI,
Collections.emptyList(), vended);
Assertions.assertEquals(vended, merged);
}
@Test
public void testAbsentVendedOptionsLeaveCatalogOptionsIntact() {
Map<String, String> catalogOptions =
- LanceStorageOptions.forUri(S3_URI, minioCatalog());
+ LanceStorageOptions.fromDorisStorageProperties(S3_URI,
minioCatalog());
Assertions.assertEquals(catalogOptions,
- LanceStorageOptions.forUri(S3_URI, minioCatalog()));
+ LanceStorageOptions.fromDorisStorageProperties(S3_URI,
minioCatalog()));
Assertions.assertEquals(catalogOptions,
- LanceStorageOptions.forVendedTable(S3_URI, minioCatalog(), new
HashMap<>()));
+ LanceStorageOptions.fromDorisAndVendedStorageOptions(S3_URI,
minioCatalog(), new HashMap<>()));
}
/** A namespace contradicting itself is not something to resolve by coin
toss. */
@@ -259,14 +398,14 @@ public class LanceStorageOptionsTest {
vended.put("access_key_id", "one");
vended.put("aws_access_key_id", "another");
Assertions.assertThrows(IllegalArgumentException.class,
- () -> LanceStorageOptions.forVendedTable(S3_URI,
minioCatalog(), vended));
+ () ->
LanceStorageOptions.fromDorisAndVendedStorageOptions(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"));
+ .fromDorisAndVendedStorageOptions(S3_URI, minioCatalog(),
agreeing).get("aws_access_key_id"));
}
/**
@@ -279,7 +418,7 @@ public class LanceStorageOptionsTest {
properties.put("s3.access_key", "ak\0ignored");
IllegalArgumentException thrown =
Assertions.assertThrows(IllegalArgumentException.class,
- () -> LanceStorageOptions.forUri(S3_URI,
createAll(properties)));
+ () -> LanceStorageOptions.fromDorisStorageProperties(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());
@@ -294,21 +433,332 @@ public class LanceStorageOptionsTest {
Map<String, String> withNulKey = new HashMap<>();
withNulKey.put("bucket\0ignored", "other-bucket");
Assertions.assertThrows(IllegalArgumentException.class, () ->
LanceStorageOptions
- .forVendedTable(S3_URI, Collections.emptyList(), withNulKey));
+ .fromDorisAndVendedStorageOptions(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));
+ .fromDorisAndVendedStorageOptions(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));
+ .fromDorisAndVendedStorageOptions(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));
+ .fromDorisAndVendedStorageOptions(S3_URI,
Collections.emptyList(), withNullKey));
+ }
+
+ /**
+ * A catalog can be absent entirely - a Lance table reached purely through
vended options has no
+ * Doris storage properties behind it. The provider has to answer with no
options rather than
+ * fail, so that {@link
LanceStorageOptions#fromDorisAndVendedStorageOptions} still has a map to merge
onto.
+ */
+ @Test
+ public void testOssDatasetWithoutACatalogInfersAnonymousAccess() {
+ Assertions.assertEquals(Collections.singletonMap("allow_anonymous",
"true"),
+ LanceStorageOptions.fromDorisStorageProperties(OSS_URI, null));
+ }
+
+ /**
+ * {@link LanceStorageOptions#fromDorisAndVendedStorageOptions} screens
out a null vended map before it reaches a
+ * provider, but the interface still admits one and the sibling providers
all tolerate it. Pin
+ * that down directly, since no public entry point can reach it.
+ */
+ @Test
+ public void testOssNormalizeVendedToleratesNoVendedOptions() {
+ Assertions.assertTrue(
+
LanceOssStorageProvider.INSTANCE.normalizeVendedStorageOptions(null).isEmpty());
+ }
+
+ /**
+ * Doris reads a blank key pair as anonymous access. OpenDAL only skips
signing when it is told
+ * to, so the Lance-native flag has to be emitted rather than merely
leaving credentials out.
+ */
+ @Test
+ public void testOssAnonymousModeIsForwardedToOpenDal() {
+ Map<String, String> properties = ossProperties();
+ properties.remove("oss.access_key");
+ properties.remove("oss.secret_key");
+
+ Map<String, String> options =
LanceStorageOptions.fromDorisStorageProperties(OSS_URI, createAll(properties));
+
+ Assertions.assertEquals("true", options.get("allow_anonymous"));
+ Assertions.assertNull(options.get("oss_access_key_id"));
+ Assertions.assertNull(options.get("oss_secret_access_key"));
+ }
+
+ @Test
+ public void testOssCredentialedModeRequiresSigning() {
+ Map<String, String> options =
LanceStorageOptions.fromDorisStorageProperties(
+ OSS_URI, createAll(ossProperties()));
+
+ Assertions.assertEquals("false", options.get("allow_anonymous"));
+ }
+
+ /**
+ * Doris models path-style addressing as a property with a default, so
both answers are stated
+ * rather than only the non-default one - see
+ * {@link #testOssVirtualHostAddressingIsStatedExplicitly} for why absence
is not neutral here.
+ */
+ @Test
+ public void testOssPathStyleAddressingIsForwarded() {
+ Map<String, String> properties = ossProperties();
+ properties.put("oss.use_path_style", "true");
+
+ Map<String, String> options =
LanceStorageOptions.fromDorisStorageProperties(OSS_URI, createAll(properties));
+
+ Assertions.assertEquals("path", options.get("addressing_style"));
+ }
+
+ /**
+ * Lance snapshots the host's {@code OSS_} and {@code AWS_} environment
variables into the same
+ * config map before storage options are applied, so the default has to be
stated rather than
+ * left implicit - otherwise an exported OSS_ADDRESSING_STYLE would
outrank the catalog.
+ */
+ @Test
+ public void testOssVirtualHostAddressingIsStatedExplicitly() {
+ Map<String, String> options =
LanceStorageOptions.fromDorisStorageProperties(
+ OSS_URI, createAll(ossProperties()));
+
+ Assertions.assertEquals("virtual", options.get("addressing_style"));
+ }
+
+ /**
+ * Doris accepts a whitespace-only key pair as "both set", so the emitted
credentials and the
+ * signing choice have to agree: whatever this provider treats as a usable
credential must
+ * suppress anonymous access, or OpenDAL sends the request unsigned.
+ */
+ @Test
+ public void testWhitespaceOssCredentialsAreNotAlsoCalledAnonymous() {
+ Map<String, String> properties = ossProperties();
+ properties.put("oss.access_key", " ");
+ properties.put("oss.secret_key", " ");
+
+ Map<String, String> options =
LanceStorageOptions.fromDorisStorageProperties(OSS_URI, createAll(properties));
+
+ Assertions.assertNotEquals(
+ options.containsKey("oss_access_key_id"),
+ Boolean.parseBoolean(options.get("allow_anonymous")),
+ "a credential and allow_anonymous=true must never both be
emitted");
+ }
+
+ /**
+ * The case that makes this matter: an anonymous catalog whose namespace
vends real credentials.
+ * OpenDAL's OSS signer returns the request unsigned whenever anonymous
access is enabled, no
+ * matter what credentials sit beside it. Leaving the flag on would
silently unsign every FE
+ * metadata read and BE scan that the vended credentials were supposed to
authorize.
+ */
+ @Test
+ public void testVendedOssCredentialsClearStaticAnonymousMode() {
+ Map<String, String> properties = ossProperties();
+ properties.remove("oss.access_key");
+ properties.remove("oss.secret_key");
+ Assertions.assertEquals("true",
+ LanceStorageOptions.fromDorisStorageProperties(OSS_URI,
createAll(properties))
+ .get("allow_anonymous"),
+ "precondition: the static half alone is anonymous");
+
+ Map<String, String> vended = new HashMap<>();
+ vended.put("access_key_id", "vended-ak");
+ vended.put("access_key_secret", "vended-sk");
+
+ Map<String, String> merged =
LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, createAll(properties), vended);
+
+ Assertions.assertEquals("false", merged.get("allow_anonymous"));
+ Assertions.assertEquals("vended-ak", merged.get("oss_access_key_id"));
+ Assertions.assertEquals("vended-sk",
merged.get("oss_secret_access_key"));
+ }
+
+ /**
+ * A namespace vending a whole credential has all of it carried through,
token included, and the
+ * signing flag inferred from it. Options merge key by key, so a namespace
is expected to vend
+ * the credential it wants used in full - what the catalog holds is not
combined with it.
+ */
+ @Test
+ public void testVendedOssTokenSurvivesWithItsOwnPair() {
+ Map<String, String> properties = ossProperties();
+ properties.put("oss.session_token", "static-token");
+ Map<String, String> vended = new HashMap<>();
+ vended.put("access_key_id", "vended-ak");
+ vended.put("access_key_secret", "vended-sk");
+ vended.put("security_token", "vended-token");
+
+ Map<String, String> merged =
LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, createAll(properties), vended);
+
+ Assertions.assertEquals("vended-token",
merged.get("oss_security_token"));
+ Assertions.assertEquals("false", merged.get("allow_anonymous"));
+ }
+
+ /**
+ * A namespace asking for anonymous access has just described this table,
which outranks a
+ * credential the catalog holds for something else. Signing with it would
403 on a bucket the
+ * catalog's account cannot read.
+ */
+ @Test
+ public void testVendedAnonymousModeConflictsWithStaticOssCredentials() {
+ Map<String, String> vended = new HashMap<>();
+ vended.put("allow_anonymous", "true");
+
+ // The catalog's credentials are not cleared to make room for it: a
namespace asking for
+ // anonymous access to a catalog that holds a credential is a
contradiction, and saying so
+ // beats silently picking one of the two.
+ IllegalArgumentException thrown = Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, createAll(ossProperties()), vended));
+ Assertions.assertTrue(thrown.getMessage().contains("Conflicting OSS
authentication"),
+ "unexpected message: " + thrown.getMessage());
+ }
+
+ @Test
+ public void testOssAnonymousAliasesCollapseAndConflict() {
+ Map<String, String> agreeing = new HashMap<>();
+ agreeing.put("allow_anonymous", "true");
+ agreeing.put("skip_signature", "true");
+ Assertions.assertEquals("true", LanceStorageOptions
+ .fromDorisAndVendedStorageOptions(OSS_URI,
Collections.emptyList(), agreeing)
+ .get("allow_anonymous"));
+
+ Map<String, String> conflicting = new HashMap<>();
+ conflicting.put("allow_anonymous", "true");
+ conflicting.put("skip_signature", "false");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
LanceStorageOptions
+ .fromDorisAndVendedStorageOptions(
+ OSS_URI, Collections.emptyList(), conflicting));
+ }
+
+ @Test
+ public void testOssAnonymousConflictsWithSigningConfiguration() {
+ Map<String, String> vended = new HashMap<>();
+ vended.put("skip_signature", "true");
+ vended.put("access_key_id", "ak");
+ vended.put("access_key_secret", "sk");
+
+ IllegalArgumentException thrown = Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, Collections.emptyList(), vended));
+ Assertions.assertTrue(thrown.getMessage().contains("Conflicting OSS
authentication"),
+ "unexpected message: " + thrown.getMessage());
+ }
+
+ @Test
+ public void testOssExplicitSigningChoiceWinsAndInferenceIsIdempotent() {
+ Map<String, String> vended =
Collections.singletonMap("skip_signature", "false");
+ Map<String, String> explicit =
LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, Collections.emptyList(), vended);
+ Assertions.assertEquals("false", explicit.get("allow_anonymous"));
+ Assertions.assertFalse(explicit.containsKey("skip_signature"));
+
+ Map<String, String> inferred =
LanceStorageOptions.fromDorisStorageProperties(
+ OSS_URI, createAll(ossProperties()));
+ Assertions.assertEquals(inferred,
LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, Collections.emptyList(), inferred));
+ }
+
+ /**
+ * Blank is not a credential. OpenDAL would otherwise build a signer from
empty strings and
+ * sign every request with them, while nothing says the access is
anonymous.
+ */
+ @Test
+ public void testVendedEmptyOssCredentialsFallBackToAnonymous() {
+ Map<String, String> properties = ossProperties();
+ properties.put("oss.session_token", "static-token");
+ Map<String, String> vended = new HashMap<>();
+ vended.put("access_key_id", "");
+ vended.put("access_key_secret", "");
+
+ Map<String, String> merged =
LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, createAll(properties), vended);
+
+ // The blanks themselves survive - the merge overlays key by key and
does not judge values.
+ // What matters is that they are not mistaken for a credential:
inference reads them as
+ // absent, so the request is marked anonymous rather than signed with
empty strings.
+ Assertions.assertEquals("true", merged.get("allow_anonymous"));
+ Assertions.assertEquals("", merged.get("oss_access_key_id"));
+ }
+
+ /**
+ * Absence is not neutral: Lance and OpenDAL may otherwise resolve
authentication from their
+ * environment. Doris states whether the final normalized configuration
should be signed.
+ */
+ @Test
+ public void testOssAnonymousModeIsStatedEitherWay() {
+ Assertions.assertEquals("false",
+ LanceStorageOptions.fromDorisStorageProperties(OSS_URI,
createAll(ossProperties()))
+ .get("allow_anonymous"));
+
+ Map<String, String> anonymous = ossProperties();
+ anonymous.remove("oss.access_key");
+ anonymous.remove("oss.secret_key");
+ Assertions.assertEquals("true",
+ LanceStorageOptions.fromDorisStorageProperties(OSS_URI,
createAll(anonymous))
+ .get("allow_anonymous"));
+ }
+
+ /**
+ * OpenDAL lower-cases every config key before deserializing, so a
credential vended in upper
+ * case still reaches the store. Left unrecognized here it would not count
as signing
+ * configuration, and the anonymous flag would be inferred as true beside
it - the store would
+ * then load the credential and skip signing anyway, sending every request
unsigned.
+ */
+ @Test
+ public void testVendedOssOptionsAreRecognizedRegardlessOfCase() {
+ Map<String, String> vended = new HashMap<>();
+ vended.put("ACCESS_KEY_ID", "vended-ak");
+ vended.put("Access_Key_Secret", "vended-sk");
+
+ Map<String, String> merged =
LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, Collections.emptyList(), vended);
+
+ Assertions.assertEquals("vended-ak", merged.get("oss_access_key_id"));
+ Assertions.assertEquals("vended-sk",
merged.get("oss_secret_access_key"));
+ Assertions.assertEquals("false", merged.get("allow_anonymous"));
+ }
+
+ /**
+ * The signing flag is read with OpenDAL's boolean grammar, not Java's.
Judging {@code on} by
+ * Boolean.parseBoolean would call it "not anonymous" and let it through
beside a credential,
+ * while the store reads it as anonymous and stops signing.
+ */
+ @Test
+ public void testVendedOssAnonymousFlagUsesTheStoresBooleanGrammar() {
+ for (String spelling : new String[] {"true", "on", "ON"}) {
+ Map<String, String> vended = new HashMap<>();
+ vended.put("allow_anonymous", spelling);
+ IllegalArgumentException thrown = Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, createAll(ossProperties()), vended),
+ "'" + spelling + "' should conflict with configured
credentials");
+ Assertions.assertTrue(thrown.getMessage().contains("Conflicting
OSS authentication"),
+ "unexpected message: " + thrown.getMessage());
+ }
+ // off/false are the store's own spellings for "sign", and agree with
a credential
+ for (String spelling : new String[] {"false", "off"}) {
+ Map<String, String> vended = new HashMap<>();
+ vended.put("allow_anonymous", spelling);
+ Assertions.assertEquals(spelling,
LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, createAll(ossProperties()),
vended).get("allow_anonymous"));
+ }
+ }
+
+ /** A value OpenDAL cannot parse is refused here, not several layers down
in the operator build. */
+ @Test
+ public void testVendedOssAnonymousFlagRejectsAnUnparsableValue() {
+ Map<String, String> vended = new HashMap<>();
+ vended.put("allow_anonymous", "yes");
+
+ IllegalArgumentException thrown = Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> LanceStorageOptions.fromDorisAndVendedStorageOptions(
+ OSS_URI, Collections.emptyList(), vended));
+ Assertions.assertTrue(thrown.getMessage().contains("Unrecognized
value"),
+ "unexpected message: " + thrown.getMessage());
}
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/LancePropertiesTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/LancePropertiesTest.java
index 1702d583f4f..b079060ead2 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/LancePropertiesTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/LancePropertiesTest.java
@@ -42,6 +42,21 @@ public class LancePropertiesTest {
lanceProperties.getRootDatabase());
}
+ @Test
+ public void testOssFilesystemProperties() throws Exception {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("type", "lance");
+ properties.put(LanceFileSystemMetastoreProperties.WAREHOUSE,
+ "oss://bucket/lance");
+
+ AbstractLanceProperties lanceProperties =
+ (AbstractLanceProperties)
MetastoreProperties.create(properties);
+
+ Assertions.assertInstanceOf(LanceFileSystemMetastoreProperties.class,
lanceProperties);
+ Assertions.assertEquals("oss://bucket/lance",
+ ((LanceFileSystemMetastoreProperties)
lanceProperties).getWarehouse());
+ }
+
@Test
public void testRestProperties() throws Exception {
Map<String, String> properties = new HashMap<>();
@@ -60,4 +75,35 @@ public class LancePropertiesTest {
Assertions.assertEquals("http://localhost:8080",
restProperties.getRestUri());
Assertions.assertEquals("bearer", restProperties.getSecurityType());
}
+
+ @Test
+ public void testObjectStoreWarehouseMustNameABucket() {
+ for (String warehouse : new String[] {"oss:/lance", "s3:/lance"}) {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("type", "lance");
+ properties.put(LanceFileSystemMetastoreProperties.WAREHOUSE,
warehouse);
+
+ IllegalArgumentException thrown = Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
MetastoreProperties.create(properties));
+ Assertions.assertTrue(thrown.getMessage().contains("must name a
bucket"),
+ "unexpected message: " + thrown.getMessage());
+ }
+ }
+
+ /**
+ * OSSHdfsProperties selects on the endpoint, so a clean-looking warehouse
still routes there
+ * when the endpoint names OSS-HDFS. Checking only the warehouse authority
would miss it.
+ */
+ @Test
+ public void testOssHdfsEndpointIsRejectedEvenWithAPlainWarehouse() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("type", "lance");
+ properties.put(LanceFileSystemMetastoreProperties.WAREHOUSE,
"oss://bkt/lance");
+ properties.put("oss.endpoint", "cn-hangzhou.oss-dls.aliyuncs.com");
+
+ IllegalArgumentException thrown = Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
MetastoreProperties.create(properties));
+ Assertions.assertTrue(thrown.getMessage().contains("OSS-HDFS is not
supported"),
+ "unexpected message: " + thrown.getMessage());
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]