From 9135757ec7be9500488596553f5f65c56ab5be29 Mon Sep 17 00:00:00 2001 From: xylaaaaa <2392805527@qq.com> Date: Thu, 6 Aug 2026 11:19:30 +0800 Subject: [PATCH 1/7] [feature](catalog) Support Alibaba Cloud OSS Tables REST catalog ### What problem does this PR solve? Issue Number: None Related PR: None Problem Summary: Alibaba Cloud OSS Tables exposes an Iceberg REST Catalog compatible with AWS S3 Tables, but Doris did not recognize the `osstables` SigV4 service name as a managed signed catalog. As a result, `s3.*` credentials were not passed to the REST signer and required signing properties were not fully validated. Recognize `osstables`, reuse S3-compatible credentials for both the REST control plane and S3FileIO data plane, require SigV4 to be enabled, and cover the official endpoint, ACS warehouse ARN, and STS configuration. ### Release note Support Alibaba Cloud OSS Tables through the Iceberg REST Catalog. ### Check List (For Author) - Test: Unit Test - `./run-fe-ut.sh --run org.apache.doris.datasource.property.metastore.IcebergRestPropertiesTest` - `./build.sh --fe` - Behavior changed: Yes. Iceberg REST catalogs with signing name `osstables` now reuse `s3.*` credentials and require a signing region with SigV4 enabled. - Does this need documentation: Yes. Follow-up documentation should include the OSS Tables REST endpoint, ACS warehouse ARN, `osstables` signing name, and OSS S3FileIO endpoint. --- .../metastore/IcebergRestProperties.java | 11 ++- .../metastore/IcebergRestPropertiesTest.java | 79 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java index 3c3da3fce5f2ee..db793663a8137b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java @@ -251,13 +251,19 @@ private ParamRules buildRules() { } } - // When signing-name is glue or s3tables: require signing-region and sigv4-enabled + // SigV4-backed REST catalogs require a signing region and SigV4 to be enabled. rules.requireIf(icebergRestSigningName, "glue", new String[] {icebergRestSigningRegion, icebergRestSigV4Enabled}, "Rest Catalog requires signing-region and sigv4-enabled set to true when signing-name is glue"); rules.requireIf(icebergRestSigningName, "s3tables", new String[] {icebergRestSigningRegion, icebergRestSigV4Enabled}, "Rest Catalog requires signing-region and sigv4-enabled set to true when signing-name is s3tables"); + rules.requireIf(icebergRestSigningName, "osstables", + new String[] {icebergRestSigningRegion, icebergRestSigV4Enabled}, + "Rest Catalog requires signing-region and sigv4-enabled set to true when signing-name is osstables"); + rules.check(() -> shouldUseS3PropertiesForRestCredentials() + && !"true".equalsIgnoreCase(icebergRestSigV4Enabled), + "Rest Catalog requires sigv4-enabled set to true when signing-name is " + icebergRestSigningName); rejectUnsupportedAwsAssumeRoleProperty(ICEBERG_REST_ROLE_ARN); rejectUnsupportedAwsAssumeRoleProperty(ICEBERG_REST_EXTERNAL_ID); @@ -362,7 +368,8 @@ private void addGlueRestCatalogProperties() { private boolean shouldUseS3PropertiesForRestCredentials() { return "glue".equals(icebergRestSigningName) - || "s3tables".equals(icebergRestSigningName); + || "s3tables".equals(icebergRestSigningName) + || "osstables".equals(icebergRestSigningName); } public Map getIcebergRestCatalogProperties() { diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java index cd9820c27223ff..c308784411368c 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java @@ -568,6 +568,85 @@ public void testS3TablesSigningNameMissingSigningRegionFails() { Assertions.assertTrue(e.getMessage().contains("signing-region") && e.getMessage().contains("s3tables")); } + @Test + public void testOssTablesRestCatalogUsesSharedS3Credentials() throws Exception { + Map props = new HashMap<>(); + props.put("iceberg.rest.uri", "https://cn-hangzhou.oss-tables.aliyuncs.com/iceberg"); + props.put("warehouse", "acs:osstables:cn-hangzhou:1234567890:bucket/my-table-bucket"); + props.put("iceberg.rest.signing-name", "osstables"); + props.put("iceberg.rest.signing-region", "cn-hangzhou"); + props.put("iceberg.rest.sigv4-enabled", "true"); + props.put("iceberg.rest.view-enabled", "false"); + props.put("io-impl", "org.apache.iceberg.aws.s3.S3FileIO"); + props.put("s3.endpoint", "https://oss-cn-hangzhou.aliyuncs.com"); + props.put("s3.region", "cn-hangzhou"); + props.put("s3.access_key", "oss-access-key"); + props.put("s3.secret_key", "oss-secret-key"); + props.put("s3.session_token", "oss-session-token"); + props.put("s3.path-style-access", "true"); + + IcebergRestProperties restProps = new IcebergRestProperties(props); + restProps.initNormalizeAndCheckProps(); + Assertions.assertFalse(restProps.isIcebergRestViewEnabled()); + + Map catalogProps = new HashMap<>(props); + catalogProps.putAll(restProps.getIcebergRestCatalogProperties()); + List storageProperties = StorageProperties.createAll(props); + restProps.toFileIOProperties(storageProperties, catalogProps, new Configuration()); + + Assertions.assertEquals("https://cn-hangzhou.oss-tables.aliyuncs.com/iceberg", + catalogProps.get(CatalogProperties.URI)); + Assertions.assertEquals("acs:osstables:cn-hangzhou:1234567890:bucket/my-table-bucket", + catalogProps.get(CatalogProperties.WAREHOUSE_LOCATION)); + Assertions.assertEquals("org.apache.iceberg.aws.s3.S3FileIO", + catalogProps.get(CatalogProperties.FILE_IO_IMPL)); + Assertions.assertEquals("osstables", catalogProps.get("rest.signing-name")); + Assertions.assertEquals("cn-hangzhou", catalogProps.get("rest.signing-region")); + Assertions.assertEquals("true", catalogProps.get("rest.sigv4-enabled")); + Assertions.assertEquals("oss-access-key", catalogProps.get("rest.access-key-id")); + Assertions.assertEquals("oss-secret-key", catalogProps.get("rest.secret-access-key")); + Assertions.assertEquals("oss-session-token", catalogProps.get("rest.session-token")); + Assertions.assertTrue(storageProperties.stream().anyMatch(OSSProperties.class::isInstance)); + Assertions.assertEquals("https://oss-cn-hangzhou.aliyuncs.com", + catalogProps.get(S3FileIOProperties.ENDPOINT)); + Assertions.assertEquals("cn-hangzhou", catalogProps.get(AwsClientProperties.CLIENT_REGION)); + Assertions.assertEquals("oss-access-key", catalogProps.get(S3FileIOProperties.ACCESS_KEY_ID)); + Assertions.assertEquals("oss-secret-key", catalogProps.get(S3FileIOProperties.SECRET_ACCESS_KEY)); + Assertions.assertEquals("oss-session-token", catalogProps.get(S3FileIOProperties.SESSION_TOKEN)); + Assertions.assertEquals("true", catalogProps.get(S3FileIOProperties.PATH_STYLE_ACCESS)); + } + + @Test + public void testOssTablesSigningNameMissingSigningRegionFails() { + Map props = new HashMap<>(); + props.put("iceberg.rest.uri", "https://cn-hangzhou.oss-tables.aliyuncs.com/iceberg"); + props.put("iceberg.rest.signing-name", "osstables"); + props.put("iceberg.rest.sigv4-enabled", "true"); + props.put("s3.access_key", "oss-access-key"); + props.put("s3.secret_key", "oss-secret-key"); + + IcebergRestProperties restProps = new IcebergRestProperties(props); + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + restProps::initNormalizeAndCheckProps); + Assertions.assertTrue(e.getMessage().contains("signing-region") && e.getMessage().contains("osstables")); + } + + @Test + public void testOssTablesSigningNameWithSigV4DisabledFails() { + Map props = new HashMap<>(); + props.put("iceberg.rest.uri", "https://cn-hangzhou.oss-tables.aliyuncs.com/iceberg"); + props.put("iceberg.rest.signing-name", "osstables"); + props.put("iceberg.rest.signing-region", "cn-hangzhou"); + props.put("iceberg.rest.sigv4-enabled", "false"); + props.put("s3.access_key", "oss-access-key"); + props.put("s3.secret_key", "oss-secret-key"); + + IcebergRestProperties restProps = new IcebergRestProperties(props); + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + restProps::initNormalizeAndCheckProps); + Assertions.assertTrue(e.getMessage().contains("sigv4-enabled") && e.getMessage().contains("osstables")); + } + @Test public void testAccessKeyAndSecretKeyMustBeSetTogether() { Map props1 = new HashMap<>(); From 50cc55ddb8b1a743cd5445c94ff982ac6f1ef8a2 Mon Sep 17 00:00:00 2001 From: xylaaaaa <2392805527@qq.com> Date: Mon, 17 Aug 2026 11:26:32 +0800 Subject: [PATCH 2/7] [fix](catalog) Reuse OSS credentials for OSS Tables ### What problem does this PR solve? Issue Number: None Related PR: #66567 Problem Summary: The branch-4.1 Iceberg REST implementation constructs its signing credentials before the storage list is available. Reusing the generic S3 binding for signing-name=osstables therefore misses oss.access_key, oss.secret_key, and oss.session_token. Bind OSSProperties for OSS Tables so the REST control plane and S3FileIO data plane use the same OSS credentials. ### Release note Support OSS-prefixed credentials for Alibaba Cloud OSS Tables REST catalogs. ### Check List (For Author) - Test: Unit Test - ./run-fe-ut.sh --run org.apache.doris.datasource.property.metastore.IcebergRestPropertiesTest - Behavior changed: Yes. OSS Tables REST signing now reuses oss.* credentials. - Does this need documentation: Yes (apache/doris-website#4054) --- .../metastore/IcebergRestProperties.java | 22 ++++++++++++++++--- .../metastore/IcebergRestPropertiesTest.java | 12 +++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java index db793663a8137b..25648fb3551ebc 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/IcebergRestProperties.java @@ -20,6 +20,7 @@ import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; import org.apache.doris.datasource.property.common.AwsCredentialsProviderMode; import org.apache.doris.datasource.property.common.IcebergAwsClientCredentialsProperties; +import org.apache.doris.datasource.property.storage.OSSProperties; import org.apache.doris.datasource.property.storage.S3Properties; import org.apache.doris.datasource.property.storage.StorageProperties; import org.apache.doris.foundation.property.ConnectorProperty; @@ -49,6 +50,7 @@ public class IcebergRestProperties extends AbstractIcebergProperties { private Map icebergRestCatalogProperties; private S3Properties s3Properties; + private OSSProperties ossProperties; @Getter @ConnectorProperty(names = {"iceberg.rest.uri", "uri"}, @@ -214,7 +216,11 @@ public void initNormalizeAndCheckProps() { AwsCredentialsProviderMode.fromString(icebergRestCredentialsProviderType); buildRules().validate(); if (shouldUseS3PropertiesForRestCredentials()) { - s3Properties = S3Properties.of(origProps); + if (isOssTables()) { + ossProperties = OSSProperties.of(origProps); + } else { + s3Properties = S3Properties.of(origProps); + } } initIcebergRestCatalogProperties(); } @@ -356,8 +362,14 @@ private void addGlueRestCatalogProperties() { icebergRestCatalogProperties.put("rest.signing-region", icebergRestSigningRegion); if (shouldUseS3PropertiesForRestCredentials()) { - IcebergAwsClientCredentialsProperties.putCredentialProviderProperties( - icebergRestCatalogProperties, s3Properties); + if (isOssTables()) { + IcebergAwsClientCredentialsProperties.putCredentialProviderProperties( + icebergRestCatalogProperties, ossProperties.getAccessKey(), ossProperties.getSecretKey(), + ossProperties.getSessionToken(), icebergRestCredentialsProviderMode); + } else { + IcebergAwsClientCredentialsProperties.putCredentialProviderProperties( + icebergRestCatalogProperties, s3Properties); + } } else { IcebergAwsClientCredentialsProperties.putCredentialProviderProperties( icebergRestCatalogProperties, icebergRestAccessKeyId, @@ -372,6 +384,10 @@ private boolean shouldUseS3PropertiesForRestCredentials() { || "osstables".equals(icebergRestSigningName); } + private boolean isOssTables() { + return "osstables".equals(icebergRestSigningName); + } + public Map getIcebergRestCatalogProperties() { return Collections.unmodifiableMap(icebergRestCatalogProperties); } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java index c308784411368c..9449e21f4674b2 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/metastore/IcebergRestPropertiesTest.java @@ -578,12 +578,12 @@ public void testOssTablesRestCatalogUsesSharedS3Credentials() throws Exception { props.put("iceberg.rest.sigv4-enabled", "true"); props.put("iceberg.rest.view-enabled", "false"); props.put("io-impl", "org.apache.iceberg.aws.s3.S3FileIO"); - props.put("s3.endpoint", "https://oss-cn-hangzhou.aliyuncs.com"); - props.put("s3.region", "cn-hangzhou"); - props.put("s3.access_key", "oss-access-key"); - props.put("s3.secret_key", "oss-secret-key"); - props.put("s3.session_token", "oss-session-token"); - props.put("s3.path-style-access", "true"); + props.put("oss.endpoint", "https://oss-cn-hangzhou.aliyuncs.com"); + props.put("oss.region", "cn-hangzhou"); + props.put("oss.access_key", "oss-access-key"); + props.put("oss.secret_key", "oss-secret-key"); + props.put("oss.session_token", "oss-session-token"); + props.put("oss.use_path_style", "true"); IcebergRestProperties restProps = new IcebergRestProperties(props); restProps.initNormalizeAndCheckProps(); From 43309644b83e5b5ad5ef088c957f4e9cae0a83e4 Mon Sep 17 00:00:00 2001 From: xylaaaaa <2392805527@qq.com> Date: Mon, 17 Aug 2026 13:03:55 +0800 Subject: [PATCH 3/7] [fix](catalog) Reuse full properties for REST connectivity checks ### What problem does this PR solve? Issue Number: None Related PR: #66567 Problem Summary: The branch-4.1 REST catalog connectivity tester initialized RESTCatalog directly with metadata properties only. This dropped the configured Iceberg FileIO and S3-compatible storage properties, so OSS Tables connectivity checks could select the unavailable OSSFileIO even though normal catalog operations used S3FileIO successfully. The connectivity tester now initializes the catalog through the same property-building path as normal operations and receives the ordered storage properties from the catalog coordinator. Added a regression test for this initialization path. ### Release note None ### Check List (For Author) - Test: FE unit test: IcebergRestConnectivityTesterTest - Behavior changed: Yes (REST connectivity checks now use the same FileIO and storage credentials as normal catalog operations) - Does this need documentation: No --- .../doris/datasource/ExternalCatalog.java | 1 + .../CatalogConnectivityTestCoordinator.java | 6 +- .../IcebergRestConnectivityTester.java | 14 +++-- .../IcebergRestConnectivityTesterTest.java | 57 +++++++++++++++++++ 4 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 fe/fe-core/src/test/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTesterTest.java diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java index 481c1fcb3aca5f..d72dadb554f96e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java @@ -304,6 +304,7 @@ public void checkWhenCreating() throws DdlException { CatalogConnectivityTestCoordinator testCoordinator = new CatalogConnectivityTestCoordinator( name, catalogProperty.getMetastoreProperties(), + catalogProperty.getOrderedStoragePropertiesList(), catalogProperty.getStoragePropertiesMap() ); testCoordinator.runTests(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/CatalogConnectivityTestCoordinator.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/CatalogConnectivityTestCoordinator.java index cf8c308849a936..9c3baf45b994d4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/CatalogConnectivityTestCoordinator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/CatalogConnectivityTestCoordinator.java @@ -35,6 +35,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.List; import java.util.Map; /** @@ -47,6 +48,7 @@ public class CatalogConnectivityTestCoordinator { private final String catalogName; private final MetastoreProperties metastoreProperties; + private final List storagePropertiesList; private final Map storagePropertiesMap; private String warehouseLocation; @@ -54,9 +56,11 @@ public class CatalogConnectivityTestCoordinator { public CatalogConnectivityTestCoordinator( String catalogName, MetastoreProperties metastoreProperties, + List storagePropertiesList, Map storagePropertiesMap) { this.catalogName = catalogName; this.metastoreProperties = metastoreProperties; + this.storagePropertiesList = storagePropertiesList; this.storagePropertiesMap = storagePropertiesMap; } @@ -295,7 +299,7 @@ private MetaConnectivityTester createMetaTester(MetastoreProperties props) { // Iceberg REST if (props instanceof IcebergRestProperties) { - return new IcebergRestConnectivityTester((IcebergRestProperties) props); + return new IcebergRestConnectivityTester((IcebergRestProperties) props, storagePropertiesList); } // Iceberg S3Table diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTester.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTester.java index def265fea8288d..630c6c870730d4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTester.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTester.java @@ -18,11 +18,12 @@ package org.apache.doris.datasource.connectivity; import org.apache.doris.datasource.property.metastore.AbstractIcebergProperties; -import org.apache.doris.datasource.property.metastore.IcebergRestProperties; +import org.apache.doris.datasource.property.storage.StorageProperties; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.rest.RESTCatalog; +import java.util.List; import java.util.Map; public class IcebergRestConnectivityTester extends AbstractIcebergConnectivityTester { @@ -30,9 +31,12 @@ public class IcebergRestConnectivityTester extends AbstractIcebergConnectivityTe private static final String DEFAULT_BASE_LOCATION = "default-base-location"; private String warehouseLocation; + private final List storagePropertiesList; - public IcebergRestConnectivityTester(AbstractIcebergProperties properties) { + public IcebergRestConnectivityTester(AbstractIcebergProperties properties, + List storagePropertiesList) { super(properties); + this.storagePropertiesList = storagePropertiesList; } @Override @@ -48,10 +52,8 @@ public String getErrorHint() { @Override public void testConnection() throws Exception { - Map restProps = ((IcebergRestProperties) properties).getIcebergRestCatalogProperties(); - - try (RESTCatalog catalog = new RESTCatalog()) { - catalog.initialize("connectivity-test", restProps); + try (RESTCatalog catalog = (RESTCatalog) properties.initializeCatalog( + "connectivity-test", storagePropertiesList)) { // Validate connection by listing namespaces. // This verifies authentication and warehouse configuration. diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTesterTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTesterTest.java new file mode 100644 index 00000000000000..4d0236b821cc63 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/connectivity/IcebergRestConnectivityTesterTest.java @@ -0,0 +1,57 @@ +// 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.connectivity; + +import org.apache.doris.datasource.property.metastore.IcebergRestProperties; +import org.apache.doris.datasource.property.storage.StorageProperties; + +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.rest.RESTCatalog; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class IcebergRestConnectivityTesterTest { + + @Test + public void testUsesNormalCatalogInitializationPath() throws Exception { + IcebergRestProperties properties = Mockito.mock(IcebergRestProperties.class); + RESTCatalog catalog = Mockito.mock(RESTCatalog.class); + StorageProperties storageProperties = Mockito.mock(StorageProperties.class); + List storagePropertiesList = Collections.singletonList(storageProperties); + Map catalogProperties = new HashMap<>(); + catalogProperties.put(CatalogProperties.WAREHOUSE_LOCATION, "s3://warehouse/path"); + + Mockito.when(properties.initializeCatalog("connectivity-test", storagePropertiesList)).thenReturn(catalog); + Mockito.when(catalog.properties()).thenReturn(catalogProperties); + + IcebergRestConnectivityTester tester = new IcebergRestConnectivityTester( + properties, storagePropertiesList); + tester.testConnection(); + + Mockito.verify(properties).initializeCatalog("connectivity-test", storagePropertiesList); + Mockito.verify(catalog).listNamespaces(); + Mockito.verify(catalog).close(); + Assertions.assertEquals("s3://warehouse/path", tester.getTestLocation()); + } +} From c4bfd79e356bbdca6465a76e13a2cd20e8f58100 Mon Sep 17 00:00:00 2001 From: xylaaaaa <2392805527@qq.com> Date: Mon, 17 Aug 2026 14:02:40 +0800 Subject: [PATCH 4/7] [fix](catalog) Prefer OSS storage for OSS Tables data paths --- .../property/storage/S3Properties.java | 8 ++++++++ .../storage/StoragePropertiesTest.java | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/S3Properties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/S3Properties.java index c63e3acce558f0..715038812c9f99 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/S3Properties.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/S3Properties.java @@ -245,6 +245,14 @@ public void initNormalizeAndCheckProps() { * @return */ protected static boolean guessIsMe(Map origProps) { + // An explicit Aliyun OSS configuration must not be shadowed by the REST + // signing region fallback below. OSS Tables uses an s3:// data location, + // while its OSS endpoint is also enough for OSSProperties to identify the + // storage provider. Prefer that concrete provider unless the user has + // explicitly enabled S3 through fs.s3.support. + if (OSSProperties.guessIsMe(origProps)) { + return false; + } String endpoint = Stream.of(ENDPOINT_NAMES_FOR_GUESSING) .map(origProps::get) .filter(Objects::nonNull) diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/StoragePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/StoragePropertiesTest.java index 022c1e9b890b95..7a5d01c67182e2 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/StoragePropertiesTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/StoragePropertiesTest.java @@ -70,6 +70,24 @@ public void testNoExplicitSupport_guessIsMeStillWorks_OSS() throws UserException "OSS should be detected via guessIsMe when no explicit fs.xx.support is set"); } + @Test + public void testOssTablesSigningRegionDoesNotCreateAnEmptyS3Provider() throws UserException { + Map props = new HashMap<>(); + props.put("iceberg.rest.signing-name", "osstables"); + props.put("iceberg.rest.signing-region", "cn-beijing"); + props.put("oss.endpoint", "https://oss-cn-beijing.aliyuncs.com"); + props.put("oss.region", "cn-beijing"); + props.put("oss.access_key", "ak"); + props.put("oss.secret_key", "sk"); + + List all = StorageProperties.createAll(props); + List> types = toTypeList(all); + + Assertions.assertTrue(types.contains(OSSProperties.class)); + Assertions.assertFalse(types.contains(S3Properties.class), + "The REST signing region must not create a competing default S3 provider"); + } + /** * When no {@code fs.xx.support} flag is set, an S3 endpoint containing * "amazonaws.com" should be detected as S3 via guessIsMe. From b712758839cb0ac652af04a6fd9a5f45cba61622 Mon Sep 17 00:00:00 2001 From: xylaaaaa <2392805527@qq.com> Date: Mon, 17 Aug 2026 17:13:14 +0800 Subject: [PATCH 5/7] [chore](build) Retrigger branch-4.1 CI ### What problem does this PR solve? Issue Number: None Related PR: #66567 Problem Summary: Retrigger CI after the previous FE test run encountered an interrupted Maven dependency download and the coverage job started before its compile artifact was available. ### Release note None ### Check List (For Author) - Test: No need to test (empty commit used only to retrigger CI) - Behavior changed: No - Does this need documentation: No From 98f44be5f1003f822841aa45709feba9f36359a4 Mon Sep 17 00:00:00 2001 From: xylaaaaa <2392805527@qq.com> Date: Tue, 18 Aug 2026 01:02:37 +0800 Subject: [PATCH 6/7] [fix](catalog) Scope OSS storage selection to Iceberg ### What problem does this PR solve? Issue Number: None Related PR: #66567 Problem Summary: The branch-4.1 backport globally disabled generic S3 detection whenever OSS was recognized. This broke valid mixed S3/OSS configurations and caused LocationPathTest to receive a null S3 provider. Restore global provider coexistence and select a single concrete S3-compatible provider only inside Iceberg catalog, scan, and write paths, matching master semantics while keeping OSS Tables data access on OSS. ### Release note Fix OSS Tables data access on branch-4.1 without changing global S3/OSS provider detection. ### Check List (For Author) - Test: Unit Test - LocationPathTest, StoragePropertiesTest, IcebergUtilsTest, IcebergRestPropertiesTest, IcebergTableSinkTest, IcebergScanNodeTest, IcebergRestConnectivityTesterTest, IcebergVendedCredentialsProviderTest, and VendedCredentialsFactoryTest - Behavior changed: Yes. Iceberg now prefers a concrete S3-compatible provider such as OSS over generic S3, while global storage detection still supports mixed S3/OSS configuration. - Does this need documentation: No --- .../datasource/iceberg/IcebergUtils.java | 54 +++++++++++++++++++ .../iceberg/source/IcebergScanNode.java | 1 + .../metastore/AbstractIcebergProperties.java | 21 +++----- .../property/storage/S3Properties.java | 8 --- .../doris/planner/IcebergTableSink.java | 18 ++++--- .../datasource/iceberg/IcebergUtilsTest.java | 30 +++++++++++ .../storage/StoragePropertiesTest.java | 6 +-- .../doris/planner/IcebergTableSinkTest.java | 48 +++++++++++++++++ 8 files changed, 152 insertions(+), 34 deletions(-) diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java index 0b375c70d6791e..891183adbd2683 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java @@ -59,6 +59,9 @@ import org.apache.doris.datasource.mvcc.MvccSnapshot; import org.apache.doris.datasource.mvcc.MvccUtil; import org.apache.doris.datasource.property.metastore.HMSBaseProperties; +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 org.apache.doris.nereids.exceptions.NotSupportedException; import org.apache.doris.nereids.trees.expressions.literal.Result; import org.apache.doris.nereids.types.VarBinaryType; @@ -145,6 +148,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.Comparator; +import java.util.EnumMap; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; @@ -177,6 +181,56 @@ public Integer initialValue() { public static final String TOTAL_POSITION_DELETES = "total-position-deletes"; public static final String TOTAL_EQUALITY_DELETES = "total-equality-deletes"; + /** + * Selects the storage bindings Iceberg should consume together. Iceberg can configure only one + * S3-compatible data plane, so a concrete provider such as OSS takes precedence over the generic + * S3 fallback while unrelated storage bindings are preserved. + */ + public static List selectEffectiveStorageProperties( + List storagePropertiesList) { + StorageProperties chosenS3 = chooseS3CompatibleStorage(storagePropertiesList); + List selected = new ArrayList<>(); + for (StorageProperties storageProperties : storagePropertiesList) { + if (!(storageProperties instanceof AbstractS3CompatibleProperties) + || storageProperties == chosenS3) { + selected.add(storageProperties); + } + } + return selected; + } + + public static Map selectEffectiveStorageProperties( + Map storagePropertiesMap) { + List ordered = new ArrayList<>(); + for (StorageProperties.Type type : StorageProperties.Type.values()) { + StorageProperties storageProperties = storagePropertiesMap.get(type); + if (storageProperties != null) { + ordered.add(storageProperties); + } + } + + Map selected = new EnumMap<>(StorageProperties.Type.class); + for (StorageProperties storageProperties : selectEffectiveStorageProperties(ordered)) { + selected.put(storageProperties.getType(), storageProperties); + } + return selected; + } + + private static StorageProperties chooseS3CompatibleStorage(List storagePropertiesList) { + StorageProperties fallback = null; + for (StorageProperties storageProperties : storagePropertiesList) { + if (storageProperties instanceof AbstractS3CompatibleProperties) { + if (fallback == null) { + fallback = storageProperties; + } + if (!(storageProperties instanceof S3Properties)) { + return storageProperties; + } + } + } + return fallback; + } + // nickname in flink and spark public static final String WRITE_FORMAT = "write-format"; public static final String COMPRESSION_CODEC = "compression-codec"; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java index 957ab6ed55e193..196ccd94c50e45 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java @@ -273,6 +273,7 @@ protected void doInitialize() throws UserException { source.getCatalog().getCatalogProperty().getStoragePropertiesMap(), icebergTable ); + storagePropertiesMap = IcebergUtils.selectEffectiveStorageProperties(storagePropertiesMap); backendStorageProperties = CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap); } finally { if (getSummaryProfile() != null) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractIcebergProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractIcebergProperties.java index 9a3a5ef5d2a318..ef225fae44c123 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractIcebergProperties.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractIcebergProperties.java @@ -19,6 +19,7 @@ import org.apache.doris.common.security.authentication.ExecutionAuthenticator; import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; +import org.apache.doris.datasource.iceberg.IcebergUtils; import org.apache.doris.datasource.metacache.CacheSpec; import org.apache.doris.datasource.property.common.IcebergAwsAssumeRoleProperties; import org.apache.doris.datasource.property.storage.AbstractS3CompatibleProperties; @@ -210,28 +211,18 @@ protected abstract Catalog initCatalog( */ public void toFileIOProperties(List storagePropertiesList, Map fileIOProperties, Configuration conf) { - // We only support one S3-compatible storage property for FileIO configuration. - // When multiple AbstractS3CompatibleProperties exist, prefer the first non-S3Properties one, - // because a non-S3 type (e.g. OSSProperties, COSProperties) indicates the user has explicitly - // specified a concrete S3-compatible storage, which should take priority over the generic S3Properties. - AbstractS3CompatibleProperties s3Fallback = null; AbstractS3CompatibleProperties s3Target = null; - for (StorageProperties storageProperties : storagePropertiesList) { + for (StorageProperties storageProperties : + IcebergUtils.selectEffectiveStorageProperties(storagePropertiesList)) { if (conf != null && storageProperties.getHadoopStorageConfig() != null) { conf.addResource(storageProperties.getHadoopStorageConfig()); } if (storageProperties instanceof AbstractS3CompatibleProperties) { - if (s3Fallback == null) { - s3Fallback = (AbstractS3CompatibleProperties) storageProperties; - } - if (s3Target == null && !(storageProperties instanceof S3Properties)) { - s3Target = (AbstractS3CompatibleProperties) storageProperties; - } + s3Target = (AbstractS3CompatibleProperties) storageProperties; } } - AbstractS3CompatibleProperties chosen = s3Target != null ? s3Target : s3Fallback; - if (chosen != null) { - toS3FileIOProperties(chosen, fileIOProperties); + if (s3Target != null) { + toS3FileIOProperties(s3Target, fileIOProperties); } else { String region = AbstractS3CompatibleProperties.getRegionFromProperties(fileIOProperties); if (!Strings.isNullOrEmpty(region)) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/S3Properties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/S3Properties.java index 715038812c9f99..c63e3acce558f0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/S3Properties.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/storage/S3Properties.java @@ -245,14 +245,6 @@ public void initNormalizeAndCheckProps() { * @return */ protected static boolean guessIsMe(Map origProps) { - // An explicit Aliyun OSS configuration must not be shadowed by the REST - // signing region fallback below. OSS Tables uses an s3:// data location, - // while its OSS endpoint is also enough for OSSProperties to identify the - // storage provider. Prefer that concrete provider unless the user has - // explicitly enabled S3 through fs.s3.support. - if (OSSProperties.guessIsMe(origProps)) { - return false; - } String endpoint = Stream.of(ENDPOINT_NAMES_FOR_GUESSING) .map(origProps::get) .filter(Objects::nonNull) diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergTableSink.java index 4f643f61748140..fea8a84c27b778 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergTableSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/IcebergTableSink.java @@ -79,10 +79,11 @@ public IcebergTableSink(IcebergExternalTable targetTable) { this.targetTable = targetTable; this.icebergTable = targetTable.getIcebergTable(); IcebergExternalCatalog catalog = (IcebergExternalCatalog) targetTable.getCatalog(); - storagePropertiesMap = VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials( - catalog.getCatalogProperty().getMetastoreProperties(), - catalog.getCatalogProperty().getStoragePropertiesMap(), - icebergTable); + storagePropertiesMap = IcebergUtils.selectEffectiveStorageProperties( + VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials( + catalog.getCatalogProperty().getMetastoreProperties(), + catalog.getCatalogProperty().getStoragePropertiesMap(), + icebergTable)); } public IcebergTableSink(IcebergExternalTable targetTable, Table icebergTable) { @@ -94,10 +95,11 @@ public IcebergTableSink(IcebergExternalTable targetTable, Table icebergTable) { // Keep credentials and every writer option on the metadata generation pinned during analysis. this.icebergTable = Objects.requireNonNull(icebergTable, "icebergTable is not null"); IcebergExternalCatalog catalog = (IcebergExternalCatalog) targetTable.getCatalog(); - storagePropertiesMap = VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials( - catalog.getCatalogProperty().getMetastoreProperties(), - catalog.getCatalogProperty().getStoragePropertiesMap(), - icebergTable); + storagePropertiesMap = IcebergUtils.selectEffectiveStorageProperties( + VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials( + catalog.getCatalogProperty().getMetastoreProperties(), + catalog.getCatalogProperty().getStoragePropertiesMap(), + icebergTable)); } @Override diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java index 45619851cdc78a..21e918a843937b 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java @@ -24,7 +24,11 @@ import org.apache.doris.catalog.Type; import org.apache.doris.common.UserException; import org.apache.doris.common.security.authentication.ExecutionAuthenticator; +import org.apache.doris.common.util.LocationPath; import org.apache.doris.datasource.iceberg.source.IcebergTableQueryInfo; +import org.apache.doris.datasource.property.storage.OSSProperties; +import org.apache.doris.datasource.property.storage.S3Properties; +import org.apache.doris.datasource.property.storage.StorageProperties; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.system.Backend; @@ -80,6 +84,32 @@ import java.util.concurrent.atomic.AtomicReference; public class IcebergUtilsTest { + @Test + public void testSelectEffectiveStoragePropertiesPrefersOssOverGenericS3() throws UserException { + Map properties = new HashMap<>(); + properties.put("iceberg.rest.signing-name", "osstables"); + properties.put("iceberg.rest.signing-region", "cn-beijing"); + properties.put("oss.endpoint", "https://oss-cn-beijing.aliyuncs.com"); + properties.put("oss.region", "cn-beijing"); + properties.put("oss.access_key", "ak"); + properties.put("oss.secret_key", "sk"); + + Map detected = new HashMap<>(); + for (StorageProperties storageProperties : StorageProperties.createAll(properties)) { + detected.put(storageProperties.getType(), storageProperties); + } + Assert.assertTrue(detected.get(StorageProperties.Type.S3) instanceof S3Properties); + Assert.assertTrue(detected.get(StorageProperties.Type.OSS) instanceof OSSProperties); + + Map selected = + IcebergUtils.selectEffectiveStorageProperties(detected); + + Assert.assertFalse(selected.containsKey(StorageProperties.Type.S3)); + Assert.assertTrue(selected.get(StorageProperties.Type.OSS) instanceof OSSProperties); + Assert.assertSame(selected.get(StorageProperties.Type.OSS), + LocationPath.of("s3://bucket/data.parquet", selected).getStorageProperties()); + } + @Test public void testSnapshotCacheFreezesSharedTableOperations() { Schema originalSchema = new Schema( diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/StoragePropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/StoragePropertiesTest.java index 7a5d01c67182e2..77315099a229ee 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/StoragePropertiesTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/property/storage/StoragePropertiesTest.java @@ -71,7 +71,7 @@ public void testNoExplicitSupport_guessIsMeStillWorks_OSS() throws UserException } @Test - public void testOssTablesSigningRegionDoesNotCreateAnEmptyS3Provider() throws UserException { + public void testOssTablesPropertiesKeepS3AndOssProvidersAvailable() throws UserException { Map props = new HashMap<>(); props.put("iceberg.rest.signing-name", "osstables"); props.put("iceberg.rest.signing-region", "cn-beijing"); @@ -84,8 +84,8 @@ public void testOssTablesSigningRegionDoesNotCreateAnEmptyS3Provider() throws Us List> types = toTypeList(all); Assertions.assertTrue(types.contains(OSSProperties.class)); - Assertions.assertFalse(types.contains(S3Properties.class), - "The REST signing region must not create a competing default S3 provider"); + Assertions.assertTrue(types.contains(S3Properties.class), + "Global storage detection must keep S3 available when OSS is also configured"); } /** diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergTableSinkTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergTableSinkTest.java index 334c70880e2738..7b93261a1baaf6 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergTableSinkTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/IcebergTableSinkTest.java @@ -20,6 +20,9 @@ import org.apache.doris.datasource.CatalogProperty; import org.apache.doris.datasource.iceberg.IcebergExternalCatalog; import org.apache.doris.datasource.iceberg.IcebergExternalTable; +import org.apache.doris.datasource.property.storage.OSSProperties; +import org.apache.doris.datasource.property.storage.S3Properties; +import org.apache.doris.datasource.property.storage.StorageProperties; import org.apache.doris.nereids.trees.plans.commands.insert.IcebergInsertCommandContext; import org.apache.doris.thrift.TIcebergTableSink; @@ -34,10 +37,55 @@ import org.mockito.Mockito; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import java.util.Optional; public class IcebergTableSinkTest { + @Test + public void testBindPrefersOssDataPlanePropertiesOverGenericS3() throws Exception { + Map properties = new HashMap<>(); + properties.put("iceberg.rest.signing-name", "osstables"); + properties.put("iceberg.rest.signing-region", "cn-beijing"); + properties.put("oss.endpoint", "https://oss-cn-beijing.aliyuncs.com"); + properties.put("oss.region", "cn-beijing"); + properties.put("oss.access_key", "oss-ak"); + properties.put("oss.secret_key", "oss-sk"); + + Map storagePropertiesMap = new HashMap<>(); + for (StorageProperties storageProperties : StorageProperties.createAll(properties)) { + storagePropertiesMap.put(storageProperties.getType(), storageProperties); + } + Assertions.assertTrue(storagePropertiesMap.get(StorageProperties.Type.S3) instanceof S3Properties); + Assertions.assertTrue(storagePropertiesMap.get(StorageProperties.Type.OSS) instanceof OSSProperties); + + IcebergExternalCatalog catalog = Mockito.mock(IcebergExternalCatalog.class); + CatalogProperty catalogProperty = Mockito.mock(CatalogProperty.class); + Mockito.when(catalog.getCatalogProperty()).thenReturn(catalogProperty); + Mockito.when(catalogProperty.getMetastoreProperties()).thenReturn(null); + Mockito.when(catalogProperty.getStoragePropertiesMap()).thenReturn(storagePropertiesMap); + + IcebergExternalTable targetTable = Mockito.mock(IcebergExternalTable.class); + Mockito.when(targetTable.isView()).thenReturn(false); + Mockito.when(targetTable.getCatalog()).thenReturn(catalog); + Mockito.when(targetTable.getDbName()).thenReturn("db"); + Mockito.when(targetTable.getName()).thenReturn("table"); + + Schema schema = new Schema(1, + Types.NestedField.required(1, "id", Types.IntegerType.get())); + Table table = mockTable(schema); + Mockito.when(table.location()).thenReturn("s3://bucket/table"); + + IcebergTableSink sink = new IcebergTableSink(targetTable, table); + sink.bindDataSink(Optional.empty()); + + TIcebergTableSink thriftSink = sink.tDataSink.getIcebergTableSink(); + Assertions.assertEquals("https://oss-cn-beijing.aliyuncs.com", + thriftSink.getHadoopConfig().get("AWS_ENDPOINT")); + Assertions.assertEquals("oss-ak", thriftSink.getHadoopConfig().get("AWS_ACCESS_KEY")); + Assertions.assertEquals("s3://bucket/table/data", thriftSink.getOriginalOutputPath()); + } + @Test public void testBindUsesPinnedIcebergTableMetadata() throws Exception { IcebergExternalCatalog catalog = Mockito.mock(IcebergExternalCatalog.class); From b9198b8ced9165489b38bdf3759a8a69d0ef1503 Mon Sep 17 00:00:00 2001 From: xylaaaaa <2392805527@qq.com> Date: Wed, 19 Aug 2026 03:22:20 +0800 Subject: [PATCH 7/7] [chore](build) Retrigger branch-4.1 CI ### What problem does this PR solve? Issue Number: None Related PR: #66823 Problem Summary: Retrigger the branch-4.1 CI pipeline after repeated External Regression environment failures while pulling Docker Hub images. This empty commit contains no behavior change. ### Release note None ### Check List (For Author) - Test: No need to test (empty commit used only to retrigger CI) - Behavior changed: No - Does this need documentation: No