zjw1111 commented on code in PR #205:
URL: https://github.com/apache/paimon-cpp/pull/205#discussion_r3811879591


##########
src/paimon/fs/oss/oss_file_system.cpp:
##########
@@ -0,0 +1,196 @@
+/*
+ * 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.
+ */
+
+#include "paimon/fs/oss/oss_file_system.h"
+
+#include <curl/curl.h>
+
+#include <algorithm>
+#include <cstdint>
+#include <ctime>
+#include <memory>
+#include <utility>
+
+#include "alibabacloud/oss2/OSSClient.h"
+#include "alibabacloud/oss2/Operation.h"
+#include "alibabacloud/oss2/Types.h"
+#include "alibabacloud/oss2/io/ByteWriter.h"
+#include "alibabacloud/oss2/models/BucketBasic.h"
+#include "alibabacloud/oss2/models/ObjectBasic.h"
+#include "fmt/format.h"
+#include "paimon/executor.h"
+
+namespace paimon::oss {
+namespace {
+
+namespace oss2 = alibabacloud::oss2;
+
+bool IsNotFoundError(const oss2::OperationError& error) {
+    return error.getStatusCode() == 404 || error.getCode() == "NoSuchKey" ||
+           error.getCode() == "NoSuchBucket" || error.getCode() == "NotFound";
+}
+
+Status ToPaimonStatus(const oss2::OperationError& error, const std::string& 
operation,
+                      const ObjectStorePath& path) {
+    std::string message = fmt::format("OSS {} 'oss://{}/{}' failed: code={}, 
status={}, message={}",
+                                      operation, path.bucket, path.key, 
error.getCode(),
+                                      error.getStatusCode(), 
error.getMessage());
+    if (!error.getRequestId().empty()) {
+        message += fmt::format(", request_id={}", error.getRequestId());
+    }
+    if (IsNotFoundError(error)) {
+        return Status::NotExist(message);
+    }
+    if (error.getCode() == "RequestCanceled") {
+        return Status::Cancelled(message);
+    }
+    return Status::IOError(message);
+}
+
+int64_t ParseTimeMillis(const std::string& value) {
+    time_t seconds = curl_getdate(value.c_str(), nullptr);

Review Comment:
   `curl_getdate` cannot parse the timestamp format that `ListObjectsV2` 
returns, so this helper returns `0` for every listed object.
   
   `curl_getdate` only accepts formats carrying an English month name (RFC 
822/1123, RFC 850, `asctime` and their loose variants), plus one compact 
`YYYYMMDD` form. In curl's `lib/parsedate.c`, `w->mon` is only ever set by 
`checkmonth()` on an alphabetic token or by the `num_digits == 8` branch in 
`datenum()`, and `datecheck()` fails immediately when `mon == -1`.
   
   `ListObjectsV2` returns ISO 8601 in the XML body and the SDK stores it 
verbatim in `ObjectSummary::lastModified`. For `2024-01-01T00:00:00.000Z` the 
parse actually fails even earlier: the tokens are `2024` (taken as year), `01` 
(taken as mday), and the third `01` has no field left to fill, so `datenum()` 
returns `PARSEDATE_FAIL`.
   
   `HeadObject` is unaffected because `HeadObjectResult::getLastModified()` 
reads the HTTP `Last-Modified` header, which is an HTTP-date and does contain a 
month name. That asymmetry makes this easy to miss.
   
   Downstream, every `FileStatus` produced by `ListDir` / `ListStatus` gets 
`modification_time == 0`, and `OrphanFilesCleanerImpl` then rejects it 
(`GetModificationTime() <= MIN_VALID_FILE_MODIFICATION_MS` returns 
`Status::Invalid("... is not in millisecond")`), so orphan file cleanup fails 
on OSS. Returning `0` also collides with 
`FileStatus::kUnknownModificationTime`, which is `-1`, so callers cannot 
distinguish "unknown" from the epoch.
   
   `s3_file_system.cpp` has the same defect in `ParseModificationTime` (S3's 
`ListObjectsV2` `LastModified` is ISO 8601 as well), and the two also differ in 
the failure check: S3 uses `seconds == static_cast<time_t>(-1)` while this uses 
`seconds < 0`, which is always false where `time_t` is unsigned.
   
   Would it be possible to extract one shared helper used by both file systems 
— handling ISO 8601 explicitly (`strptime("%Y-%m-%dT%H:%M:%S")` + `timegm`, 
plus the optional `.SSS` and the `Z` / `+hh:mm` suffix), falling back to 
`curl_getdate` for HTTP-date, and returning `kUnknownModificationTime` on 
failure?
   
   It would also help to assert a parsed value in the tests rather than only 
the failure path. `TestListObjects` already feeds 
`<LastModified>2024-01-01T00:00:00.000Z</LastModified>` but only checks 
`statuses.size()`, and `TestHeadObjectParsesMetadata` uses `not-a-timestamp` 
and asserts `0`, so this slipped through.



##########
src/paimon/fs/oss/oss_file_system_factory.cpp:
##########
@@ -0,0 +1,201 @@
+/*
+ * 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.
+ */
+
+#include "paimon/fs/oss/oss_file_system_factory.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <utility>
+
+#include "alibabacloud/oss2/ClientConfiguration.h"
+#include "alibabacloud/oss2/OSSClient.h"
+#include "alibabacloud/oss2/credentials/CredentialsProvider.h"
+#include "fmt/format.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/common/utils/string_utils.h"
+#include "paimon/factories/factory.h"
+#include "paimon/fs/oss/oss_file_system.h"
+
+namespace paimon::oss {
+namespace {
+
+namespace oss2 = alibabacloud::oss2;
+
+constexpr std::string_view kOssOptionPrefix = "fs.oss.";
+
+std::string GetBucketOptionKey(const std::string& bucket, std::string_view 
option) {
+    return fmt::format("fs.oss.bucket.{}.{}", bucket, 
option.substr(kOssOptionPrefix.size()));
+}
+
+const std::string* FindOption(const std::map<std::string, std::string>& 
options,
+                              const std::string& bucket, std::string_view 
option,
+                              std::string* option_key) {
+    std::string bucket_option_key = GetBucketOptionKey(bucket, option);
+    auto bucket_option = options.find(bucket_option_key);
+    if (bucket_option != options.end()) {
+        *option_key = std::move(bucket_option_key);
+        return &bucket_option->second;
+    }
+    option_key->assign(option);
+    auto global_option = options.find(*option_key);
+    return global_option == options.end() ? nullptr : &global_option->second;
+}
+
+std::string GetOption(const std::map<std::string, std::string>& options, const 
std::string& bucket,
+                      std::string_view option) {
+    std::string option_key;
+    const std::string* value = FindOption(options, bucket, option, 
&option_key);
+    return value == nullptr ? "" : *value;
+}
+
+Result<std::string> GetRequiredOption(const std::map<std::string, 
std::string>& options,
+                                      const std::string& bucket, 
std::string_view option) {
+    std::string option_key;
+    const std::string* value = FindOption(options, bucket, option, 
&option_key);
+    if (value == nullptr || value->empty()) {
+        return Status::Invalid(fmt::format("OSS option '{}' must not be 
empty", option_key));
+    }
+    return *value;
+}
+
+Result<std::unique_ptr<Executor>> CreateExecutor(const std::map<std::string, 
std::string>& options,
+                                                 const std::string& bucket) {
+    std::string option_key;
+    const std::string* value =
+        FindOption(options, bucket, kOssExecutorThreadCountOption, 
&option_key);
+    if (value == nullptr) {
+        return CreateDefaultExecutor();
+    }
+    std::optional<uint32_t> thread_count = 
StringUtils::StringToValue<uint32_t>(*value);
+    if (!thread_count.has_value() || *thread_count == 0) {
+        return Status::Invalid(fmt::format(
+            "OSS executor thread count for option '{}' must be greater than 
0", option_key));
+    }
+    return CreateDefaultExecutor(*thread_count);
+}
+
+std::string NormalizeEndpoint(std::string endpoint) {
+    if (!endpoint.empty() && endpoint.find("://") == std::string::npos) {
+        endpoint = "https://"; + endpoint;
+    }
+    return endpoint;
+}
+
+std::string InferRegion(std::string endpoint) {
+    constexpr std::string_view kPrefix = "oss-";
+    constexpr std::string_view kSuffix = ".aliyuncs.com";
+    size_t scheme = endpoint.find("://");
+    if (scheme != std::string::npos) {
+        endpoint.erase(0, scheme + 3);
+    }
+    size_t slash = endpoint.find('/');
+    if (slash != std::string::npos) {
+        endpoint.erase(slash);
+    }
+    if (endpoint.rfind(kPrefix, 0) != 0) {
+        return "";
+    }
+    size_t suffix = endpoint.find(kSuffix);
+    if (suffix == std::string::npos || suffix + kSuffix.size() != 
endpoint.size()) {
+        return "";
+    }
+    std::string region = endpoint.substr(kPrefix.size(), suffix - 
kPrefix.size());
+    constexpr std::string_view kInternal = "-internal";
+    if (region.size() > kInternal.size() &&
+        region.compare(region.size() - kInternal.size(), kInternal.size(), 
kInternal) == 0) {
+        region.erase(region.size() - kInternal.size());
+    }
+    if (region == "accelerate" ||

Review Comment:
   Thanks for tightening this after the previous round — failing fast instead 
of silently guessing is the right direction. I think the deny-list still lets 
one real endpoint through.
   
   `oss-accelerate-overseas.aliyuncs.com` is a real endpoint (the SDK produces 
exactly this string for `EndpointType::Overseas` in `regionToEndpoint()`). It 
is neither equal to `"accelerate"` nor suffixed with `-dualstack`, so 
`InferRegion` returns `"accelerate-overseas"`, which is not a region. That 
value is assigned to `config.region`, and since the SDK defaults 
`signatureVersion` to `v4`, the region feeds the V4 signing scope and the 
derived signing key — so every request fails with `SignatureDoesNotMatch`, with 
an error mentioning nothing about endpoint or region configuration. Returning 
`""` here would instead surface the clear "OSS region must be configured ..." 
error at creation time.
   
   The `-dualstack` branch also looks unreachable: real dual-stack endpoints 
are `<region>.oss.aliyuncs.com` (again per `regionToEndpoint()`), which does 
not start with `oss-` and is already rejected by the prefix check above. 
`TestEndpointRegionValidation` uses `oss-cn-hangzhou-dualstack.aliyuncs.com`, a 
form OSS does not serve, so that case passes for the wrong reason.
   
   Would it be possible to invert this into an allow-list instead — accepting 
the inferred value only when it matches a region shape such as 
`^[a-z]{2,3}-[a-z]+(-[0-9]+)?$`? Then `accelerate`, `accelerate-overseas` and 
any future non-region endpoint all fall into the explicit-error path 
automatically, and the `-dualstack` branch could be dropped. It would be good 
to also change that test case to a real `cn-hangzhou.oss.aliyuncs.com` and add 
one for `oss-accelerate-overseas.aliyuncs.com`.
   
   One more small gap in the same function: an endpoint with an explicit port 
such as `oss-cn-hangzhou.aliyuncs.com:443` fails the `.aliyuncs.com`-at-the-end 
check and lands in the same "region must be configured" error, even though the 
endpoint itself is standard. Stripping a trailing `:<port>` after the path is 
removed would cover that.



##########
src/paimon/fs/oss/oss_file_system_factory.cpp:
##########
@@ -0,0 +1,201 @@
+/*
+ * 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.
+ */
+
+#include "paimon/fs/oss/oss_file_system_factory.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <utility>
+
+#include "alibabacloud/oss2/ClientConfiguration.h"
+#include "alibabacloud/oss2/OSSClient.h"
+#include "alibabacloud/oss2/credentials/CredentialsProvider.h"
+#include "fmt/format.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/common/utils/string_utils.h"
+#include "paimon/factories/factory.h"
+#include "paimon/fs/oss/oss_file_system.h"
+
+namespace paimon::oss {
+namespace {
+
+namespace oss2 = alibabacloud::oss2;
+
+constexpr std::string_view kOssOptionPrefix = "fs.oss.";
+
+std::string GetBucketOptionKey(const std::string& bucket, std::string_view 
option) {
+    return fmt::format("fs.oss.bucket.{}.{}", bucket, 
option.substr(kOssOptionPrefix.size()));
+}
+
+const std::string* FindOption(const std::map<std::string, std::string>& 
options,
+                              const std::string& bucket, std::string_view 
option,
+                              std::string* option_key) {
+    std::string bucket_option_key = GetBucketOptionKey(bucket, option);
+    auto bucket_option = options.find(bucket_option_key);
+    if (bucket_option != options.end()) {
+        *option_key = std::move(bucket_option_key);
+        return &bucket_option->second;
+    }
+    option_key->assign(option);
+    auto global_option = options.find(*option_key);
+    return global_option == options.end() ? nullptr : &global_option->second;
+}
+
+std::string GetOption(const std::map<std::string, std::string>& options, const 
std::string& bucket,
+                      std::string_view option) {
+    std::string option_key;
+    const std::string* value = FindOption(options, bucket, option, 
&option_key);
+    return value == nullptr ? "" : *value;
+}
+
+Result<std::string> GetRequiredOption(const std::map<std::string, 
std::string>& options,
+                                      const std::string& bucket, 
std::string_view option) {
+    std::string option_key;
+    const std::string* value = FindOption(options, bucket, option, 
&option_key);
+    if (value == nullptr || value->empty()) {
+        return Status::Invalid(fmt::format("OSS option '{}' must not be 
empty", option_key));
+    }
+    return *value;
+}
+
+Result<std::unique_ptr<Executor>> CreateExecutor(const std::map<std::string, 
std::string>& options,
+                                                 const std::string& bucket) {
+    std::string option_key;
+    const std::string* value =
+        FindOption(options, bucket, kOssExecutorThreadCountOption, 
&option_key);
+    if (value == nullptr) {
+        return CreateDefaultExecutor();
+    }
+    std::optional<uint32_t> thread_count = 
StringUtils::StringToValue<uint32_t>(*value);
+    if (!thread_count.has_value() || *thread_count == 0) {
+        return Status::Invalid(fmt::format(
+            "OSS executor thread count for option '{}' must be greater than 
0", option_key));
+    }
+    return CreateDefaultExecutor(*thread_count);
+}
+
+std::string NormalizeEndpoint(std::string endpoint) {
+    if (!endpoint.empty() && endpoint.find("://") == std::string::npos) {
+        endpoint = "https://"; + endpoint;
+    }
+    return endpoint;
+}
+
+std::string InferRegion(std::string endpoint) {
+    constexpr std::string_view kPrefix = "oss-";
+    constexpr std::string_view kSuffix = ".aliyuncs.com";
+    size_t scheme = endpoint.find("://");
+    if (scheme != std::string::npos) {
+        endpoint.erase(0, scheme + 3);
+    }
+    size_t slash = endpoint.find('/');
+    if (slash != std::string::npos) {
+        endpoint.erase(slash);
+    }
+    if (endpoint.rfind(kPrefix, 0) != 0) {

Review Comment:
   `StringUtils::StartsWith` / `EndsWith` already exist in 
`common/utils/string_utils.h`, and this file already includes it and uses 
`StringUtils::StringToValue` elsewhere. The four hand-written prefix/suffix 
checks in this function — this one, the `.aliyuncs.com` check below, the 
`-internal` strip, and the `-dualstack` check — would read better through them.
   
   One caveat if you do this: the helpers take `const std::string&`, while 
`kPrefix` / `kSuffix` / `kInternal` are `constexpr std::string_view`, which 
does not convert implicitly — so those constants would need to become 
`constexpr const char*` (or `StringUtils` would need `string_view` overloads). 
Also note the `.aliyuncs.com` check currently uses `find()` (first occurrence) 
plus an end-position check, so switching to `EndsWith` slightly changes 
behaviour for inputs like `oss-x.aliyuncs.com.aliyuncs.com`; that difference 
disappears if the allow-list suggestion above is adopted.



##########
src/paimon/fs/oss/oss_file_system_test.cpp:
##########
@@ -0,0 +1,320 @@
+/*
+ * 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.
+ */
+
+#include "paimon/fs/oss/oss_file_system.h"
+
+#include <chrono>
+#include <cstdint>
+#include <future>
+#include <map>
+#include <memory>
+#include <sstream>
+#include <string>
+#include <system_error>
+#include <utility>
+#include <vector>
+
+#include "alibabacloud/oss2/ClientConfiguration.h"
+#include "alibabacloud/oss2/OSSClient.h"
+#include "alibabacloud/oss2/credentials/CredentialsProvider.h"
+#include "alibabacloud/oss2/io/ByteWriter.h"
+#include "alibabacloud/oss2/transport/HttpTransport.h"
+#include "gtest/gtest.h"
+#include "paimon/fs/oss/oss_file_system_factory.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::oss {
+namespace {
+
+namespace oss2 = alibabacloud::oss2;
+
+class MockHttpTransport : public oss2::HttpTransport {
+ public:
+    oss2::ResponseResult send(std::unique_ptr<oss2::RequestMessage>& request,
+                              const oss2::RequestOptions& options) override {
+        
requests.emplace_back(std::make_unique<oss2::RequestMessage>(*request));
+        if (responses.empty()) {
+            return 
oss2::TransportError{std::make_error_code(std::errc::no_message_available), "",
+                                        ""};
+        }
+        std::unique_ptr<oss2::ResponseMessage> response = 
std::move(responses.front());
+        responses.erase(responses.begin());
+        if (response->statusCode / 100 == 2 && options.sinkFactory.has_value() 
&&
+            response->body != nullptr) {
+            int64_t content_length = -1;
+            auto content_length_header = 
response->headers.find("Content-Length");
+            if (content_length_header != response->headers.end()) {
+                content_length = std::stoll(content_length_header->second);
+            }
+            std::shared_ptr<oss2::ByteWriter> sink =
+                options.sinkFactory.value()(content_length, response->headers);
+            std::ostringstream body;
+            body << response->body->rdbuf();
+            const std::string data = body.str();
+            sink->write(reinterpret_cast<const uint8_t*>(data.data()), 
data.size());
+            response->body.reset();
+        }
+        return response;
+    }
+
+    std::string getName() const override {
+        return "MockHttpTransport";
+    }
+
+    void AddResponse(int status_code, oss2::HeaderCollection headers, 
std::string body = "") {
+        std::shared_ptr<std::iostream> response_body;
+        if (!body.empty()) {
+            response_body = 
std::make_shared<std::stringstream>(std::move(body));
+        }
+        
responses.emplace_back(std::make_unique<oss2::ResponseMessage>(oss2::ResponseMessage{
+            status_code, "", std::move(headers), std::move(response_body), 
nullptr}));
+    }
+
+    std::vector<std::unique_ptr<oss2::ResponseMessage>> responses;

Review Comment:
   Small naming nit: `MockHttpTransport` is a class, so Google style wants a 
trailing underscore on these two data members (`responses_` / `requests_`). 
`MockHttpClient::status_code_` in `s3_file_system_test.cpp` follows that 
already. `send` / `getName` above are SDK overrides, so keeping those names is 
of course correct.



##########
src/paimon/fs/oss/oss_file_system.cpp:
##########
@@ -0,0 +1,196 @@
+/*
+ * 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.
+ */
+
+#include "paimon/fs/oss/oss_file_system.h"
+
+#include <curl/curl.h>
+
+#include <algorithm>
+#include <cstdint>
+#include <ctime>
+#include <memory>
+#include <utility>
+
+#include "alibabacloud/oss2/OSSClient.h"
+#include "alibabacloud/oss2/Operation.h"
+#include "alibabacloud/oss2/Types.h"
+#include "alibabacloud/oss2/io/ByteWriter.h"
+#include "alibabacloud/oss2/models/BucketBasic.h"
+#include "alibabacloud/oss2/models/ObjectBasic.h"
+#include "fmt/format.h"
+#include "paimon/executor.h"
+
+namespace paimon::oss {
+namespace {
+
+namespace oss2 = alibabacloud::oss2;
+
+bool IsNotFoundError(const oss2::OperationError& error) {
+    return error.getStatusCode() == 404 || error.getCode() == "NoSuchKey" ||
+           error.getCode() == "NoSuchBucket" || error.getCode() == "NotFound";
+}
+
+Status ToPaimonStatus(const oss2::OperationError& error, const std::string& 
operation,
+                      const ObjectStorePath& path) {
+    std::string message = fmt::format("OSS {} 'oss://{}/{}' failed: code={}, 
status={}, message={}",
+                                      operation, path.bucket, path.key, 
error.getCode(),
+                                      error.getStatusCode(), 
error.getMessage());
+    if (!error.getRequestId().empty()) {
+        message += fmt::format(", request_id={}", error.getRequestId());
+    }
+    if (IsNotFoundError(error)) {
+        return Status::NotExist(message);
+    }
+    if (error.getCode() == "RequestCanceled") {
+        return Status::Cancelled(message);
+    }
+    return Status::IOError(message);
+}
+
+int64_t ParseTimeMillis(const std::string& value) {
+    time_t seconds = curl_getdate(value.c_str(), nullptr);
+    return seconds < 0 ? 0 : static_cast<int64_t>(seconds) * 1000;
+}
+
+class OssObjectStoreClient : public ObjectStoreClient,
+                             public 
std::enable_shared_from_this<OssObjectStoreClient> {
+ public:
+    OssObjectStoreClient(std::string bucket, std::shared_ptr<oss2::OSSClient> 
client,
+                         std::unique_ptr<Executor> executor)
+        : bucket_(std::move(bucket)), client_(std::move(client)), 
executor_(std::move(executor)) {}
+
+    Result<ObjectMetadata> HeadObject(const ObjectStorePath& path) const 
override {
+        PAIMON_RETURN_NOT_OK(ValidateBucket(path));
+        oss2::models::HeadObjectRequest request;
+        request.setBucket(path.bucket).setKey(path.key);
+        oss2::HeadObjectOutcome outcome = client_->headObject(request);
+        if (!outcome.has_value()) {
+            return ToPaimonStatus(outcome.error(), "HeadObject", path);
+        }
+        const oss2::models::HeadObjectResult& result = outcome.value();
+        if (result.getContentLength() < 0) {
+            return Status::IOError("OSS HeadObject response is missing 
Content-Length");
+        }
+        return ObjectMetadata{path.key, result.getContentLength(),
+                              ParseTimeMillis(result.getLastModified())};
+    }
+
+    Result<ListObjectsResult> ListObjects(const ObjectStorePath& path,
+                                          const std::string& 
continuation_token,
+                                          int32_t max_keys) const override {
+        PAIMON_RETURN_NOT_OK(ValidateBucket(path));
+        oss2::models::ListObjectsV2Request request;
+        request.setBucket(path.bucket).setPrefix(path.key).setDelimiter("/");
+        if (!continuation_token.empty()) {
+            request.setContinuationToken(continuation_token);
+        }
+        if (max_keys > 0) {
+            // OSS limits ListObjectsV2 requests to 1000 keys.
+            request.setMaxKeys(std::min(max_keys, 1000));

Review Comment:
   Minor: could `1000` become a named constant, e.g. `constexpr int32_t 
kMaxKeysPerRequest = 1000;`? The comment above explains where the limit comes 
from, but a named constant keeps it greppable if it is needed elsewhere.
   
   The same nit applies in `oss_file_system_factory.cpp`, where `"-dualstack"` 
is spelled inline three times within one expression while the neighbouring 
`-internal` is already a named `constexpr std::string_view kInternal`. That one 
goes away if the `InferRegion` allow-list suggestion is taken.



##########
CMakeLists.txt:
##########
@@ -75,8 +76,10 @@ endif()
 if(PAIMON_ENABLE_REST)
     add_definitions(-DPAIMON_ENABLE_REST)
 endif()
-# libcurl backs the HTTP client shared by the S3 file system and the rest 
catalog.
-if(PAIMON_ENABLE_S3 OR PAIMON_ENABLE_REST)
+# libcurl backs the HTTP client shared by the object store file systems and 
the rest catalog.

Review Comment:
   Now that `PAIMON_ENABLE_OSS` is part of this condition, the comment is no 
longer accurate. The shared HTTP client (`common/utils/http_client.cpp`) is 
still gated on `PAIMON_ENABLE_S3 OR PAIMON_ENABLE_REST` in 
`src/paimon/CMakeLists.txt`, so the OSS file system does not use it. OSS needs 
libcurl for two unrelated reasons: the OSS SDK's own curl transport, and the 
direct `curl_getdate` call in `oss_file_system.cpp`.
   
   Something like "libcurl is required by the REST catalog and the S3 HTTP 
client, and by the OSS SDK's curl transport" would describe the dependency more 
accurately. (If the shared timestamp helper suggested on `oss_file_system.cpp` 
lands in common code, the direct dependency from the OSS sources goes away as 
well.)



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to