blob: 1b34917abf332452e721593c37d34dbbf1bbbcaa [file]
/*
* 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/common/fs/object_store_file_system.h"
#include <gtest/gtest.h>
#include <array>
#include <cstring>
#include <limits>
#include <map>
#include <utility>
#include <vector>
#include "paimon/testing/utils/testharness.h"
namespace paimon::test {
namespace {
using Range = std::pair<int64_t, int64_t>;
class MockObjectStoreClient : public ObjectStoreClient {
public:
Result<ObjectMetadata> HeadObject(const ObjectStorePath& path) const override {
++head_calls_;
if (!head_error_.ok()) {
return head_error_;
}
auto iter = objects_.find(path.key);
if (iter == objects_.end()) {
return Status::NotExist("not found");
}
return ObjectMetadata{path.key, static_cast<int64_t>(iter->second.size()), 1};
}
Result<ListObjectsResult> ListObjects(const ObjectStorePath& path, const std::string& token,
int32_t) const override {
++list_calls_;
if (!list_error_.ok()) {
return list_error_;
}
if (!pages_.empty()) {
return pages_.at(token);
}
ListObjectsResult result;
for (const auto& [key, value] : objects_) {
if (key.rfind(path.key, 0) == 0) {
result.objects.push_back(
ObjectMetadata{key, static_cast<int64_t>(value.size()), 1});
}
}
return result;
}
Result<int64_t> GetObjectRange(const ObjectStorePath& path, int64_t offset, int64_t size,
char* buffer) const override {
ranges_.emplace_back(offset, size);
const std::string& value = objects_.at(path.key);
int64_t available = std::min(size, static_cast<int64_t>(value.size()) - offset);
if (short_read_) {
--available;
}
std::memcpy(buffer, value.data() + offset, available);
return available;
}
void GetObjectRangeAsync(const ObjectStorePath& path, int64_t offset, int64_t size,
char* buffer, std::function<void(Status)>&& callback) const override {
Result<int64_t> result = GetObjectRange(path, offset, size, buffer);
callback(result.ok() && result.value() == size ? Status::OK()
: Status::IOError("short read"));
}
std::map<std::string, std::string> objects_;
std::map<std::string, ListObjectsResult> pages_;
Status head_error_ = Status::OK();
Status list_error_ = Status::OK();
mutable int32_t list_calls_ = 0;
mutable int32_t head_calls_ = 0;
mutable std::vector<Range> ranges_;
bool short_read_ = false;
};
TEST(ObjectStoreFileSystemTest, TestObjectWinsOverPrefix) {
auto client = std::make_shared<MockObjectStoreClient>();
client->objects_["foo"] = "file";
client->objects_["foo/bar"] = "child";
ObjectStoreFileSystem fs("s3", client);
ASSERT_OK_AND_ASSIGN(auto status, fs.GetFileStatus("s3://bucket/foo"));
ASSERT_FALSE(status->IsDir());
std::vector<std::unique_ptr<FileStatus>> statuses;
ASSERT_OK(fs.ListFileStatus("s3://bucket/foo", &statuses));
ASSERT_EQ(statuses.size(), 1);
ASSERT_FALSE(statuses[0]->IsDir());
}
TEST(ObjectStoreFileSystemTest, TestHeadErrorIsNotMasked) {
auto client = std::make_shared<MockObjectStoreClient>();
client->head_error_ = Status::IOError("access denied");
client->objects_["foo/bar"] = "child";
ObjectStoreFileSystem fs("s3", client);
auto status = fs.GetFileStatus("s3://bucket/foo");
ASSERT_TRUE(status.status().IsIOError());
ASSERT_EQ(client->list_calls_, 0);
}
TEST(ObjectStoreFileSystemTest, TestPaginationAndDirectoryMarker) {
auto client = std::make_shared<MockObjectStoreClient>();
ListObjectsResult first;
first.objects.push_back({"dir/", 0, 0});
first.objects.push_back({"dir/a", 1, 1});
first.is_truncated = true;
first.continuation_token = "next";
ListObjectsResult second;
second.common_prefixes.push_back("dir/sub/");
client->pages_[""] = first;
client->pages_["next"] = second;
ObjectStoreFileSystem fs("s3", client);
std::vector<std::unique_ptr<FileStatus>> statuses;
ASSERT_OK(fs.ListFileStatus("s3://bucket/dir/", &statuses));
ASSERT_EQ(statuses.size(), 2);
ASSERT_EQ(statuses[0]->GetPath(), "s3://bucket/dir/a");
ASSERT_TRUE(statuses[1]->IsDir());
}
TEST(ObjectStoreFileSystemTest, TestTruncatedPageRequiresContinuationToken) {
auto client = std::make_shared<MockObjectStoreClient>();
ListObjectsResult page;
page.objects.push_back({"dir/a", 1, 1});
page.is_truncated = true;
client->pages_[""] = page;
ObjectStoreFileSystem fs("s3", client);
std::vector<std::unique_ptr<FileStatus>> statuses;
ASSERT_TRUE(fs.ListFileStatus("s3://bucket/dir/", &statuses).IsIOError());
ASSERT_TRUE(statuses.empty());
}
TEST(ObjectStoreFileSystemTest, TestOpenBucketRootIsDirectory) {
auto client = std::make_shared<MockObjectStoreClient>();
ObjectStoreFileSystem fs("s3", client);
ASSERT_TRUE(fs.Open("s3://bucket/").status().IsInvalid());
ASSERT_EQ(client->head_calls_, 0);
ASSERT_EQ(client->list_calls_, 0);
}
TEST(ObjectStoreFileSystemTest, TestPathWithLeadingSlashes) {
auto client = std::make_shared<MockObjectStoreClient>();
client->objects_["file"] = "data";
ObjectStoreFileSystem fs("s3", client);
ASSERT_OK_AND_ASSIGN(auto status, fs.GetFileStatus("s3://bucket///file"));
ASSERT_EQ(status->GetPath(), "s3://bucket/file");
}
TEST(ObjectStoreInputStreamTest, TestBoundsCloseAndSeekOverflow) {
auto client = std::make_shared<MockObjectStoreClient>();
client->objects_["file"] = std::string(128 * 1024, 'x');
ObjectStoreFileSystem fs("s3", client, 64 * 1024);
ASSERT_OK_AND_ASSIGN(auto stream, fs.Open("s3://bucket/file"));
char data[4];
bool called = false;
stream->ReadAsync(data, 4, 128 * 1024 - 2, [&called](Status status) {
called = true;
ASSERT_TRUE(status.IsInvalid());
});
ASSERT_TRUE(called);
ASSERT_TRUE(stream->Seek(std::numeric_limits<int64_t>::max(), FS_SEEK_END).IsInvalid());
ASSERT_OK(stream->Close());
called = false;
stream->ReadAsync(data, 1, 0, [&called, &stream](Status status) {
called = true;
ASSERT_TRUE(status.IsIOError());
ASSERT_TRUE(stream->GetPos().status().IsIOError());
});
ASSERT_TRUE(called);
}
TEST(ObjectStoreInputStreamTest, TestShortReadFails) {
auto client = std::make_shared<MockObjectStoreClient>();
client->objects_["file"] = std::string(128 * 1024, 'x');
client->short_read_ = true;
ObjectStoreFileSystem fs("s3", client);
ASSERT_OK_AND_ASSIGN(auto stream, fs.Open("s3://bucket/file"));
char data[4];
ASSERT_TRUE(stream->Read(data, sizeof(data)).status().IsIOError());
}
TEST(ObjectStoreInputStreamTest, TestSequentialReadAheadGrows) {
auto client = std::make_shared<MockObjectStoreClient>();
client->objects_["file"] = std::string(1024 * 1024, 'x');
ObjectStoreFileSystem fs("s3", client);
ASSERT_OK_AND_ASSIGN(auto stream, fs.Open("s3://bucket/file"));
std::array<char, 4096> buffer{};
ASSERT_OK(stream->Read(buffer.data(), buffer.size()));
ASSERT_EQ(client->ranges_.back(), Range(0, 64 * 1024));
ASSERT_OK(stream->Seek(64 * 1024, FS_SEEK_SET));
ASSERT_OK(stream->Read(buffer.data(), buffer.size()));
ASSERT_EQ(client->ranges_.back(), Range(64 * 1024, 128 * 1024));
}
TEST(ObjectStoreInputStreamTest, TestConsumedBufferReleasesBudget) {
auto client = std::make_shared<MockObjectStoreClient>();
client->objects_["file"] = std::string(1024 * 1024, 'x');
ObjectStoreFileSystem fs("s3", client, 64 * 1024);
ASSERT_OK_AND_ASSIGN(auto stream, fs.Open("s3://bucket/file"));
std::array<char, 4096> buffer{};
for (int32_t i = 0; i < 17; ++i) {
ASSERT_OK(stream->Read(buffer.data(), buffer.size()));
}
ASSERT_EQ(client->ranges_.size(), 2);
ASSERT_EQ(client->ranges_.back(), Range(64 * 1024, 64 * 1024));
}
TEST(ObjectStoreInputStreamTest, TestBackwardSeekReleasesBudget) {
auto client = std::make_shared<MockObjectStoreClient>();
client->objects_["file"] = std::string(1024 * 1024, 'x');
ObjectStoreFileSystem fs("s3", client, 64 * 1024);
ASSERT_OK_AND_ASSIGN(auto stream, fs.Open("s3://bucket/file"));
std::array<char, 4096> buffer{};
ASSERT_OK(stream->Seek(64 * 1024, FS_SEEK_SET));
ASSERT_OK(stream->Read(buffer.data(), buffer.size()));
ASSERT_OK(stream->Seek(0, FS_SEEK_SET));
ASSERT_OK(stream->Read(buffer.data(), buffer.size()));
ASSERT_EQ(client->ranges_.back(), Range(0, 64 * 1024));
}
TEST(ObjectStoreInputStreamTest, TestLargeDirectReadReleasesBudget) {
auto client = std::make_shared<MockObjectStoreClient>();
client->objects_["file"] = std::string(16 * 1024 * 1024, 'x');
ObjectStoreFileSystem fs("s3", client, 64 * 1024);
ASSERT_OK_AND_ASSIGN(auto stream, fs.Open("s3://bucket/file"));
std::array<char, 4096> small_buffer{};
ASSERT_OK(stream->Seek(64 * 1024, FS_SEEK_SET));
ASSERT_OK(stream->Read(small_buffer.data(), small_buffer.size()));
std::vector<char> large_buffer(8 * 1024 * 1024);
ASSERT_OK(stream->Seek(1024 * 1024, FS_SEEK_SET));
ASSERT_OK(stream->Read(large_buffer.data(), large_buffer.size()));
ASSERT_OK(stream->Seek(0, FS_SEEK_SET));
ASSERT_OK(stream->Read(small_buffer.data(), small_buffer.size()));
ASSERT_EQ(client->ranges_.back(), Range(0, 64 * 1024));
}
TEST(ObjectStoreInputStreamTest, TestCompetingStreamsShareBudget) {
auto client = std::make_shared<MockObjectStoreClient>();
client->objects_["first"] = std::string(1024 * 1024, 'x');
client->objects_["second"] = std::string(1024 * 1024, 'x');
ObjectStoreFileSystem fs("s3", client, 64 * 1024);
ASSERT_OK_AND_ASSIGN(auto first, fs.Open("s3://bucket/first"));
ASSERT_OK_AND_ASSIGN(auto second, fs.Open("s3://bucket/second"));
std::array<char, 4096> buffer{};
ASSERT_OK(first->Read(buffer.data(), buffer.size()));
ASSERT_OK(second->Read(buffer.data(), buffer.size()));
ASSERT_EQ(client->ranges_.back(), Range(0, 4096));
ASSERT_OK(first->Close());
ASSERT_OK(second->Seek(100000, FS_SEEK_SET));
ASSERT_OK(second->Read(buffer.data(), buffer.size()));
ASSERT_EQ(client->ranges_.back(), Range(100000, 64 * 1024));
}
} // namespace
} // namespace paimon::test