| /* | |
| * 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/rest/rest_catalog.h" | |
| #include <algorithm> | |
| #include <atomic> | |
| #include <chrono> | |
| #include <cstring> | |
| #include <map> | |
| #include <memory> | |
| #include <mutex> | |
| #include <optional> | |
| #include <string> | |
| #include <thread> | |
| #include <utility> | |
| #include <vector> | |
| #include "arrow/api.h" | |
| #include "arrow/c/bridge.h" | |
| #include "fmt/format.h" | |
| #include "fmt/ranges.h" | |
| #include "gtest/gtest.h" | |
| #include "paimon/catalog/catalog.h" | |
| #include "paimon/catalog/table.h" | |
| #include "paimon/catalog_options.h" | |
| #include "paimon/commit_context.h" | |
| #include "paimon/commit_message.h" | |
| #include "paimon/common/utils/checked_cast.h" | |
| #include "paimon/common/utils/path_util.h" | |
| #include "paimon/common/utils/string_utils.h" | |
| #include "paimon/core/catalog/commit_lock.h" | |
| #include "paimon/core/catalog/commit_table_request.h" | |
| #include "paimon/core/core_options.h" | |
| #include "paimon/core/partition/partition_statistics.h" | |
| #include "paimon/core/schema/table_schema.h" | |
| #include "paimon/core/utils/snapshot_manager.h" | |
| #include "paimon/defs.h" | |
| #include "paimon/file_store_commit.h" | |
| #include "paimon/fs/file_system.h" | |
| #include "paimon/memory/memory_pool.h" | |
| #include "paimon/rest/mock_rest_server.h" | |
| #include "paimon/rest/rest_api.h" | |
| #include "paimon/schema/schema.h" | |
| #include "paimon/table/format/format_table.h" | |
| #include "paimon/testing/utils/snapshot_test_helper.h" | |
| #include "paimon/testing/utils/testharness.h" | |
| namespace paimon::test { | |
| namespace { | |
| constexpr const char kToken[] = "test-token"; | |
| constexpr const char kPrefix[] = "paimon"; | |
| constexpr const char kWarehouse[] = "wh1"; | |
| // Expiration of the data tokens the mock issues, far enough in the future that they are | |
| // never refreshed within a test. | |
| constexpr int64_t kDataTokenExpiresAtMillis = 4102444800000; | |
| // The in-memory catalog state behind the mock rest server. | |
| struct MockCatalogState { | |
| struct TableData { | |
| std::string schema_json; | |
| int64_t schema_id = 0; | |
| std::string path; | |
| std::string id = "1"; | |
| std::map<std::string, std::string> token = {{"fs.oss.accessKeyId", "ak-1"}}; | |
| }; | |
| std::map<std::string, std::map<std::string, TableData>> databases; | |
| // headers of the last request, with lower-cased names | |
| std::map<std::string, std::string> last_headers; | |
| // paths the data token endpoint was called at, in order | |
| std::vector<std::string> token_requests; | |
| // when set, every request except "/v1/config" fails with this http code | |
| std::optional<int32_t> force_error_code; | |
| // how many times a single table has been fetched, so a caller that needs the path and the | |
| // schema together can be held to one round trip | |
| int32_t get_table_requests = 0; | |
| // how many create-table requests reached the server, so a schema the client should have | |
| // refused can be shown never to have been sent | |
| int32_t create_table_requests = 0; | |
| std::string last_commit_body; | |
| std::string last_commit_table; | |
| bool refuse_commit = false; | |
| int32_t refused_commits_left = 0; | |
| std::vector<CommitLockRequest> lock_requests; | |
| std::vector<std::string> lock_tables; | |
| int32_t contended_acquires = 0; | |
| int64_t lease_millis = 60000; | |
| bool lose_on_renew = false; | |
| int64_t snapshot_delay_millis = 0; | |
| int64_t get_table_delay_millis = 0; | |
| std::vector<std::string> table_events; | |
| bool break_commit_response = false; | |
| bool commit_response_without_outcome = false; | |
| bool accept_commit_then_report_unavailable = false; | |
| int32_t accepted_commit_response_code = 503; | |
| int32_t commit_requests = 0; | |
| std::optional<std::string> current_snapshot; | |
| bool null_snapshot_response = false; | |
| std::string last_snapshot_table; | |
| // guards all fields above: the handler runs on the server's accept thread while | |
| // tests seed and inspect the state | |
| std::mutex mutex; | |
| }; | |
| MockRestServer::Response JsonResponse(int32_t code, const std::string& body) { | |
| MockRestServer::Response response; | |
| response.code = code; | |
| response.body = body; | |
| return response; | |
| } | |
| MockRestServer::Response MockError(int32_t code, const std::string& resource_type, | |
| const std::string& resource_name, const std::string& message) { | |
| ErrorResponse error(resource_type, resource_name, message, code); | |
| return JsonResponse(code, error.ToJsonString().value()); | |
| } | |
| std::string SnapshotJson(int64_t id) { | |
| return fmt::format( | |
| R"({{"version":3,"id":{},"schemaId":0,"baseManifestList":"bml","deltaManifestList":"dml",)" | |
| R"("commitUser":"user1","commitIdentifier":1,"commitKind":"APPEND","timeMillis":100,)" | |
| R"("totalRecordCount":10,"deltaRecordCount":1}})", | |
| id); | |
| } | |
| std::string TableResponseJson(const std::string& name, const MockCatalogState::TableData& table) { | |
| std::string id_member = table.id.empty() ? "" : fmt::format(R"("id":"{}",)", table.id); | |
| return fmt::format( | |
| R"({{{}"name":"{}","path":"{}","isExternal":false,"schemaId":{},"schema":{},)" | |
| R"("owner":"owner1","updatedAt":123}})", | |
| id_member, name, table.path, table.schema_id, table.schema_json); | |
| } | |
| // Serves `names` one item per page to exercise the pagination loop of the client. | |
| std::pair<std::vector<std::string>, std::optional<std::string>> PageOf( | |
| const std::vector<std::string>& names, const MockRestServer::Request& request) { | |
| size_t index = 0; | |
| auto token_iter = request.query_params.find(RestApi::kQueryParamPageToken); | |
| if (token_iter != request.query_params.end()) { | |
| // A malformed token must not throw: an exception on the accept thread would | |
| // terminate the test binary. | |
| index = static_cast<size_t>( | |
| StringUtils::StringToValue<uint64_t>(token_iter->second).value_or(0)); | |
| } | |
| std::vector<std::string> page; | |
| std::optional<std::string> next_page_token; | |
| if (index < names.size()) { | |
| page.push_back(names[index]); | |
| if (index + 1 < names.size()) { | |
| next_page_token = std::to_string(index + 1); | |
| } | |
| } | |
| return {page, next_page_token}; | |
| } | |
| // Implements the subset of the rest catalog protocol used by `RestCatalog` on top of | |
| // `MockCatalogState`. | |
| MockRestServer::Response HandleCatalogRequest(MockCatalogState* state, | |
| const MockRestServer::Request& request) { | |
| std::lock_guard<std::mutex> lock(state->mutex); | |
| state->last_headers = request.headers; | |
| auto auth_iter = request.headers.find("authorization"); | |
| if (auth_iter == request.headers.end() || | |
| auth_iter->second != std::string("Bearer ") + kToken) { | |
| return MockError(401, "", "", "invalid token"); | |
| } | |
| if (request.path == "/v1/config") { | |
| auto warehouse_iter = request.query_params.find("warehouse"); | |
| if (warehouse_iter == request.query_params.end() || warehouse_iter->second != kWarehouse) { | |
| return MockError(400, "", "", "unexpected warehouse"); | |
| } | |
| ConfigResponse config( | |
| {{RestApi::kOptionUrlPrefix, kPrefix}, | |
| {"header.x-server-header", "from-config"}, | |
| {"table-default.write-only", "true"}, | |
| {"table-default.bucket", "8"}}, | |
| {{"server-override", "from-server"}, {"header.x-shared-header", "from-config"}}); | |
| return JsonResponse(200, config.ToJsonString().value()); | |
| } | |
| if (state->force_error_code) { | |
| return MockError(state->force_error_code.value(), "", "", "injected failure"); | |
| } | |
| const std::string base = std::string("/v1/") + kPrefix; | |
| if (request.path.rfind(base, 0) != 0) { | |
| return MockError(404, "", "", "unknown path " + request.path); | |
| } | |
| std::string rest = request.path.substr(base.size()); | |
| if (rest == "/databases") { | |
| if (request.method == "GET") { | |
| std::vector<std::string> names; | |
| for (const auto& [name, tables] : state->databases) { | |
| names.push_back(name); | |
| } | |
| auto [page, next_page_token] = PageOf(names, request); | |
| ListDatabasesResponse response(page, next_page_token); | |
| return JsonResponse(200, response.ToJsonString().value()); | |
| } | |
| if (request.method == "POST") { | |
| CreateDatabaseRequest create_request("", {}); | |
| if (!RapidJsonUtil::FromJsonString(request.body, &create_request).ok()) { | |
| return MockError(400, "", "", "bad create database request"); | |
| } | |
| if (state->databases.count(create_request.GetName()) > 0) { | |
| return MockError(409, ErrorResponse::kResourceTypeDatabase, | |
| create_request.GetName(), "database already exists"); | |
| } | |
| state->databases[create_request.GetName()] = {}; | |
| return JsonResponse(200, ""); | |
| } | |
| return MockError(400, "", "", "unsupported method"); | |
| } | |
| if (rest == "/tables/rename" && request.method == "POST") { | |
| RenameTableRequest rename_request("", "", "", ""); | |
| if (!RapidJsonUtil::FromJsonString(request.body, &rename_request).ok()) { | |
| return MockError(400, "", "", "bad rename table request"); | |
| } | |
| auto db_iter = state->databases.find(rename_request.GetSourceDatabase()); | |
| if (db_iter == state->databases.end() || | |
| db_iter->second.count(rename_request.GetSourceTable()) == 0) { | |
| return MockError(404, ErrorResponse::kResourceTypeTable, | |
| rename_request.GetSourceTable(), "table not found"); | |
| } | |
| auto& dest_tables = state->databases[rename_request.GetDestinationDatabase()]; | |
| if (dest_tables.count(rename_request.GetDestinationTable()) > 0) { | |
| return MockError(409, ErrorResponse::kResourceTypeTable, | |
| rename_request.GetDestinationTable(), "table already exists"); | |
| } | |
| dest_tables[rename_request.GetDestinationTable()] = | |
| db_iter->second[rename_request.GetSourceTable()]; | |
| db_iter->second.erase(rename_request.GetSourceTable()); | |
| return JsonResponse(200, ""); | |
| } | |
| const std::string databases_prefix = "/databases/"; | |
| if (rest.rfind(databases_prefix, 0) != 0) { | |
| return MockError(404, "", "", "unknown path " + request.path); | |
| } | |
| std::string remainder = rest.substr(databases_prefix.size()); | |
| size_t tables_pos = remainder.find("/tables"); | |
| const std::string token_suffix = "/token"; | |
| if (tables_pos != std::string::npos && remainder.size() > token_suffix.size() && | |
| remainder.compare(remainder.size() - token_suffix.size(), token_suffix.size(), | |
| token_suffix) == 0) { | |
| // The mock issues credentials for any table, so that a test can ask for the token | |
| // of a branch or of a table it did not seed. | |
| state->token_requests.push_back(request.path); | |
| std::map<std::string, std::string> credentials = {{"fs.oss.accessKeyId", "ak-1"}}; | |
| auto database = state->databases.find(remainder.substr(0, tables_pos)); | |
| if (database != state->databases.end()) { | |
| size_t table_start = tables_pos + std::strlen("/tables/"); | |
| std::string table_name = | |
| remainder.substr(table_start, remainder.size() - table_start - token_suffix.size()); | |
| auto table = database->second.find(table_name); | |
| if (table != database->second.end()) { | |
| credentials = table->second.token; | |
| } | |
| } | |
| GetTableTokenResponse token(credentials, kDataTokenExpiresAtMillis); | |
| return JsonResponse(200, token.ToJsonString().value()); | |
| } | |
| if (tables_pos == std::string::npos) { | |
| const std::string& db_name = remainder; | |
| auto db_iter = state->databases.find(db_name); | |
| if (request.method == "GET") { | |
| if (db_iter == state->databases.end()) { | |
| return MockError(404, ErrorResponse::kResourceTypeDatabase, db_name, | |
| "database not found"); | |
| } | |
| std::string body = fmt::format( | |
| R"({{"id":"1","name":"{}","location":"{}/{}.db","options":{{"dbk":"dbv"}}}})", | |
| db_name, kWarehouse, db_name); | |
| return JsonResponse(200, body); | |
| } | |
| if (request.method == "DELETE") { | |
| if (db_iter == state->databases.end()) { | |
| return MockError(404, ErrorResponse::kResourceTypeDatabase, db_name, | |
| "database not found"); | |
| } | |
| state->databases.erase(db_iter); | |
| return JsonResponse(200, ""); | |
| } | |
| return MockError(400, "", "", "unsupported method"); | |
| } | |
| std::string db_name = remainder.substr(0, tables_pos); | |
| auto db_iter = state->databases.find(db_name); | |
| if (db_iter == state->databases.end()) { | |
| return MockError(404, ErrorResponse::kResourceTypeDatabase, db_name, "database not found"); | |
| } | |
| auto& tables = db_iter->second; | |
| std::string table_part = remainder.substr(tables_pos + std::strlen("/tables")); | |
| if (table_part.empty()) { | |
| if (request.method == "GET") { | |
| std::vector<std::string> names; | |
| for (const auto& [name, table] : tables) { | |
| names.push_back(name); | |
| } | |
| auto [page, next_page_token] = PageOf(names, request); | |
| ListTablesResponse response(page, next_page_token); | |
| return JsonResponse(200, response.ToJsonString().value()); | |
| } | |
| if (request.method == "POST") { | |
| state->create_table_requests++; | |
| CreateTableRequest create_request("", "", ""); | |
| if (!RapidJsonUtil::FromJsonString(request.body, &create_request).ok()) { | |
| return MockError(400, "", "", "bad create table request"); | |
| } | |
| if (tables.count(create_request.GetTable()) > 0) { | |
| return MockError(409, ErrorResponse::kResourceTypeTable, create_request.GetTable(), | |
| "table already exists"); | |
| } | |
| MockCatalogState::TableData table; | |
| table.schema_json = create_request.GetSchemaJson(); | |
| table.schema_id = 0; | |
| table.path = fmt::format("{}/{}.db/{}", kWarehouse, db_name, create_request.GetTable()); | |
| tables[create_request.GetTable()] = table; | |
| return JsonResponse(200, ""); | |
| } | |
| return MockError(400, "", "", "unsupported method"); | |
| } | |
| std::string table_name = table_part.substr(1); | |
| bool list_snapshots = false; | |
| bool commit_table = false; | |
| bool load_snapshot = false; | |
| bool acquire_lock = false; | |
| bool renew_lock = false; | |
| const std::string snapshots_suffix = "/snapshots"; | |
| const std::string snapshot_suffix = "/snapshot"; | |
| const std::string commit_suffix = "/commit"; | |
| const std::string acquire_lock_suffix = "/commit-lock"; | |
| const std::string renew_lock_suffix = "/commit-lock/renew"; | |
| if (table_name.size() > renew_lock_suffix.size() && | |
| table_name.compare(table_name.size() - renew_lock_suffix.size(), renew_lock_suffix.size(), | |
| renew_lock_suffix) == 0) { | |
| table_name = table_name.substr(0, table_name.size() - renew_lock_suffix.size()); | |
| renew_lock = true; | |
| } else if (table_name.size() > acquire_lock_suffix.size() && | |
| table_name.compare(table_name.size() - acquire_lock_suffix.size(), | |
| acquire_lock_suffix.size(), acquire_lock_suffix) == 0) { | |
| table_name = table_name.substr(0, table_name.size() - acquire_lock_suffix.size()); | |
| acquire_lock = true; | |
| } else if (table_name.size() > snapshots_suffix.size() && | |
| table_name.compare(table_name.size() - snapshots_suffix.size(), | |
| snapshots_suffix.size(), snapshots_suffix) == 0) { | |
| table_name = table_name.substr(0, table_name.size() - snapshots_suffix.size()); | |
| list_snapshots = true; | |
| } else if (table_name.size() > commit_suffix.size() && | |
| table_name.compare(table_name.size() - commit_suffix.size(), commit_suffix.size(), | |
| commit_suffix) == 0) { | |
| table_name = table_name.substr(0, table_name.size() - commit_suffix.size()); | |
| commit_table = true; | |
| } else if (table_name.size() > snapshot_suffix.size() && | |
| table_name.compare(table_name.size() - snapshot_suffix.size(), | |
| snapshot_suffix.size(), snapshot_suffix) == 0) { | |
| table_name = table_name.substr(0, table_name.size() - snapshot_suffix.size()); | |
| load_snapshot = true; | |
| } | |
| auto table_iter = tables.find(table_name); | |
| if (table_iter == tables.end()) { | |
| return MockError(404, ErrorResponse::kResourceTypeTable, table_name, "table not found"); | |
| } | |
| if (acquire_lock || renew_lock) { | |
| if (request.method != "POST") { | |
| return MockError(400, "", "", "unsupported method"); | |
| } | |
| Result<CommitLockRequest> lock_request = CommitLockRequest::FromJsonString(request.body); | |
| if (!lock_request.ok()) { | |
| return MockError(400, "", "", "bad commit lock request"); | |
| } | |
| state->lock_requests.push_back(lock_request.value()); | |
| state->lock_tables.push_back(table_name); | |
| state->table_events.push_back(acquire_lock ? "acquire" : "renew"); | |
| bool contended = acquire_lock && state->contended_acquires > 0; | |
| if (contended) { | |
| --state->contended_acquires; | |
| } | |
| if (contended || (renew_lock && state->lose_on_renew)) { | |
| CommitLockResponse not_granted(/*acquired=*/false, /*commit_user=*/std::nullopt, | |
| /*expires_at_millis=*/0, /*lease_millis=*/0, | |
| /*snapshot=*/std::nullopt); | |
| return JsonResponse(200, not_granted.ToJsonString().value()); | |
| } | |
| CommitLockResponse response(/*acquired=*/true, lock_request.value().GetCommitUser(), | |
| /*expires_at_millis=*/1700000000000 + state->lease_millis, | |
| state->lease_millis, /*snapshot=*/std::nullopt); | |
| return JsonResponse(200, response.ToJsonString().value()); | |
| } | |
| if (commit_table) { | |
| if (request.method != "POST") { | |
| return MockError(400, "", "", "unsupported method"); | |
| } | |
| state->last_commit_body = request.body; | |
| state->last_commit_table = table_name; | |
| state->table_events.push_back("commit"); | |
| ++state->commit_requests; | |
| if (state->accept_commit_then_report_unavailable) { | |
| if (state->commit_requests == 1) { | |
| Result<CommitTableRequest> taken = CommitTableRequest::FromJsonString(request.body); | |
| if (taken.ok()) { | |
| Result<std::string> json = taken.value().GetSnapshot().ToJsonString(); | |
| if (json.ok()) { | |
| state->current_snapshot = json.value(); | |
| } | |
| } | |
| if (state->accepted_commit_response_code == 200) { | |
| return JsonResponse(200, R"({"success": null})"); | |
| } | |
| if (state->accepted_commit_response_code == 204) { | |
| return JsonResponse(204, ""); | |
| } | |
| MockRestServer::Response response = MockError(state->accepted_commit_response_code, | |
| "", "", "commit outcome unknown"); | |
| response.headers["Location"] = request.path; | |
| return response; | |
| } | |
| bool based_on_held = false; | |
| Result<CommitTableRequest> later = CommitTableRequest::FromJsonString(request.body); | |
| if (later.ok() && state->current_snapshot) { | |
| Result<Snapshot> held = Snapshot::FromJsonString(state->current_snapshot.value()); | |
| based_on_held = | |
| held.ok() && later.value().GetBaseSnapshotUuid() == held.value().Uuid(); | |
| if (based_on_held) { | |
| Result<std::string> json = later.value().GetSnapshot().ToJsonString(); | |
| if (json.ok()) { | |
| state->current_snapshot = json.value(); | |
| } | |
| } | |
| } | |
| return JsonResponse(200, CommitTableResponse(based_on_held).ToJsonString().value()); | |
| } | |
| if (state->break_commit_response) { | |
| return JsonResponse(200, "not json"); | |
| } | |
| if (state->commit_response_without_outcome) { | |
| return JsonResponse(200, R"({"success": null})"); | |
| } | |
| bool refuse = state->refuse_commit; | |
| if (state->refused_commits_left > 0) { | |
| --state->refused_commits_left; | |
| refuse = true; | |
| } | |
| CommitTableResponse response(!refuse); | |
| return JsonResponse(200, response.ToJsonString().value()); | |
| } | |
| if (load_snapshot) { | |
| if (request.method != "GET") { | |
| return MockError(400, "", "", "unsupported method"); | |
| } | |
| state->last_snapshot_table = table_name; | |
| state->table_events.push_back("snapshot"); | |
| if (state->snapshot_delay_millis > 0) { | |
| std::this_thread::sleep_for(std::chrono::milliseconds(state->snapshot_delay_millis)); | |
| } | |
| if (state->null_snapshot_response) { | |
| return JsonResponse(200, R"({"snapshot": null})"); | |
| } | |
| if (!state->current_snapshot) { | |
| return MockError(404, ErrorResponse::kResourceTypeSnapshot, table_name, | |
| "snapshot not found"); | |
| } | |
| return JsonResponse(200, fmt::format(R"({{"snapshot":{{"snapshot":{},"recordCount":1,)" | |
| R"("fileSizeInBytes":2,"fileCount":3,)" | |
| R"("lastFileCreationTime":4}}}})", | |
| state->current_snapshot.value())); | |
| } | |
| if (list_snapshots) { | |
| // two pages, out of order to exercise pagination and sorting | |
| auto token_iter = request.query_params.find(RestApi::kQueryParamPageToken); | |
| if (token_iter == request.query_params.end()) { | |
| return JsonResponse( | |
| 200, fmt::format(R"({{"snapshots":[{}],"nextPageToken":"1"}})", SnapshotJson(2))); | |
| } | |
| return JsonResponse(200, fmt::format(R"({{"snapshots":[{}]}})", SnapshotJson(1))); | |
| } | |
| if (request.method == "GET") { | |
| state->get_table_requests++; | |
| if (state->get_table_delay_millis > 0) { | |
| std::this_thread::sleep_for(std::chrono::milliseconds(state->get_table_delay_millis)); | |
| } | |
| return JsonResponse(200, TableResponseJson(table_name, table_iter->second)); | |
| } | |
| if (request.method == "DELETE") { | |
| tables.erase(table_iter); | |
| return JsonResponse(200, ""); | |
| } | |
| return MockError(400, "", "", "unsupported method"); | |
| } | |
| } // namespace | |
| class RestCatalogTest : public ::testing::Test { | |
| protected: | |
| void SetUp() override { | |
| state_ = std::make_shared<MockCatalogState>(); | |
| ASSERT_OK_AND_ASSIGN( | |
| server_, MockRestServer::Start([state = state_](const MockRestServer::Request& req) { | |
| return HandleCatalogRequest(state.get(), req); | |
| })); | |
| options_ = { | |
| {CatalogOptions::METASTORE, "rest"}, | |
| {CatalogOptions::URI, server_->GetBaseUri()}, | |
| {CatalogOptions::TOKEN_PROVIDER, "bear"}, | |
| {CatalogOptions::TOKEN, kToken}, | |
| {Options::FILE_SYSTEM, "local"}, | |
| // mock_format is linked statically into the test binary, so its factory is | |
| // registered in the binary's own registry even when the real format plugin | |
| // dylibs register into a different one (macOS two-level namespace) | |
| {Options::FILE_FORMAT, "mock_format"}, | |
| {"header.x-client-header", "from-client"}, | |
| {"header.x-shared-header", "from-client"}, | |
| }; | |
| } | |
| void TearDown() override { | |
| if (server_) { | |
| server_->Stop(); | |
| } | |
| } | |
| Result<std::unique_ptr<RestCatalog>> CreateRestCatalog( | |
| const RestHttpClient::Config& http_config = RestHttpClient::Config()) { | |
| return RestCatalog::Create(kWarehouse, options_, nullptr, http_config); | |
| } | |
| static RestHttpClient::Config FastRetryConfig() { | |
| RestHttpClient::Config config; | |
| config.retry_base_delay_ms = 1; | |
| return config; | |
| } | |
| Status CreateSampleTable(Catalog* catalog, const Identifier& identifier, | |
| bool ignore_if_exists = false) { | |
| std::shared_ptr<arrow::Schema> schema = | |
| arrow::schema({arrow::field("f0", arrow::int32(), /*nullable=*/false), | |
| arrow::field("f1", arrow::utf8())}); | |
| struct ArrowSchema c_schema; | |
| if (!arrow::ExportSchema(*schema, &c_schema).ok()) { | |
| return Status::Invalid("failed to export arrow schema"); | |
| } | |
| Status status = | |
| catalog->CreateTable(identifier, &c_schema, /*partition_keys=*/{}, | |
| /*primary_keys=*/{"f0"}, {{"bucket", "2"}}, ignore_if_exists); | |
| // CreateTable takes ownership of the exported schema only once it reaches | |
| // arrow::ImportSchema, which an identifier rejected by its checks never does | |
| if (c_schema.release != nullptr) { | |
| c_schema.release(&c_schema); | |
| } | |
| return status; | |
| } | |
| // Seeds `schema_json` as table `table_name` of "db1" behind the mock server and | |
| // expects loading the table to fail with an Invalid status carrying | |
| // `expected_message`. | |
| void ExpectBrokenSchemaRejected(Catalog* catalog, const std::string& table_name, | |
| const std::string& schema_json, | |
| const std::string& expected_message) { | |
| MockCatalogState::TableData table_data; | |
| table_data.schema_json = schema_json; | |
| table_data.path = "wh1/db1.db/" + table_name; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"][table_name] = table_data; | |
| } | |
| Status status = catalog->GetTable(Identifier("db1", table_name)).status(); | |
| ASSERT_TRUE(status.IsInvalid()) << status.ToString(); | |
| ASSERT_NOK_WITH_MSG(status, expected_message); | |
| } | |
| std::shared_ptr<MockCatalogState> state_; | |
| std::unique_ptr<MockRestServer> server_; | |
| std::map<std::string, std::string> options_; | |
| // Path the data token of `database`.`table` is requested at. | |
| static std::string TokenPath(const std::string& database, const std::string& table) { | |
| return fmt::format("/v1/{}/databases/{}/tables/{}/token", kPrefix, database, table); | |
| } | |
| std::vector<std::string> TokenRequests() { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| return state_->token_requests; | |
| } | |
| }; | |
| TEST_F(RestCatalogTest, CreateMergesServerConfig) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| const std::map<std::string, std::string>& merged = catalog->GetOptions(); | |
| ASSERT_EQ(kPrefix, merged.at(RestApi::kOptionUrlPrefix)); | |
| ASSERT_EQ("from-server", merged.at("server-override")); | |
| ASSERT_EQ(kWarehouse, catalog->GetRootPath()); | |
| ASSERT_NE(nullptr, catalog->GetFileSystem()); | |
| } | |
| TEST_F(RestCatalogTest, TableFileSystemWithoutDataToken) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> fs, | |
| catalog->GetTableFileSystem(Identifier("db1", "t1"))); | |
| // without the data token the catalog wide credentials are used for the data as well | |
| ASSERT_EQ(catalog->GetFileSystem(), fs); | |
| } | |
| TEST_F(RestCatalogTest, TableFileSystemKeepsAnExplicitlySuppliedFileSystem) { | |
| options_[CatalogOptions::DATA_TOKEN_ENABLED] = "true"; | |
| std::unique_ptr<UniqueTestDirectory> dir = UniqueTestDirectory::Create(); | |
| ASSERT_NE(nullptr, dir); | |
| std::shared_ptr<FileSystem> custom_fs = dir->GetFileSystem(); | |
| ASSERT_NE(nullptr, custom_fs); | |
| // A file system supplied to Create authenticates its own accesses and is used as-is, so | |
| // it is handed out for the table data even though the server issues data tokens: | |
| // rebuilding a delegate from the options would discard it and its CredentialProvider. | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, | |
| RestCatalog::Create(kWarehouse, options_, custom_fs)); | |
| ASSERT_EQ(custom_fs, catalog->GetFileSystem()); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> table_fs, | |
| catalog->GetTableFileSystem(Identifier("db1", "t1"))); | |
| ASSERT_EQ(custom_fs, table_fs); | |
| // Handing out the supplied file system asks the server for no data token. | |
| ASSERT_TRUE(TokenRequests().empty()); | |
| // The same options without a supplied file system do build a token file system, so the | |
| // identity above comes from the supplied file system, not from data tokens being off. | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> token_catalog, CreateRestCatalog()); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> token_fs, | |
| token_catalog->GetTableFileSystem(Identifier("db1", "t1"))); | |
| ASSERT_NE(custom_fs, token_fs); | |
| } | |
| TEST_F(RestCatalogTest, TableFileSystemWithDataToken) { | |
| options_[CatalogOptions::DATA_TOKEN_ENABLED] = "true"; | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> fs, | |
| catalog->GetTableFileSystem(Identifier("db1", "t1"))); | |
| ASSERT_NE(nullptr, fs); | |
| ASSERT_NE(catalog->GetFileSystem(), fs); | |
| // building it asks the server for nothing: the credentials of the table it is bound to | |
| // are loaded when it is first used | |
| ASSERT_TRUE(TokenRequests().empty()); | |
| ASSERT_OK_AND_ASSIGN(bool exists, fs->Exists("/no-such-file")); | |
| ASSERT_FALSE(exists); | |
| ASSERT_EQ(std::vector<std::string>({TokenPath("db1", "t1")}), TokenRequests()); | |
| // a system table reads the files of the table it belongs to, so it is served the | |
| // credentials of that table | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> system_table_fs, | |
| catalog->GetTableFileSystem(Identifier("db1", "t1$snapshots"))); | |
| ASSERT_OK(system_table_fs->Exists("/no-such-file").status()); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> other_table_fs, | |
| catalog->GetTableFileSystem(Identifier("db1", "t2"))); | |
| ASSERT_OK(other_table_fs->Exists("/no-such-file").status()); | |
| ASSERT_EQ(std::vector<std::string>( | |
| {TokenPath("db1", "t1"), TokenPath("db1", "t1"), TokenPath("db1", "t2")}), | |
| TokenRequests()); | |
| } | |
| TEST_F(RestCatalogTest, DataTokenFileSystemResolvesSchemesThroughTheIdentifierMap) { | |
| options_[CatalogOptions::DATA_TOKEN_ENABLED] = "true"; | |
| // The merged options resolve the local scheme through "local", but a scheme-to-identifier | |
| // map routes it to a file system that does not exist. The map must reach both the | |
| // catalog-level file system and the delegate a data token builds. | |
| ASSERT_OK_AND_ASSIGN( | |
| std::unique_ptr<RestCatalog> catalog, | |
| RestCatalog::Create(kWarehouse, options_, /*file_system=*/nullptr, RestHttpClient::Config(), | |
| {{"file", "no-such-file-system"}})); | |
| // the catalog-level file system routes the local scheme to the missing backend | |
| Status catalog_fs_status = catalog->GetFileSystem()->Exists("/no-such-file").status(); | |
| ASSERT_NOK(catalog_fs_status); | |
| ASSERT_NOK_WITH_MSG(catalog_fs_status, "no-such-file-system"); | |
| // the delegate the data token builds routes it the same way | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> table_fs, | |
| catalog->GetTableFileSystem(Identifier("db1", "t1"))); | |
| Status table_fs_status = table_fs->Exists("/no-such-file").status(); | |
| ASSERT_NOK(table_fs_status); | |
| ASSERT_NOK_WITH_MSG(table_fs_status, "no-such-file-system"); | |
| } | |
| TEST_F(RestCatalogTest, TableFileSystemIsBoundToTheTableItWasAskedFor) { | |
| options_[CatalogOptions::DATA_TOKEN_ENABLED] = "true"; | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| // the database and the table are addressed separately, so identifiers that print the | |
| // same must not be served the credentials of one another | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> dotted_database, | |
| catalog->GetTableFileSystem(Identifier("db1.a", "t1"))); | |
| ASSERT_OK(dotted_database->Exists("/no-such-file").status()); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> dotted_table, | |
| catalog->GetTableFileSystem(Identifier("db1", "a.t1"))); | |
| ASSERT_OK(dotted_table->Exists("/no-such-file").status()); | |
| ASSERT_EQ(std::vector<std::string>({TokenPath("db1.a", "t1"), TokenPath("db1", "a.t1")}), | |
| TokenRequests()); | |
| } | |
| TEST_F(RestCatalogTest, TableFileSystemNormalizesTheBranch) { | |
| options_[CatalogOptions::DATA_TOKEN_ENABLED] = "true"; | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| // the main branch is the table itself, so it shares the credentials | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> main_branch_fs, | |
| catalog->GetTableFileSystem(Identifier("db1", "t1$branch_main"))); | |
| ASSERT_OK(main_branch_fs->Exists("/no-such-file").status()); | |
| // another branch is addressed as its own object on the server, so it gets its own | |
| // credentials | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> branch_fs, | |
| catalog->GetTableFileSystem(Identifier("db1", "t1$branch_b1"))); | |
| ASSERT_OK(branch_fs->Exists("/no-such-file").status()); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> branch_system_fs, | |
| catalog->GetTableFileSystem(Identifier("db1", "t1$branch_b1$snapshots"))); | |
| ASSERT_OK(branch_system_fs->Exists("/no-such-file").status()); | |
| ASSERT_EQ(std::vector<std::string>({TokenPath("db1", "t1"), TokenPath("db1", "t1$branch_b1"), | |
| TokenPath("db1", "t1$branch_b1")}), | |
| TokenRequests()); | |
| } | |
| TEST_F(RestCatalogTest, TableFileSystemIsNotRetainedPerTable) { | |
| options_[CatalogOptions::DATA_TOKEN_ENABLED] = "true"; | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| // The catalog remembers no file system of a table, so a table dropped and recreated at | |
| // another location is never served the credentials of the dropped one. What is cached | |
| // and bounded are the file systems built from the credentials, keyed by them. | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> first, | |
| catalog->GetTableFileSystem(Identifier("db1", "t1"))); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> second, | |
| catalog->GetTableFileSystem(Identifier("db1", "t1"))); | |
| ASSERT_NE(first, second); | |
| ASSERT_OK(first->Exists("/no-such-file").status()); | |
| ASSERT_OK(second->Exists("/no-such-file").status()); | |
| ASSERT_EQ(std::vector<std::string>({TokenPath("db1", "t1"), TokenPath("db1", "t1")}), | |
| TokenRequests()); | |
| } | |
| TEST_F(RestCatalogTest, RecreatedTableLoadsNewCredentialsBeforeOldTokenExpires) { | |
| options_[CatalogOptions::DATA_TOKEN_ENABLED] = "true"; | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| Identifier identifier("db1", "t1"); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| ASSERT_OK_AND_ASSIGN(std::string old_location, catalog->GetTableLocation(identifier)); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> first, | |
| catalog->GetTableFileSystem(identifier)); | |
| std::shared_ptr<RestTokenFileSystem> first_token_fs = | |
| std::dynamic_pointer_cast<RestTokenFileSystem>(first); | |
| ASSERT_NE(nullptr, first_token_fs); | |
| ASSERT_OK_AND_ASSIGN(RestToken old_token, first_token_fs->ValidToken()); | |
| ASSERT_EQ("ak-1", old_token.token.at("fs.oss.accessKeyId")); | |
| ASSERT_OK(catalog->DropTable(identifier, /*ignore_if_not_exists=*/false)); | |
| ASSERT_OK_AND_ASSIGN(bool exists, catalog->TableExists(identifier)); | |
| ASSERT_FALSE(exists); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| std::string new_location = old_location + "-recreated"; | |
| { | |
| // the server hands the recreated table another location and other credentials, while | |
| // the expiration of the credentials of the dropped table stays the same | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| MockCatalogState::TableData& table = state_->databases.at("db1").at("t1"); | |
| table.path = new_location; | |
| table.token = {{"fs.oss.accessKeyId", "ak-2"}}; | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::string location, catalog->GetTableLocation(identifier)); | |
| ASSERT_EQ(new_location, location); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileSystem> second, | |
| catalog->GetTableFileSystem(identifier)); | |
| ASSERT_NE(first, second); | |
| std::shared_ptr<RestTokenFileSystem> second_token_fs = | |
| std::dynamic_pointer_cast<RestTokenFileSystem>(second); | |
| ASSERT_NE(nullptr, second_token_fs); | |
| ASSERT_OK_AND_ASSIGN(RestToken new_token, second_token_fs->ValidToken()); | |
| ASSERT_EQ("ak-2", new_token.token.at("fs.oss.accessKeyId")); | |
| ASSERT_EQ(old_token.expires_at_millis, new_token.expires_at_millis); | |
| ASSERT_EQ(kDataTokenExpiresAtMillis, new_token.expires_at_millis); | |
| ASSERT_EQ(std::vector<std::string>({TokenPath("db1", "t1"), TokenPath("db1", "t1")}), | |
| TokenRequests()); | |
| } | |
| TEST_F(RestCatalogTest, CatalogFactoryMetastoreDispatch) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<Catalog> catalog, Catalog::Create(kWarehouse, options_)); | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::string> databases, catalog->ListDatabases()); | |
| ASSERT_TRUE(databases.empty()); | |
| options_[CatalogOptions::METASTORE] = "something-else"; | |
| Status status = Catalog::Create(kWarehouse, options_).status(); | |
| ASSERT_TRUE(status.IsInvalid()) << status.ToString(); | |
| ASSERT_NOK_WITH_MSG(status, "unsupported metastore"); | |
| } | |
| TEST_F(RestCatalogTest, CreateWithWrongTokenFails) { | |
| options_[CatalogOptions::TOKEN] = "wrong-token"; | |
| ASSERT_NOK_WITH_MSG(CreateRestCatalog().status(), "not authorized"); | |
| } | |
| TEST_F(RestCatalogTest, CreateRejectsInvalidOptions) { | |
| // all rejected by client side validation, before any request reaches the server | |
| const std::map<std::string, std::string> valid_options = options_; | |
| options_.erase(CatalogOptions::URI); | |
| ASSERT_NOK_WITH_MSG(CreateRestCatalog().status(), "'uri' must be configured"); | |
| options_ = valid_options; | |
| options_.erase(CatalogOptions::TOKEN_PROVIDER); | |
| ASSERT_NOK_WITH_MSG(CreateRestCatalog().status(), "'token.provider' must be configured"); | |
| options_ = valid_options; | |
| options_[CatalogOptions::TOKEN_PROVIDER] = "unsupported"; | |
| Status unsupported_provider = CreateRestCatalog().status(); | |
| ASSERT_TRUE(unsupported_provider.IsNotImplemented()) << unsupported_provider.ToString(); | |
| ASSERT_NOK_WITH_MSG(unsupported_provider, "unsupported token provider"); | |
| options_ = valid_options; | |
| options_.erase(CatalogOptions::TOKEN); | |
| ASSERT_NOK_WITH_MSG(CreateRestCatalog().status(), "bear token provider"); | |
| } | |
| TEST_F(RestCatalogTest, DatabaseOperations) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| ASSERT_OK(catalog->CreateDatabase("db2", {}, /*ignore_if_exists=*/false)); | |
| Status duplicated = catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false); | |
| ASSERT_TRUE(duplicated.IsExist()) << duplicated.ToString(); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/true)); | |
| // the mock server returns one database per page | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::string> databases, catalog->ListDatabases()); | |
| ASSERT_EQ((std::vector<std::string>{"db1", "db2"}), databases); | |
| ASSERT_OK_AND_ASSIGN(bool exists, catalog->DatabaseExists("db1")); | |
| ASSERT_TRUE(exists); | |
| ASSERT_OK_AND_ASSIGN(exists, catalog->DatabaseExists("db3")); | |
| ASSERT_FALSE(exists); | |
| ASSERT_OK_AND_ASSIGN(std::string db1_location, catalog->GetDatabaseLocation("db1")); | |
| ASSERT_EQ("wh1/db1.db", db1_location); | |
| // the location is resolved on the server, so an unknown database is reported as an error | |
| Status no_location = catalog->GetDatabaseLocation("db3").status(); | |
| ASSERT_TRUE(no_location.IsNotExist()) << no_location.ToString(); | |
| // the virtual "sys" database is never asked about and has no location | |
| ASSERT_OK_AND_ASSIGN(std::string sys_location, catalog->GetDatabaseLocation("sys")); | |
| ASSERT_EQ("", sys_location); | |
| ASSERT_OK(catalog->DropDatabase("db2", /*ignore_if_not_exists=*/false, /*cascade=*/false)); | |
| ASSERT_OK(catalog->DropDatabase("db2", /*ignore_if_not_exists=*/true, /*cascade=*/false)); | |
| Status missing = catalog->DropDatabase("db2", /*ignore_if_not_exists=*/false, | |
| /*cascade=*/false); | |
| ASSERT_TRUE(missing.IsNotExist()) << missing.ToString(); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t1"))); | |
| ASSERT_NOK_WITH_MSG( | |
| catalog->DropDatabase("db1", /*ignore_if_not_exists=*/false, /*cascade=*/false), | |
| "non-empty database"); | |
| // cascade drop skips the emptiness check | |
| ASSERT_OK(catalog->DropDatabase("db1", /*ignore_if_not_exists=*/false, /*cascade=*/true)); | |
| ASSERT_OK_AND_ASSIGN(exists, catalog->DatabaseExists("db1")); | |
| ASSERT_FALSE(exists); | |
| } | |
| TEST_F(RestCatalogTest, TableOperations) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| Identifier identifier("db1", "t1"); | |
| Status missing_db = CreateSampleTable(catalog.get(), Identifier("db_missing", "t1")); | |
| ASSERT_TRUE(missing_db.IsNotExist()) << missing_db.ToString(); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| Status duplicated = CreateSampleTable(catalog.get(), identifier); | |
| ASSERT_TRUE(duplicated.IsExist()) << duplicated.ToString(); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier, /*ignore_if_exists=*/true)); | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::string> tables, catalog->ListTables("db1")); | |
| ASSERT_EQ((std::vector<std::string>{"t1"}), tables); | |
| Status list_missing = catalog->ListTables("db_missing").status(); | |
| ASSERT_TRUE(list_missing.IsNotExist()) << list_missing.ToString(); | |
| ASSERT_OK_AND_ASSIGN(bool exists, catalog->TableExists(identifier)); | |
| ASSERT_TRUE(exists); | |
| ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("db1", "t2"))); | |
| ASSERT_FALSE(exists); | |
| ASSERT_OK_AND_ASSIGN(std::string location, catalog->GetTableLocation(identifier)); | |
| ASSERT_EQ("wh1/db1.db/t1", location); | |
| int32_t requests_before_get_table = 0; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| requests_before_get_table = state_->get_table_requests; | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table, catalog->GetTable(identifier)); | |
| ASSERT_EQ("t1", table->Name()); | |
| ASSERT_EQ("1", table->Uuid()); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(requests_before_get_table + 1, state_->get_table_requests); | |
| } | |
| std::shared_ptr<DataSchema> schema = | |
| std::dynamic_pointer_cast<DataSchema>(table->LatestSchema()); | |
| ASSERT_NE(nullptr, schema); | |
| ASSERT_EQ((std::vector<std::string>{"f0", "f1"}), schema->FieldNames()); | |
| ASSERT_EQ((std::vector<std::string>{"f0"}), schema->PrimaryKeys()); | |
| ASSERT_EQ(0, schema->Id()); | |
| // options are enriched with the table path and audit info from the server | |
| ASSERT_EQ("wh1/db1.db/t1", schema->Options().at("path")); | |
| ASSERT_EQ("owner1", schema->Options().at("owner")); | |
| // "table-default." options of the merged config apply only where the caller left the | |
| // option unset: "write-only" is taken from the config, "bucket" keeps the value passed | |
| // to CreateTable instead of the configured "table-default.bucket" of 8 | |
| ASSERT_EQ("true", schema->Options().at("write-only")); | |
| ASSERT_EQ("2", schema->Options().at("bucket")); | |
| // timeMillis is backed by the server's audit "updatedAt" instead of the current | |
| // time, keeping the conversion deterministic | |
| std::shared_ptr<TableSchema> table_schema = | |
| std::dynamic_pointer_cast<TableSchema>(table->LatestSchema()); | |
| ASSERT_NE(nullptr, table_schema); | |
| ASSERT_EQ(123, table_schema->TimeMillis()); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Schema> loaded_schema, | |
| catalog->LoadTableSchema(identifier)); | |
| ASSERT_EQ((std::vector<std::string>{"f0", "f1"}), loaded_schema->FieldNames()); | |
| Status schema_missing = catalog->LoadTableSchema(Identifier("db1", "t2")).status(); | |
| ASSERT_TRUE(schema_missing.IsNotExist()) << schema_missing.ToString(); | |
| // a schema comment of the server response is carried into the table schema | |
| MockCatalogState::TableData commented; | |
| commented.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {},)" | |
| R"( "comment": "a table comment"})"; | |
| commented.path = "wh1/db1.db/commented"; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["commented"] = commented; | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> commented_table, | |
| catalog->GetTable(Identifier("db1", "commented"))); | |
| ASSERT_EQ("a table comment", commented_table->LatestSchema()->Comment().value_or("")); | |
| ASSERT_OK(catalog->RenameTable(identifier, Identifier("db1", "t2"), | |
| /*ignore_if_not_exists=*/false)); | |
| ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("db1", "t2"))); | |
| ASSERT_TRUE(exists); | |
| ASSERT_OK(catalog->RenameTable(identifier, Identifier("db1", "t3"), | |
| /*ignore_if_not_exists=*/true)); | |
| Status rename_missing = catalog->RenameTable(identifier, Identifier("db1", "t3"), | |
| /*ignore_if_not_exists=*/false); | |
| ASSERT_TRUE(rename_missing.IsNotExist()) << rename_missing.ToString(); | |
| ASSERT_OK(catalog->DropTable(Identifier("db1", "t2"), /*ignore_if_not_exists=*/false)); | |
| ASSERT_OK(catalog->DropTable(Identifier("db1", "t2"), /*ignore_if_not_exists=*/true)); | |
| Status drop_missing = catalog->DropTable(Identifier("db1", "t2"), | |
| /*ignore_if_not_exists=*/false); | |
| ASSERT_TRUE(drop_missing.IsNotExist()) << drop_missing.ToString(); | |
| } | |
| TEST_F(RestCatalogTest, TableUuidFallsBackToFullNameWithoutServerId) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, false)); | |
| Identifier identifier("db1", "t1"); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["t1"].id.clear(); | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table, catalog->GetTable(identifier)); | |
| ASSERT_EQ("db1.t1", table->Uuid()); | |
| ASSERT_EQ(table->CatalogUuid(), std::nullopt); | |
| ASSERT_OK_AND_ASSIGN(bool committed, | |
| catalog->CommitSnapshot(identifier, table->CatalogUuid(), std::nullopt, | |
| BuildTestSnapshot(1), {})); | |
| ASSERT_TRUE(committed); | |
| std::string commit_body; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| commit_body = state_->last_commit_body; | |
| } | |
| ASSERT_OK_AND_ASSIGN(CommitTableRequest sent, CommitTableRequest::FromJsonString(commit_body)); | |
| ASSERT_EQ(sent.GetTableId(), std::nullopt); | |
| ASSERT_NE(commit_body.find("\"tableId\": null"), std::string::npos) << commit_body; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["t1"].id = "server-side-id"; | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> identified, catalog->GetTable(identifier)); | |
| ASSERT_EQ(identified->CatalogUuid(), std::optional<std::string>("server-side-id")); | |
| ASSERT_EQ("server-side-id", identified->Uuid()); | |
| } | |
| TEST_F(RestCatalogTest, ClientAndServerHeadersAreSent) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::string> databases, catalog->ListDatabases()); | |
| // "header." options from both the client and the merged server config are sent as | |
| // http headers on every request | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ("from-client", state_->last_headers.at("x-client-header")); | |
| ASSERT_EQ("from-config", state_->last_headers.at("x-server-header")); | |
| // when the client and the server config set the same "header." option, the | |
| // merged config wins (overrides > client options > defaults) | |
| ASSERT_EQ("from-config", state_->last_headers.at("x-shared-header")); | |
| ASSERT_EQ(std::string("Bearer ") + kToken, state_->last_headers.at("authorization")); | |
| } | |
| // a request carrying a body declares the json content type (set before the auth | |
| // headers are merged, so a signing auth provider covers it) | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ("application/json", state_->last_headers.at("content-type")); | |
| } | |
| } | |
| TEST_F(RestCatalogTest, SystemTableSchema) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t1"))); | |
| // the "options" system table has a static schema and needs no file system access | |
| Identifier system_identifier("db1", "t1$options"); | |
| ASSERT_OK_AND_ASSIGN(bool exists, catalog->TableExists(system_identifier)); | |
| ASSERT_TRUE(exists); | |
| ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("db1", "t9$options"))); | |
| ASSERT_FALSE(exists); | |
| ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("db1", "t1$unsupported"))); | |
| ASSERT_FALSE(exists); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Schema> schema, | |
| catalog->LoadTableSchema(system_identifier)); | |
| ASSERT_EQ((std::vector<std::string>{"key", "value"}), schema->FieldNames()); | |
| int32_t requests_before = 0; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| requests_before = state_->get_table_requests; | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table, catalog->GetTable(system_identifier)); | |
| ASSERT_EQ("t1$options", table->Name()); | |
| ASSERT_EQ((std::vector<std::string>{"key", "value"}), table->LatestSchema()->FieldNames()); | |
| ASSERT_EQ("db1.t1$options", table->Uuid()); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(requests_before + 1, state_->get_table_requests); | |
| } | |
| Status unsupported = catalog->LoadTableSchema(Identifier("db1", "t1$unsupported")).status(); | |
| ASSERT_TRUE(unsupported.IsNotExist()) << unsupported.ToString(); | |
| } | |
| TEST_F(RestCatalogTest, BranchTableLoadsBranchSchemaFromServer) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t1"))); | |
| // the identifier sent to the server keeps the branch, so the server resolves it and | |
| // returns the branch's own schema. The path it reports is the data table root, not | |
| // the branch subdirectory: readers derive "<path>/branch/branch-<name>" from the | |
| // branch option, so a branch path here would be applied twice | |
| MockCatalogState::TableData branch_data; | |
| branch_data.schema_json = R"({"fields": [{"id": 0, "name": "b0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {}})"; | |
| branch_data.schema_id = 3; | |
| branch_data.path = "wh1/db1.db/t1"; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["t1$branch_b1"] = branch_data; | |
| } | |
| Identifier branch_identifier("db1", "t1$branch_b1"); | |
| ASSERT_OK_AND_ASSIGN(bool exists, catalog->TableExists(branch_identifier)); | |
| ASSERT_TRUE(exists); | |
| // a branch the server does not know is missing instead of silently falling back to | |
| // the main table | |
| ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("db1", "t1$branch_missing"))); | |
| ASSERT_FALSE(exists); | |
| ASSERT_OK_AND_ASSIGN(std::string location, catalog->GetTableLocation(branch_identifier)); | |
| ASSERT_EQ("wh1/db1.db/t1", location); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table, catalog->GetTable(branch_identifier)); | |
| ASSERT_EQ("t1$branch_b1", table->Name()); | |
| std::shared_ptr<DataSchema> schema = | |
| std::dynamic_pointer_cast<DataSchema>(table->LatestSchema()); | |
| ASSERT_NE(nullptr, schema); | |
| // the branch's own schema and schema id, not the main table's | |
| ASSERT_EQ((std::vector<std::string>{"b0"}), schema->FieldNames()); | |
| ASSERT_EQ(3, schema->Id()); | |
| ASSERT_EQ("b1", schema->Options().at(Options::BRANCH)); | |
| // the default branch is addressed as the bare table: "t1$branch_main" resolves to | |
| // "t1" and carries no branch option | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> main_table, | |
| catalog->GetTable(Identifier("db1", "t1$branch_main"))); | |
| std::shared_ptr<DataSchema> main_schema = | |
| std::dynamic_pointer_cast<DataSchema>(main_table->LatestSchema()); | |
| ASSERT_NE(nullptr, main_schema); | |
| ASSERT_EQ((std::vector<std::string>{"f0", "f1"}), main_schema->FieldNames()); | |
| ASSERT_EQ(0, main_schema->Options().count(Options::BRANCH)); | |
| // the default branch is matched ignoring case, as in the Java client, so | |
| // "t1$branch_MAIN" addresses the bare table too | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> main_case_table, | |
| catalog->GetTable(Identifier("db1", "t1$branch_MAIN"))); | |
| std::shared_ptr<DataSchema> main_case_schema = | |
| std::dynamic_pointer_cast<DataSchema>(main_case_table->LatestSchema()); | |
| ASSERT_NE(nullptr, main_case_schema); | |
| ASSERT_EQ((std::vector<std::string>{"f0", "f1"}), main_case_schema->FieldNames()); | |
| ASSERT_EQ(0, main_case_schema->Options().count(Options::BRANCH)); | |
| // the branch the alias spells resolves to the main branch, not to a branch | |
| // directory named after the alias | |
| ASSERT_OK_AND_ASSIGN(std::string main_case_branch, main_case_table->CurrentBranch()); | |
| ASSERT_EQ("main", main_case_branch); | |
| // a system table on a branch resolves against the branch's data table: the system | |
| // suffix is stripped while the branch stays in the identifier sent to the server | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Schema> options_schema, | |
| catalog->LoadTableSchema(Identifier("db1", "t1$branch_b1$options"))); | |
| ASSERT_EQ((std::vector<std::string>{"key", "value"}), options_schema->FieldNames()); | |
| // a missing branch fails through the system table path too instead of silently | |
| // resolving against the main table | |
| Status missing_branch = | |
| catalog->LoadTableSchema(Identifier("db1", "t1$branch_missing$options")).status(); | |
| ASSERT_TRUE(missing_branch.IsNotExist()) << missing_branch.ToString(); | |
| ASSERT_NOK_WITH_MSG(catalog->DropTable(branch_identifier, /*ignore_if_not_exists=*/false), | |
| "branch table"); | |
| ASSERT_NOK_WITH_MSG(catalog->RenameTable(branch_identifier, Identifier("db1", "t2"), | |
| /*ignore_if_not_exists=*/false), | |
| "branch table"); | |
| ASSERT_NOK_WITH_MSG(CreateSampleTable(catalog.get(), Identifier("db1", "t2$branch_b1")), | |
| "branch table"); | |
| } | |
| TEST_F(RestCatalogTest, CreateRefusesAFormatTableThisClientCouldNotOpen) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| // The server takes schemas this library cannot open. Without the same checks the file system | |
| // catalog runs, creating through this client would succeed and opening the very same table | |
| // through it would fail. | |
| auto create = [&catalog](const std::string& name, const std::shared_ptr<arrow::Schema>& schema, | |
| const std::vector<std::string>& partition_keys, | |
| const std::map<std::string, std::string>& options) { | |
| struct ArrowSchema c_schema; | |
| EXPECT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok()); | |
| Status status = catalog->CreateTable(Identifier("db1", name), &c_schema, partition_keys, | |
| /*primary_keys=*/{}, options, false); | |
| if (c_schema.release != nullptr) { | |
| c_schema.release(&c_schema); | |
| } | |
| return status; | |
| }; | |
| const std::map<std::string, std::string> format_table = {{Options::TYPE, "format-table"}, | |
| {Options::FILE_FORMAT, "parquet"}}; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->create_table_requests = 0; | |
| } | |
| // Every column a partition column leaves the data files with nothing in them. | |
| std::shared_ptr<arrow::Schema> only_partitions = | |
| arrow::schema({arrow::field("dt", arrow::utf8())}); | |
| ASSERT_NOK_WITH_MSG(create("only_partitions", only_partitions, {"dt"}, format_table), | |
| "every one of its columns"); | |
| // A partition type this library does not support. | |
| std::shared_ptr<arrow::Schema> timestamp_partition = | |
| arrow::schema({arrow::field("f0", arrow::int32()), | |
| arrow::field("dt", arrow::timestamp(arrow::TimeUnit::MICRO))}); | |
| ASSERT_NOK_WITH_MSG(create("timestamp_partition", timestamp_partition, {"dt"}, format_table), | |
| "cannot be TIMESTAMP/DECIMAL/BLOB"); | |
| // A format table format with no reader here. | |
| std::shared_ptr<arrow::Schema> good = | |
| arrow::schema({arrow::field("f0", arrow::int32()), arrow::field("dt", arrow::utf8())}); | |
| std::map<std::string, std::string> csv_table = format_table; | |
| csv_table[Options::FILE_FORMAT] = "csv"; | |
| ASSERT_NOK_WITH_MSG(create("csv_table", good, {"dt"}, csv_table), | |
| "not supported by paimon-cpp yet"); | |
| // None of them was sent: the table must not exist on the server for another client to find. | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(0, state_->create_table_requests); | |
| } | |
| // And a schema this library can open is created and opens. | |
| ASSERT_OK(create("fine", good, {"dt"}, format_table)); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FormatTable> table, | |
| catalog->GetFormatTable(Identifier("db1", "fine"))); | |
| ASSERT_EQ(FormatTable::Format::PARQUET, table->GetFormat()); | |
| } | |
| TEST_F(RestCatalogTest, TableResponseWithoutAPathIsRejected) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| MockCatalogState::TableData no_path; | |
| no_path.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [],)" | |
| R"( "options": {"type": "format-table", "file.format": "parquet"}})"; | |
| no_path.path = ""; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["no_path"] = no_path; | |
| } | |
| // Everything built from the response reads and writes below the path. A table whose paths | |
| // would be checked against an empty one has no boundary at all, so the response is refused | |
| // rather than turned into a table. What the response said is not repeated back: a body may | |
| // carry credentials, so a failure to read one names the request and nothing else. | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("db1", "no_path")), | |
| "failed to deserialize the response"); | |
| ASSERT_NOK_WITH_MSG(catalog->GetTableLocation(Identifier("db1", "no_path")), | |
| "failed to deserialize the response"); | |
| } | |
| TEST_F(RestCatalogTest, FormatTableIsLoadedInOneRequest) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| MockCatalogState::TableData table_data; | |
| table_data.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"},)" | |
| R"( {"id": 1, "name": "dt", "type": "STRING"}],)" | |
| R"( "partitionKeys": ["dt"], "primaryKeys": [],)" | |
| R"( "options": {"type": "format-table", "file.format": "parquet"}})"; | |
| table_data.schema_id = 7; | |
| table_data.path = "wh1/db1.db/fmt"; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["fmt"] = table_data; | |
| state_->get_table_requests = 0; | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<FormatTable> table, | |
| catalog->GetFormatTable(Identifier("db1", "fmt"))); | |
| ASSERT_EQ("wh1/db1.db/fmt", table->Location()); | |
| ASSERT_EQ(FormatTable::Format::PARQUET, table->GetFormat()); | |
| ASSERT_EQ((std::vector<std::string>{"dt"}), table->PartitionKeys()); | |
| // The location and the schema come from one response, so they cannot describe two different | |
| // states of the table. | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(1, state_->get_table_requests); | |
| } | |
| // This catalog holds the schema itself, so everything below the location is data - a | |
| // directory named "schema" there is a partition value, not metadata to skip. | |
| ASSERT_FALSE(table->LocationCarriesPaimonMetadata()); | |
| ASSERT_NOK_WITH_MSG(catalog->GetTable(Identifier("db1", "fmt")), "Cannot open format table"); | |
| // A managed table stays out of this path, and so does a system table. | |
| ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t1"))); | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("db1", "t1")), "is not a format table"); | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("db1", "t1$snapshots")), | |
| "for system table"); | |
| // The database is checked before the table name is parsed, so a malformed name under "sys" | |
| // is refused as a system table rather than surfacing a parse error. | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("sys", "all_table_options")), | |
| "for system table"); | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("sys", "a$b$c$d")), "for system table"); | |
| } | |
| TEST_F(RestCatalogTest, FormatTableWithAnUnusableSchemaIsRejectedOnLoad) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| // A schema from a rest catalog never passed through table creation in this library, so the | |
| // checks that run there have to run again on load. Without that these would be accepted here | |
| // and fail only when the first reader or writer was built. | |
| const std::string default_fields = R"([{"id": 0, "name": "f0", "type": "INT NOT NULL"},)" | |
| R"( {"id": 1, "name": "dt", "type": "STRING"}])"; | |
| auto seed = [this](const std::string& name, const std::string& options, | |
| const std::string& partition_keys, const std::string& fields) { | |
| MockCatalogState::TableData table_data; | |
| table_data.schema_json = R"({"fields": )" + fields + R"(, "partitionKeys": )" + | |
| partition_keys + R"(, "primaryKeys": [], "options": )" + options + | |
| "}"; | |
| table_data.path = "wh1/db1.db/" + name; | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"][name] = table_data; | |
| }; | |
| // A format table format with no reader here. | |
| seed("csv_table", R"({"type": "format-table", "file.format": "csv"})", R"(["dt"])", | |
| default_fields); | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("db1", "csv_table")), | |
| "not supported by paimon-cpp yet"); | |
| // A row count no file could ever reach. | |
| seed("bad_rows", | |
| R"({"type": "format-table", "file.format": "parquet", "target-file-row-num": "0"})", | |
| R"(["dt"])", default_fields); | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("db1", "bad_rows")), | |
| "should be at least 1"); | |
| // The failure names the table, which is all a caller holding several has to go on. | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("db1", "bad_rows")), "db1.bad_rows"); | |
| // The structural rules run here too, not only the format-table ones: a partition key that is | |
| // not a field of the schema is a table nothing could ever read. | |
| seed("bad_partition_key", R"({"type": "format-table", "file.format": "parquet"})", | |
| R"(["nosuchfield"])", default_fields); | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("db1", "bad_partition_key")), | |
| "should include all partition fields"); | |
| // Two schemas this library cannot open, refused when the table is opened rather than when a | |
| // reader is first built. Both are in the user guide's list of limits. | |
| seed("timestamp_partition", R"({"type": "format-table", "file.format": "parquet"})", | |
| R"(["dt"])", | |
| R"([{"id": 0, "name": "f0", "type": "INT NOT NULL"},)" | |
| // Not a raw string: `TIMESTAMP(6)"` holds the sequence that would end one. | |
| " {\"id\": 1, \"name\": \"dt\", \"type\": \"TIMESTAMP(6)\"}]"); | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("db1", "timestamp_partition")), | |
| "cannot be TIMESTAMP/DECIMAL/BLOB"); | |
| seed("only_partitions", R"({"type": "format-table", "file.format": "parquet"})", R"(["dt"])", | |
| R"([{"id": 0, "name": "dt", "type": "STRING"}])"); | |
| ASSERT_NOK_WITH_MSG(catalog->GetFormatTable(Identifier("db1", "only_partitions")), | |
| "every one of its columns"); | |
| // An option this library does not honour keeps its own status code, so a caller can still | |
| // tell "not supported yet" from "bad table". | |
| seed("catalog_partitions", | |
| R"({"type": "format-table", "file.format": "parquet",)" | |
| R"( "metastore.partitioned-table": "true"})", | |
| R"(["dt"])", default_fields); | |
| Status not_implemented = | |
| catalog->GetFormatTable(Identifier("db1", "catalog_partitions")).status(); | |
| ASSERT_TRUE(not_implemented.IsNotImplemented()) << not_implemented.ToString(); | |
| } | |
| TEST_F(RestCatalogTest, NestedSchemaHighestFieldId) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| MockCatalogState::TableData table_data; | |
| table_data.schema_json = R"({ | |
| "fields": [ | |
| {"id": 0, "name": "f0", "type": "INT NOT NULL"}, | |
| {"id": 1, "name": "s", "type": {"type": "ROW", | |
| "fields": [{"id": 3, "name": "inner", "type": "INT"}]}}, | |
| {"id": 2, "name": "arr", "type": {"type": "ARRAY", | |
| "element": {"type": "ROW", | |
| "fields": [{"id": 7, "name": "deep", "type": "BIGINT"}]}}}, | |
| {"id": 4, "name": "m", "type": {"type": "MAP", | |
| "key": {"type": "ROW NOT NULL", | |
| "fields": [{"id": 8, "name": "k", "type": "INT NOT NULL"}]}, | |
| "value": {"type": "ROW", | |
| "fields": [{"id": 9, "name": "v", "type": "INT"}]}}} | |
| ], | |
| "partitionKeys": [], | |
| "primaryKeys": [], | |
| "options": {} | |
| })"; | |
| table_data.schema_id = 5; | |
| table_data.path = "wh1/db1.db/nested"; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["nested"] = table_data; | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table, | |
| catalog->GetTable(Identifier("db1", "nested"))); | |
| std::shared_ptr<DataSchema> schema = | |
| std::dynamic_pointer_cast<DataSchema>(table->LatestSchema()); | |
| ASSERT_NE(nullptr, schema); | |
| ASSERT_EQ(5, schema->Id()); | |
| // 9 lives inside the value row of the map: ROW, ARRAY element and MAP key/value | |
| // must all be traversed | |
| ASSERT_EQ(9, schema->HighestFieldId()); | |
| ASSERT_EQ((std::vector<std::string>{"f0", "s", "arr", "m"}), schema->FieldNames()); | |
| } | |
| TEST_F(RestCatalogTest, ListTablesPaged) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t1"))); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t2"))); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), Identifier("db1", "t3"))); | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::string> tables, catalog->ListTables("db1")); | |
| ASSERT_EQ((std::vector<std::string>{"t1", "t2", "t3"}), tables); | |
| } | |
| TEST_F(RestCatalogTest, BrokenSchemaRejected) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| ExpectBrokenSchemaRejected(catalog.get(), "duplicate", | |
| R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"},)" | |
| R"( {"id": 0, "name": "f1", "type": "STRING"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {}})", | |
| "duplicated"); | |
| // an id inside a nested row colliding with an outer field id is a duplicate too | |
| ExpectBrokenSchemaRejected(catalog.get(), "duplicate_nested", | |
| R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"},)" | |
| R"( {"id": 1, "name": "s", "type": {"type": "ROW",)" | |
| R"( "fields": [{"id": 0, "name": "inner", "type": "INT"}]}}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {}})", | |
| "duplicated"); | |
| // a field without an integer id must fail the conversion instead of being | |
| // silently skipped when computing highestFieldId | |
| ExpectBrokenSchemaRejected(catalog.get(), "no_id", | |
| R"({"fields": [{"name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {}})", | |
| "integer id"); | |
| ExpectBrokenSchemaRejected(catalog.get(), "string_id", | |
| R"({"fields": [{"id": "0", "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {}})", | |
| "integer id"); | |
| // a field that is not an object fails too instead of being silently skipped | |
| ExpectBrokenSchemaRejected(catalog.get(), "non_object", | |
| R"({"fields": [1],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {}})", | |
| "must be an object"); | |
| // a missing or wrong-typed member is a visible failure instead of silently | |
| // defaulting to an empty value (e.g. loading a partitioned table as | |
| // unpartitioned) | |
| ExpectBrokenSchemaRejected(catalog.get(), "missing_partition_keys", | |
| R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "primaryKeys": [], "options": {}})", | |
| "missing 'partitionKeys'"); | |
| ExpectBrokenSchemaRejected(catalog.get(), "missing_primary_keys", | |
| R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "options": {}})", | |
| "missing 'primaryKeys'"); | |
| ExpectBrokenSchemaRejected(catalog.get(), "missing_options", | |
| R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": []})", | |
| "missing 'options'"); | |
| ExpectBrokenSchemaRejected(catalog.get(), "wrong_typed_partition_keys", | |
| R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": {}, "primaryKeys": [], "options": {}})", | |
| "'partitionKeys' is not an array"); | |
| ExpectBrokenSchemaRejected(catalog.get(), "wrong_typed_primary_keys", | |
| R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": "f0", "options": {}})", | |
| "'primaryKeys' is not an array"); | |
| ExpectBrokenSchemaRejected(catalog.get(), "wrong_typed_options", | |
| R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": []})", | |
| "'options' is not an object"); | |
| } | |
| TEST_F(RestCatalogTest, PartitionKeysRoundTrip) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| std::shared_ptr<arrow::Schema> schema = | |
| arrow::schema({arrow::field("f0", arrow::int32(), /*nullable=*/false), | |
| arrow::field("f1", arrow::utf8(), /*nullable=*/false)}); | |
| struct ArrowSchema c_schema; | |
| ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok()); | |
| // The server's config sets a `table-default.bucket`, which would make this a bucketed append | |
| // table - and one of those needs a bucket key it has not been given. | |
| ASSERT_OK(catalog->CreateTable(Identifier("db1", "pt"), &c_schema, | |
| /*partition_keys=*/{"f1"}, /*primary_keys=*/{}, | |
| {{"bucket", "-1"}}, | |
| /*ignore_if_exists=*/false)); | |
| // the partition keys survive both the create request and the load response conversion | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Table> table, catalog->GetTable(Identifier("db1", "pt"))); | |
| std::shared_ptr<DataSchema> loaded = | |
| std::dynamic_pointer_cast<DataSchema>(table->LatestSchema()); | |
| ASSERT_NE(nullptr, loaded); | |
| ASSERT_EQ((std::vector<std::string>{"f1"}), loaded->PartitionKeys()); | |
| } | |
| TEST_F(RestCatalogTest, ServerErrorIsPropagatedNotMappedToAbsent) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->force_error_code = 500; | |
| } | |
| // a server failure surfaces as an error instead of "does not exist" | |
| Status db_status = catalog->DatabaseExists("db1").status(); | |
| ASSERT_NOK_WITH_MSG(db_status, "server error"); | |
| Status table_status = catalog->TableExists(Identifier("db1", "t1")).status(); | |
| ASSERT_NOK_WITH_MSG(table_status, "server error"); | |
| } | |
| TEST_F(RestCatalogTest, SystemTableChecks) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_NOK_WITH_MSG(catalog->CreateDatabase("sys", {}, false), "system database"); | |
| ASSERT_NOK_WITH_MSG(catalog->DropDatabase("sys", false, false), "system database"); | |
| ASSERT_NOK_WITH_MSG(catalog->DropTable(Identifier("sys", "t"), false), "system table"); | |
| ASSERT_NOK_WITH_MSG( | |
| catalog->RenameTable(Identifier("db1", "t1$snapshots"), Identifier("db1", "t2"), false), | |
| "system table"); | |
| } | |
| TEST_F(RestCatalogTest, SystemDatabase) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| // the "sys" database and its global system tables are resolved locally without | |
| // contacting the server, like in FileSystemCatalog | |
| ASSERT_OK_AND_ASSIGN(bool exists, catalog->DatabaseExists("sys")); | |
| ASSERT_TRUE(exists); | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::string> sys_tables, catalog->ListTables("sys")); | |
| ASSERT_TRUE(std::find(sys_tables.begin(), sys_tables.end(), "tables") != sys_tables.end()); | |
| ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("sys", "tables"))); | |
| ASSERT_TRUE(exists); | |
| ASSERT_OK_AND_ASSIGN(exists, catalog->TableExists(Identifier("sys", "unsupported"))); | |
| ASSERT_FALSE(exists); | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Schema> schema, | |
| catalog->LoadTableSchema(Identifier("sys", "tables"))); | |
| ASSERT_FALSE(schema->FieldNames().empty()); | |
| Status missing = catalog->LoadTableSchema(Identifier("sys", "unsupported")).status(); | |
| ASSERT_TRUE(missing.IsNotExist()) << missing.ToString(); | |
| } | |
| TEST_F(RestCatalogTest, ListSnapshots) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/false)); | |
| Identifier identifier("db1", "t1"); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| ASSERT_OK_AND_ASSIGN(std::vector<SnapshotInfo> snapshots, | |
| catalog->ListSnapshots(identifier, "")); | |
| ASSERT_EQ(2, snapshots.size()); | |
| // fetched via two pages and sorted by snapshot id | |
| ASSERT_EQ(1, snapshots[0].snapshot_id); | |
| ASSERT_EQ(2, snapshots[1].snapshot_id); | |
| ASSERT_EQ("user1", snapshots[0].commit_user); | |
| ASSERT_EQ(SnapshotInfo::CommitKind::APPEND, snapshots[0].commit_kind); | |
| // the default branch is addressed as the bare table, so passing it explicitly | |
| // equals the branch-less call | |
| ASSERT_OK_AND_ASSIGN(std::vector<SnapshotInfo> main_snapshots, | |
| catalog->ListSnapshots(identifier, "main")); | |
| ASSERT_EQ(2, main_snapshots.size()); | |
| // the match ignores case, as in the Java client, so "MAIN" is the default branch too | |
| ASSERT_OK_AND_ASSIGN(std::vector<SnapshotInfo> main_case_snapshots, | |
| catalog->ListSnapshots(identifier, "MAIN")); | |
| ASSERT_EQ(2, main_case_snapshots.size()); | |
| // a blank name is the main branch everywhere in this library, so it is the bare table here | |
| ASSERT_OK_AND_ASSIGN(std::vector<SnapshotInfo> blank_snapshots, | |
| catalog->ListSnapshots(identifier, " ")); | |
| ASSERT_EQ(2, blank_snapshots.size()); | |
| Status missing = catalog->ListSnapshots(Identifier("db1", "t9"), "").status(); | |
| ASSERT_TRUE(missing.IsNotExist()) << missing.ToString(); | |
| // a non-main branch is sent under its branch object name, so the server resolves | |
| // the branch and lists its own snapshots | |
| MockCatalogState::TableData branch_data; | |
| branch_data.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {}})"; | |
| branch_data.path = "wh1/db1.db/t1"; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["t1$branch_b1"] = branch_data; | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::vector<SnapshotInfo> branch_snapshots, | |
| catalog->ListSnapshots(identifier, "b1")); | |
| ASSERT_EQ(2, branch_snapshots.size()); | |
| // a branch the server does not know is missing instead of silently falling back | |
| // to the main table | |
| Status missing_branch = catalog->ListSnapshots(identifier, "b_missing").status(); | |
| ASSERT_TRUE(missing_branch.IsNotExist()) << missing_branch.ToString(); | |
| // a branch must be passed as the branch argument, not encoded in the identifier | |
| ASSERT_NOK_WITH_MSG(catalog->ListSnapshots(Identifier("db1", "t1$branch_b1"), "").status(), | |
| "branch table"); | |
| } | |
| TEST_F(RestCatalogTest, GetLatestSnapshot) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, false)); | |
| Identifier identifier("db1", "t1"); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| const Catalog& public_catalog = *catalog; | |
| ASSERT_OK_AND_ASSIGN(std::optional<SnapshotInfo> empty, | |
| public_catalog.GetLatestSnapshot(identifier)); | |
| ASSERT_FALSE(empty); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->current_snapshot = SnapshotJson(7); | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::optional<SnapshotInfo> snapshot, | |
| public_catalog.GetLatestSnapshot(identifier)); | |
| ASSERT_TRUE(snapshot); | |
| ASSERT_EQ(snapshot->snapshot_id, 7); | |
| ASSERT_EQ(snapshot->schema_id, 0); | |
| } | |
| TEST_F(RestCatalogTest, GetLatestSnapshotBranchAndErrors) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, false)); | |
| Identifier identifier("db1", "t1"); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->current_snapshot = SnapshotJson(7); | |
| state_->databases["db1"]["t1$branch_b1"] = state_->databases["db1"]["t1"]; | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::optional<SnapshotInfo> snapshot, | |
| catalog->GetLatestSnapshot(identifier, "b1")); | |
| ASSERT_TRUE(snapshot); | |
| ASSERT_EQ(snapshot->snapshot_id, 7); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(state_->last_snapshot_table, "t1$branch_b1"); | |
| } | |
| ASSERT_TRUE(catalog->GetLatestSnapshot(Identifier("db1", "missing"), "").status().IsNotExist()); | |
| ASSERT_TRUE(catalog->GetLatestSnapshot(identifier, "missing").status().IsNotExist()); | |
| } | |
| TEST_F(RestCatalogTest, FileStoreCommitIsBuiltFromTheCatalog) { | |
| std::unique_ptr<UniqueTestDirectory> dir = UniqueTestDirectory::Create(); | |
| ASSERT_TRUE(dir); | |
| MockCatalogState::TableData table_data; | |
| table_data.schema_json = | |
| R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [],)" | |
| R"( "options": {"file.format": "mock_format", "manifest.format": "avro"}})"; | |
| table_data.path = dir->Str(); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> rest_catalog, CreateRestCatalog()); | |
| ASSERT_OK(rest_catalog->CreateDatabase("db1", {}, false)); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["t1"] = table_data; | |
| } | |
| ASSERT_TRUE(rest_catalog->SupportsVersionManagement()); | |
| int32_t requests_before = 0; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| requests_before = state_->get_table_requests; | |
| } | |
| std::shared_ptr<Catalog> shared_catalog(std::move(rest_catalog)); | |
| CommitContextBuilder builder(dir->Str(), "commit-user"); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<CommitContext> context, | |
| builder.WithCatalog(shared_catalog, Identifier("db1", "t1")).Finish()); | |
| ASSERT_OK(FileStoreCommit::Create(std::move(context))); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_GT(state_->get_table_requests, requests_before); | |
| } | |
| } | |
| TEST_F(RestCatalogTest, CommitSnapshot) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, false)); | |
| Identifier identifier("db1", "t1"); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| ASSERT_TRUE(catalog->SupportsVersionManagement()); | |
| Snapshot snapshot = BuildTestSnapshot(2); | |
| std::vector<PartitionStatistics> statistics = { | |
| PartitionStatistics({{"dt", "20240101"}}, 1, 541, 1, 1724090888743, -1)}; | |
| ASSERT_OK_AND_ASSIGN(bool success, | |
| catalog->CommitSnapshot(identifier, "table-uuid", "base-snapshot-uuid", | |
| snapshot, statistics)); | |
| ASSERT_TRUE(success); | |
| std::string commit_body; | |
| std::string commit_table; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| commit_body = state_->last_commit_body; | |
| commit_table = state_->last_commit_table; | |
| } | |
| ASSERT_EQ("t1", commit_table); | |
| ASSERT_OK_AND_ASSIGN(CommitTableRequest request, | |
| CommitTableRequest::FromJsonString(commit_body)); | |
| ASSERT_EQ(request.GetTableId(), std::optional<std::string>("table-uuid")); | |
| ASSERT_EQ(request.GetBaseSnapshotUuid(), std::optional<std::string>("base-snapshot-uuid")); | |
| ASSERT_EQ(request.GetSnapshot().Id(), 2); | |
| ASSERT_EQ(request.GetStatistics(), statistics); | |
| MockCatalogState::TableData branch_data; | |
| branch_data.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {}})"; | |
| branch_data.path = "wh1/db1.db/t1"; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["t1$branch_b1"] = branch_data; | |
| state_->last_commit_table.clear(); | |
| state_->last_commit_body.clear(); | |
| } | |
| ASSERT_OK_AND_ASSIGN(bool branch_success, | |
| catalog->CommitSnapshot(Identifier("db1", "t1$branch_b1"), std::nullopt, | |
| std::nullopt, snapshot, statistics)); | |
| ASSERT_TRUE(branch_success); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ("t1$branch_b1", state_->last_commit_table); | |
| } | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->last_commit_table.clear(); | |
| } | |
| ASSERT_OK_AND_ASSIGN(bool main_success, | |
| catalog->CommitSnapshot(Identifier("db1", "t1$branch_main"), std::nullopt, | |
| std::nullopt, snapshot, statistics)); | |
| ASSERT_TRUE(main_success); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ("t1", state_->last_commit_table); | |
| } | |
| } | |
| TEST_F(RestCatalogTest, LoadSnapshot) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, false)); | |
| Identifier identifier("db1", "t1"); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| ASSERT_OK_AND_ASSIGN(std::optional<Snapshot> empty, catalog->LoadSnapshot(identifier)); | |
| ASSERT_FALSE(empty.has_value()); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->null_snapshot_response = true; | |
| } | |
| ASSERT_OK_AND_ASSIGN(empty, catalog->LoadSnapshot(identifier)); | |
| ASSERT_FALSE(empty.has_value()); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->null_snapshot_response = false; | |
| state_->current_snapshot = SnapshotJson(7); | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::optional<Snapshot> loaded, catalog->LoadSnapshot(identifier)); | |
| ASSERT_TRUE(loaded.has_value()); | |
| ASSERT_EQ(loaded.value().Id(), 7); | |
| ASSERT_EQ(loaded.value().BaseManifestList(), "bml"); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ("t1", state_->last_snapshot_table); | |
| } | |
| MockCatalogState::TableData branch_data; | |
| branch_data.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {}})"; | |
| branch_data.path = "wh1/db1.db/t1"; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["t1$branch_b1"] = branch_data; | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::optional<Snapshot> branch_snapshot, | |
| catalog->LoadSnapshot(Identifier("db1", "t1$branch_b1"))); | |
| ASSERT_TRUE(branch_snapshot.has_value()); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ("t1$branch_b1", state_->last_snapshot_table); | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::optional<Snapshot> main_snapshot, | |
| catalog->LoadSnapshot(Identifier("db1", "t1$branch_main"))); | |
| ASSERT_TRUE(main_snapshot.has_value()); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ("t1", state_->last_snapshot_table); | |
| } | |
| Status missing = catalog->LoadSnapshot(Identifier("db1", "t9")).status(); | |
| ASSERT_TRUE(missing.IsNotExist()) << missing.ToString(); | |
| ASSERT_NOK_WITH_MSG(catalog->LoadSnapshot(Identifier("db1", "t1$snapshots")), | |
| "Cannot 'loadSnapshot' for system table"); | |
| } | |
| TEST_F(RestCatalogTest, CommitSnapshotErrors) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, false)); | |
| Identifier identifier("db1", "t1"); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| Snapshot snapshot = BuildTestSnapshot(2); | |
| std::vector<PartitionStatistics> statistics; | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->refuse_commit = true; | |
| } | |
| ASSERT_OK_AND_ASSIGN(bool refused, | |
| catalog->CommitSnapshot(identifier, "table-uuid", "base-snapshot-uuid", | |
| snapshot, statistics)); | |
| ASSERT_FALSE(refused); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->refuse_commit = false; | |
| } | |
| Status missing = catalog | |
| ->CommitSnapshot(Identifier("db1", "t9"), std::nullopt, std::nullopt, | |
| snapshot, statistics) | |
| .status(); | |
| ASSERT_TRUE(missing.IsNotExist()) << missing.ToString(); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->break_commit_response = true; | |
| } | |
| ASSERT_NOK_WITH_MSG( | |
| catalog->CommitSnapshot(identifier, "table-uuid", std::nullopt, snapshot, statistics), | |
| "failed to deserialize the response"); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->break_commit_response = false; | |
| } | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->commit_response_without_outcome = true; | |
| } | |
| ASSERT_NOK_WITH_MSG( | |
| catalog->CommitSnapshot(identifier, "table-uuid", std::nullopt, snapshot, statistics), | |
| "failed to deserialize the response"); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->commit_response_without_outcome = false; | |
| } | |
| ASSERT_NOK_WITH_MSG(catalog->CommitSnapshot(Identifier("db1", "t1$snapshots"), std::nullopt, | |
| std::nullopt, snapshot, statistics), | |
| "Cannot 'commitSnapshot' for system table"); | |
| } | |
| namespace { | |
| Result<std::vector<std::shared_ptr<CommitMessage>>> ReadFixtureCommitMessages( | |
| const std::shared_ptr<FileSystem>& fs) { | |
| std::string bytes; | |
| PAIMON_RETURN_NOT_OK( | |
| fs->ReadFile(paimon::test::GetDataDir() + | |
| "/orc/append_09.db/append_09/commit_messages/commit_messages-01", | |
| &bytes)); | |
| return CommitMessage::DeserializeList(3, bytes.data(), bytes.size(), GetDefaultPool()); | |
| } | |
| } // namespace | |
| class RestCatalogCommitRecoveryTest : public RestCatalogTest, | |
| public ::testing::WithParamInterface<int32_t> {}; | |
| TEST_P(RestCatalogCommitRecoveryTest, CommitAcceptedThenLostRecoversAfterRestart) { | |
| std::unique_ptr<UniqueTestDirectory> dir = UniqueTestDirectory::Create(); | |
| ASSERT_TRUE(dir); | |
| MockCatalogState::TableData table_data; | |
| table_data.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "STRING"},)" | |
| R"( {"id": 1, "name": "f1", "type": "INT"},)" | |
| R"( {"id": 2, "name": "f2", "type": "INT"},)" | |
| R"( {"id": 3, "name": "f3", "type": "DOUBLE"}],)" | |
| R"( "partitionKeys": ["f1"], "primaryKeys": [],)" | |
| R"( "options": {"file.format": "orc", "manifest.format": "avro"}})"; | |
| table_data.path = dir->Str(); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> rest_catalog, | |
| CreateRestCatalog(FastRetryConfig())); | |
| ASSERT_OK(rest_catalog->CreateDatabase("db1", {}, false)); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["t1"] = table_data; | |
| state_->accept_commit_then_report_unavailable = true; | |
| state_->accepted_commit_response_code = GetParam(); | |
| state_->commit_requests = 0; | |
| } | |
| Identifier identifier("db1", "t1"); | |
| RestCatalog* raw_catalog = rest_catalog.get(); | |
| std::shared_ptr<Catalog> catalog(std::move(rest_catalog)); | |
| auto create_commit = [&]() -> Result<std::unique_ptr<FileStoreCommit>> { | |
| CommitContextBuilder builder(dir->Str(), "commit-user"); | |
| PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<CommitContext> context, | |
| builder.WithCatalog(catalog, identifier).Finish()); | |
| return FileStoreCommit::Create(std::move(context)); | |
| }; | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileStoreCommit> commit, create_commit()); | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> msgs, | |
| ReadFixtureCommitMessages(dir->GetFileSystem())); | |
| ASSERT_GT(msgs.size(), 0u); | |
| ASSERT_NOK_WITH_MSG(commit->Commit(msgs, 7), "call FilterAndCommit"); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(1, state_->commit_requests); | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::optional<Snapshot> held, raw_catalog->LoadSnapshot(identifier)); | |
| ASSERT_TRUE(held.has_value()); | |
| ASSERT_EQ(held.value().Id(), 1); | |
| ASSERT_EQ(held.value().CommitIdentifier(), 7); | |
| ASSERT_EQ(held.value().CommitUser(), "commit-user"); | |
| for (const std::string& manifest_list : | |
| {held.value().BaseManifestList(), held.value().DeltaManifestList()}) { | |
| ASSERT_OK_AND_ASSIGN(bool exist, dir->GetFileSystem()->Exists(PathUtil::JoinPath( | |
| dir->Str(), "manifest/" + manifest_list))); | |
| ASSERT_TRUE(exist) << manifest_list; | |
| } | |
| std::map<int64_t, std::vector<std::shared_ptr<CommitMessage>>> inputs; | |
| inputs[7] = msgs; | |
| commit.reset(); | |
| ASSERT_OK_AND_ASSIGN(commit, create_commit()); | |
| ASSERT_OK_AND_ASSIGN(int32_t again, commit->FilterAndCommit(inputs, 10)); | |
| ASSERT_EQ(0, again); | |
| ASSERT_OK_AND_ASSIGN(std::optional<Snapshot> recovered, raw_catalog->LoadSnapshot(identifier)); | |
| ASSERT_EQ(recovered, held); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(1, state_->commit_requests); | |
| state_->accept_commit_then_report_unavailable = false; | |
| } | |
| } | |
| INSTANTIATE_TEST_SUITE_P(UncertainResponses, RestCatalogCommitRecoveryTest, | |
| ::testing::Values(200, 204, 307, 429, 503), | |
| [](const ::testing::TestParamInfo<int32_t>& info) { | |
| return fmt::format("Http{}", info.param); | |
| }); | |
| TEST_F(RestCatalogTest, CommitTakenThenReportedUnavailableIsNotReplayed) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, | |
| CreateRestCatalog(FastRetryConfig())); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, false)); | |
| Identifier identifier("db1", "t1"); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->accept_commit_then_report_unavailable = true; | |
| state_->commit_requests = 0; | |
| } | |
| const Snapshot taken = BuildTestSnapshot(2, "snapshot-uuid-2"); | |
| Status unavailable = | |
| catalog->CommitSnapshot(identifier, "table-uuid", "base-snapshot-uuid", taken, {}).status(); | |
| ASSERT_FALSE(unavailable.ok()) << "a 503 must not read as a refusal"; | |
| ASSERT_TRUE(unavailable.IsIOError()) << unavailable.ToString(); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(1, state_->commit_requests); | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::optional<Snapshot> held, catalog->LoadSnapshot(identifier)); | |
| ASSERT_TRUE(held.has_value()); | |
| ASSERT_EQ(held.value().Id(), 2); | |
| ASSERT_EQ(held.value().Uuid(), std::optional<std::string>("snapshot-uuid-2")); | |
| ASSERT_OK_AND_ASSIGN(bool replayed, catalog->CommitSnapshot(identifier, "table-uuid", | |
| "base-snapshot-uuid", taken, {})); | |
| ASSERT_FALSE(replayed); | |
| ASSERT_OK_AND_ASSIGN(bool rebased, | |
| catalog->CommitSnapshot(identifier, "table-uuid", "snapshot-uuid-2", | |
| BuildTestSnapshot(3, "snapshot-uuid-3"), {})); | |
| ASSERT_TRUE(rebased); | |
| ASSERT_OK_AND_ASSIGN(std::optional<Snapshot> now_held, catalog->LoadSnapshot(identifier)); | |
| ASSERT_TRUE(now_held.has_value()); | |
| ASSERT_EQ(now_held.value().Id(), 3); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(3, state_->commit_requests); | |
| state_->accept_commit_then_report_unavailable = false; | |
| } | |
| } | |
| TEST_F(RestCatalogTest, CommitLockAddressesTheTableOrBranch) { | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestCatalog> catalog, CreateRestCatalog()); | |
| ASSERT_OK(catalog->CreateDatabase("db1", {}, false)); | |
| Identifier identifier("db1", "t1"); | |
| ASSERT_OK(CreateSampleTable(catalog.get(), identifier)); | |
| ASSERT_OK_AND_ASSIGN(CoreOptions options, | |
| CoreOptions::FromMap({{Options::FILE_SYSTEM, "local"}, | |
| {Options::FILE_FORMAT, "mock_format"}, | |
| {Options::REST_COMMIT_LOCK_ENABLED, "true"}, | |
| {Options::REST_COMMIT_ACQUIRE_LOCK, "true"}})); | |
| ASSERT_NOK_WITH_MSG(catalog->CreateCommitLock(Identifier("db1", "t1$snapshots"), "table-uuid", | |
| "commit-user", options), | |
| "Cannot 'createCommitLock' for system table"); | |
| MockCatalogState::TableData branch_data; | |
| branch_data.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "INT NOT NULL"}],)" | |
| R"( "partitionKeys": [], "primaryKeys": [], "options": {}})"; | |
| branch_data.path = "wh1/db1.db/t1"; | |
| { | |
| std::lock_guard<std::mutex> guard(state_->mutex); | |
| state_->databases["db1"]["t1$branch_b1"] = branch_data; | |
| } | |
| struct Case { | |
| Identifier identifier; | |
| std::string served_table; | |
| }; | |
| for (const Case& lease_case : | |
| {Case{identifier, "t1"}, Case{Identifier("db1", "t1$branch_b1"), "t1$branch_b1"}, | |
| Case{Identifier("db1", "t1$branch_main"), "t1"}}) { | |
| SCOPED_TRACE(lease_case.identifier.ToString()); | |
| size_t requests_before = 0; | |
| { | |
| std::lock_guard<std::mutex> guard(state_->mutex); | |
| requests_before = state_->lock_requests.size(); | |
| } | |
| ASSERT_OK_AND_ASSIGN( | |
| std::unique_ptr<CommitLock> lock, | |
| catalog->CreateCommitLock(lease_case.identifier, "table-uuid", "commit-user", options)); | |
| ASSERT_TRUE(lock); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<CommitLockScope> scope, lock->Acquire()); | |
| ASSERT_OK(scope->EnsureValid()); | |
| std::lock_guard<std::mutex> guard(state_->mutex); | |
| ASSERT_EQ(requests_before + 1, state_->lock_requests.size()); | |
| ASSERT_EQ(lease_case.served_table, state_->lock_tables.back()); | |
| ASSERT_EQ("table-uuid", state_->lock_requests.back().GetTableId()); | |
| ASSERT_EQ("commit-user", state_->lock_requests.back().GetCommitUser()); | |
| } | |
| } | |
| class RestCatalogCommitLeaseTest : public RestCatalogTest { | |
| protected: | |
| void SetUp() override { | |
| RestCatalogTest::SetUp(); | |
| dir_ = UniqueTestDirectory::Create(); | |
| ASSERT_TRUE(dir_); | |
| } | |
| Result<std::shared_ptr<Catalog>> CreateCatalogServingTable(bool lock_enabled) { | |
| std::string lock_option = lock_enabled ? R"(, "rest.commit.lock-enabled": "true")" : ""; | |
| MockCatalogState::TableData table_data; | |
| table_data.schema_json = R"({"fields": [{"id": 0, "name": "f0", "type": "STRING"},)" | |
| R"( {"id": 1, "name": "f1", "type": "INT"},)" | |
| R"( {"id": 2, "name": "f2", "type": "INT"},)" | |
| R"( {"id": 3, "name": "f3", "type": "DOUBLE"}],)" | |
| R"( "partitionKeys": ["f1"], "primaryKeys": [],)" | |
| R"( "options": {"file.format": "orc", "manifest.format": "avro")" + | |
| lock_option + "}}"; | |
| table_data.path = dir_->Str(); | |
| PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<RestCatalog> catalog, | |
| CreateRestCatalog(FastRetryConfig())); | |
| PAIMON_RETURN_NOT_OK(catalog->CreateDatabase("db1", {}, /*ignore_if_exists=*/true)); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->databases["db1"]["t1"] = table_data; | |
| } | |
| return std::shared_ptr<Catalog>(std::move(catalog)); | |
| } | |
| Result<std::unique_ptr<FileStoreCommit>> CreateCommit( | |
| const std::shared_ptr<Catalog>& catalog, const std::optional<std::string>& table_id, | |
| const std::map<std::string, std::string>& options) { | |
| CommitContextBuilder builder(dir_->Str(), "commit-user"); | |
| builder.WithCatalog(catalog, Identifier("db1", "t1")); | |
| if (table_id) { | |
| builder.WithTableId(table_id.value()); | |
| } | |
| for (const auto& [key, value] : options) { | |
| builder.AddOption(key, value); | |
| } | |
| PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<CommitContext> context, builder.Finish()); | |
| return FileStoreCommit::Create(std::move(context)); | |
| } | |
| std::vector<std::string> TableEvents() { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| return state_->table_events; | |
| } | |
| size_t LockRequests() { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| return state_->lock_requests.size(); | |
| } | |
| void AssertNoManifestsLeft() { | |
| std::shared_ptr<FileSystem> fs = dir_->GetFileSystem(); | |
| const std::string manifest_dir = PathUtil::JoinPath(dir_->Str(), "manifest"); | |
| ASSERT_OK_AND_ASSIGN(bool manifest_dir_exists, fs->Exists(manifest_dir)); | |
| if (manifest_dir_exists) { | |
| std::vector<BasicFileStatus> manifests; | |
| ASSERT_OK(fs->ListDir(manifest_dir, &manifests)); | |
| ASSERT_TRUE(manifests.empty()) << manifests.size(); | |
| } | |
| } | |
| std::unique_ptr<UniqueTestDirectory> dir_; | |
| }; | |
| TEST_F(RestCatalogCommitLeaseTest, LeaseIsAcquiredBeforeTheHeadAndAgainAfterAConflict) { | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Catalog> catalog, | |
| CreateCatalogServingTable(/*lock_enabled=*/true)); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileStoreCommit> commit, | |
| CreateCommit(catalog, "1", | |
| {{Options::REST_COMMIT_ACQUIRE_LOCK, "true"}, | |
| {Options::COMMIT_MIN_RETRY_WAIT, "1ms"}})); | |
| ASSERT_EQ(0u, LockRequests()); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->refused_commits_left = 1; | |
| state_->table_events.clear(); | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> msgs, | |
| ReadFixtureCommitMessages(dir_->GetFileSystem())); | |
| ASSERT_OK(commit->Commit(msgs, 7)); | |
| std::vector<std::string> events = TableEvents(); | |
| std::vector<std::string> leases_and_commits; | |
| bool leased = false; | |
| bool head_read_under_lease = false; | |
| for (const std::string& event : events) { | |
| if (event == "snapshot") { | |
| head_read_under_lease = head_read_under_lease || leased; | |
| continue; | |
| } | |
| leases_and_commits.push_back(event); | |
| if (event == "acquire") { | |
| leased = true; | |
| head_read_under_lease = false; | |
| } else if (event == "commit") { | |
| ASSERT_TRUE(leased && head_read_under_lease) | |
| << fmt::format("{}", fmt::join(events, ",")); | |
| leased = false; | |
| } | |
| } | |
| ASSERT_EQ((std::vector<std::string>{"acquire", "commit", "acquire", "commit"}), | |
| leases_and_commits); | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(2u, state_->lock_requests.size()); | |
| for (size_t i = 0; i < state_->lock_requests.size(); ++i) { | |
| ASSERT_EQ("t1", state_->lock_tables[i]); | |
| ASSERT_EQ("1", state_->lock_requests[i].GetTableId()); | |
| ASSERT_EQ("commit-user", state_->lock_requests[i].GetCommitUser()); | |
| } | |
| ASSERT_OK_AND_ASSIGN(CommitTableRequest published, | |
| CommitTableRequest::FromJsonString(state_->last_commit_body)); | |
| ASSERT_EQ(std::optional<std::string>("1"), published.GetTableId()); | |
| ASSERT_EQ("commit-user", published.GetSnapshot().CommitUser()); | |
| } | |
| TEST_F(RestCatalogCommitLeaseTest, LeaseNeedsBothOptionsAndTheTableUuid) { | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Catalog> catalog, | |
| CreateCatalogServingTable(/*lock_enabled=*/true)); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileStoreCommit> commit, CreateCommit(catalog, "1", {})); | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> msgs, | |
| ReadFixtureCommitMessages(dir_->GetFileSystem())); | |
| ASSERT_OK(commit->Commit(msgs, 7)); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(1, state_->commit_requests); | |
| } | |
| ASSERT_NOK_WITH_MSG( | |
| CreateCommit(catalog, std::nullopt, {{Options::REST_COMMIT_ACQUIRE_LOCK, "true"}}), | |
| "requires the table UUID"); | |
| ASSERT_OK_AND_ASSIGN(catalog, CreateCatalogServingTable(/*lock_enabled=*/false)); | |
| Status refused = | |
| CreateCommit(catalog, "1", {{Options::REST_COMMIT_ACQUIRE_LOCK, "true"}}).status(); | |
| ASSERT_TRUE(refused.IsInvalid()) << refused.ToString(); | |
| ASSERT_NOK_WITH_MSG(refused, | |
| "rest.commit.acquire-lock requires rest.commit.lock-enabled on the table."); | |
| ASSERT_EQ(0u, LockRequests()); | |
| } | |
| TEST_F(RestCatalogCommitLeaseTest, ContendedLeaseIsAwaitedUntilCommitTimeout) { | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Catalog> catalog, | |
| CreateCatalogServingTable(/*lock_enabled=*/true)); | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> msgs, | |
| ReadFixtureCommitMessages(dir_->GetFileSystem())); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileStoreCommit> commit, | |
| CreateCommit(catalog, "1", | |
| {{Options::REST_COMMIT_ACQUIRE_LOCK, "true"}, | |
| {Options::COMMIT_MAX_RETRIES, "0"}})); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->contended_acquires = 2; | |
| } | |
| ASSERT_OK(commit->Commit(msgs, 7)); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(3u, state_->lock_requests.size()); | |
| ASSERT_EQ(1, state_->commit_requests); | |
| } | |
| ASSERT_OK_AND_ASSIGN(commit, CreateCommit(catalog, "1", | |
| {{Options::REST_COMMIT_ACQUIRE_LOCK, "true"}, | |
| {Options::COMMIT_TIMEOUT, "0ms"}, | |
| {Options::COMMIT_MAX_RETRIES, "0"}})); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->contended_acquires = 1000; | |
| } | |
| Status failed = commit->Commit(msgs, 8); | |
| ASSERT_NOK_WITH_MSG(failed, "could not acquire commit lock"); | |
| ASSERT_NOK_WITH_MSG(failed, "timed out acquiring the commit lock"); | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| ASSERT_EQ(4u, state_->lock_requests.size()); | |
| ASSERT_EQ(1, state_->commit_requests); | |
| } | |
| TEST_F(RestCatalogCommitLeaseTest, LeaseExpiredLocallyBeforePublicationSendsNoCommit) { | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Catalog> catalog, | |
| CreateCatalogServingTable(/*lock_enabled=*/true)); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileStoreCommit> commit, | |
| CreateCommit(catalog, "1", | |
| {{Options::REST_COMMIT_ACQUIRE_LOCK, "true"}, | |
| {Options::COMMIT_MAX_RETRIES, "1"}, | |
| {Options::COMMIT_MIN_RETRY_WAIT, "1ms"}})); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->lease_millis = 60; | |
| state_->snapshot_delay_millis = 200; | |
| state_->table_events.clear(); | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> msgs, | |
| ReadFixtureCommitMessages(dir_->GetFileSystem())); | |
| Status failed = commit->Commit(msgs, 7); | |
| ASSERT_TRUE(failed.IsInvalid()) << failed.ToString(); | |
| ASSERT_NOK_WITH_MSG(failed, "the commit lock was lost before publishing"); | |
| ASSERT_NOK_WITH_MSG(failed, "is no longer usable"); | |
| ASSERT_NOK_WITH_MSG(failed, "expired locally"); | |
| ASSERT_EQ(std::string::npos, failed.ToString().find("commit conflicts")) << failed.ToString(); | |
| std::vector<std::string> events = TableEvents(); | |
| ASSERT_EQ(2, std::count(events.begin(), events.end(), std::string("acquire"))); | |
| ASSERT_EQ(0, std::count(events.begin(), events.end(), std::string("commit"))); | |
| AssertNoManifestsLeft(); | |
| } | |
| TEST_F(RestCatalogCommitLeaseTest, RenewalRefusedBeforePublicationSendsNoCommit) { | |
| ASSERT_OK_AND_ASSIGN(std::shared_ptr<Catalog> catalog, | |
| CreateCatalogServingTable(/*lock_enabled=*/true)); | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileStoreCommit> commit, | |
| CreateCommit(catalog, "1", | |
| {{Options::REST_COMMIT_ACQUIRE_LOCK, "true"}, | |
| {Options::COMMIT_MAX_RETRIES, "0"}})); | |
| { | |
| std::lock_guard<std::mutex> lock(state_->mutex); | |
| state_->lease_millis = 900; | |
| state_->lose_on_renew = true; | |
| state_->snapshot_delay_millis = 600; | |
| state_->get_table_delay_millis = 100; | |
| state_->table_events.clear(); | |
| } | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<CommitMessage>> msgs, | |
| ReadFixtureCommitMessages(dir_->GetFileSystem())); | |
| Status failed = commit->Commit(msgs, 7); | |
| ASSERT_TRUE(failed.IsInvalid()) << failed.ToString(); | |
| ASSERT_NOK_WITH_MSG(failed, "the commit lock was lost before publishing"); | |
| ASSERT_NOK_WITH_MSG(failed, "is no longer usable: the commit lease has expired"); | |
| ASSERT_EQ(std::string::npos, failed.ToString().find("expired locally")) << failed.ToString(); | |
| std::vector<std::string> events = TableEvents(); | |
| ASSERT_EQ(1, std::count(events.begin(), events.end(), std::string("acquire"))); | |
| ASSERT_EQ(1, std::count(events.begin(), events.end(), std::string("renew"))); | |
| ASSERT_EQ(0, std::count(events.begin(), events.end(), std::string("commit"))); | |
| AssertNoManifestsLeft(); | |
| } | |
| TEST(RestApiErrorTest, ErrorToStatus) { | |
| RestHttpClient::Response response; | |
| response.code = 404; | |
| response.body = R"({"message": "no table", "resourceType": "TABLE", "resourceName": "t1"})"; | |
| response.headers["x-request-id"] = "req-123"; | |
| Status status = RestApi::ErrorToStatus(response); | |
| ASSERT_TRUE(status.IsNotExist()); | |
| ASSERT_TRUE(status.ToString().find("requestId:req-123") != std::string::npos) | |
| << status.ToString(); | |
| ASSERT_NE(status.detail(), nullptr); | |
| ASSERT_STREQ(status.detail()->type_id(), RestErrorDetail::kTypeId); | |
| ASSERT_EQ(static_cast<const RestErrorDetail*>(status.detail().get())->GetResourceType(), | |
| ErrorResponse::kResourceTypeTable); | |
| response.body = R"({"message": "no snapshot", "resourceType": "SNAPSHOT",)" | |
| R"( "resourceName": "t1"})"; | |
| Status snapshot_status = RestApi::ErrorToStatus(response); | |
| ASSERT_TRUE(snapshot_status.IsNotExist()); | |
| ASSERT_EQ( | |
| static_cast<const RestErrorDetail*>(snapshot_status.detail().get())->GetResourceType(), | |
| ErrorResponse::kResourceTypeSnapshot); | |
| response.body = R"({"message": "boom"})"; | |
| Status plain = RestApi::ErrorToStatus(response); | |
| ASSERT_TRUE( | |
| static_cast<const RestErrorDetail*>(plain.detail().get())->GetResourceType().empty()); | |
| response.body = R"({"message": "no table", "resourceType": "TABLE", "resourceName": "t1"})"; | |
| response.code = 409; | |
| Status exist_status = RestApi::ErrorToStatus(response); | |
| ASSERT_TRUE(exist_status.IsExist()); | |
| response.code = 501; | |
| response.body = ""; | |
| ASSERT_TRUE(RestApi::ErrorToStatus(response).IsNotImplemented()); | |
| // a body that is not an error object at all is reported as such, since the body | |
| // itself is never echoed | |
| response.code = 500; | |
| response.body = "not-a-json"; | |
| Status unparsable = RestApi::ErrorToStatus(response); | |
| ASSERT_NOK_WITH_MSG(unparsable, "server error"); | |
| ASSERT_NOK_WITH_MSG(unparsable, "unparsable error response body (http status 500)"); | |
| ASSERT_EQ(std::string::npos, unparsable.ToString().find("not-a-json")) << unparsable.ToString(); | |
| // the code of the error body wins over the http status when they disagree (e.g. a | |
| // gateway rewriting the status) | |
| response.code = 500; | |
| response.body = R"({"message": "gone", "code": 404})"; | |
| ASSERT_TRUE(RestApi::ErrorToStatus(response).IsNotExist()); | |
| // an error object without a message is told apart from an unparsable body, and the | |
| // resource info is kept | |
| response.code = 404; | |
| response.body = R"({"resourceType": "TABLE", "resourceName": "t1"})"; | |
| Status empty_message = RestApi::ErrorToStatus(response); | |
| ASSERT_NOK_WITH_MSG(empty_message, "empty error message (http status 404)"); | |
| ASSERT_TRUE(empty_message.ToString().find("resource name: t1") != std::string::npos) | |
| << empty_message.ToString(); | |
| // server messages that may embed secrets are redacted as a whole | |
| response.code = 400; | |
| response.body = R"({"message": "bad option password=abc123", "code": 400})"; | |
| Status redacted = RestApi::ErrorToStatus(response); | |
| ASSERT_TRUE(redacted.IsInvalid()) << redacted.ToString(); | |
| ASSERT_TRUE(redacted.ToString().find("abc123") == std::string::npos) << redacted.ToString(); | |
| ASSERT_TRUE(redacted.ToString().find("******") != std::string::npos) << redacted.ToString(); | |
| // any header carrying a request id is used when x-request-id is absent | |
| response.code = 404; | |
| response.body = ""; | |
| response.headers.clear(); | |
| response.headers["x-amz-request-id"] = "amz-1"; | |
| Status fallback = RestApi::ErrorToStatus(response); | |
| ASSERT_TRUE(fallback.ToString().find("requestId:amz-1") != std::string::npos) | |
| << fallback.ToString(); | |
| // the "unknown" placeholder is not a real request id | |
| response.headers.clear(); | |
| response.headers["x-request-id"] = "unknown"; | |
| Status unknown_id = RestApi::ErrorToStatus(response); | |
| ASSERT_TRUE(unknown_id.ToString().find("requestId") == std::string::npos) | |
| << unknown_id.ToString(); | |
| // 401/403 map to IOError; the mapped code is carried as a status detail so | |
| // callers can distinguish them | |
| response.headers.clear(); | |
| response.code = 401; | |
| Status not_authorized = RestApi::ErrorToStatus(response); | |
| ASSERT_NOK_WITH_MSG(not_authorized, "not authorized"); | |
| ASSERT_NE(nullptr, not_authorized.detail()); | |
| ASSERT_EQ(std::string(RestErrorDetail::kTypeId), not_authorized.detail()->type_id()); | |
| ASSERT_EQ(401, checked_pointer_cast<RestErrorDetail>(not_authorized.detail())->GetCode()); | |
| response.code = 403; | |
| Status forbidden = RestApi::ErrorToStatus(response); | |
| ASSERT_NOK_WITH_MSG(forbidden, "forbidden"); | |
| ASSERT_EQ(403, checked_pointer_cast<RestErrorDetail>(forbidden.detail())->GetCode()); | |
| // 503 and the codes without an own mapping (e.g. 429) become IOError with a | |
| // message naming the code | |
| response.code = 503; | |
| ASSERT_NOK_WITH_MSG(RestApi::ErrorToStatus(response), "service unavailable"); | |
| response.code = 429; | |
| ASSERT_NOK_WITH_MSG(RestApi::ErrorToStatus(response), "rest request failed with code 429"); | |
| response.code = 418; | |
| ASSERT_NOK_WITH_MSG(RestApi::ErrorToStatus(response), "rest request failed with code 418"); | |
| response.code = 302; | |
| Status signed_redirect = RestApi::ErrorToStatus(response, /*follow_redirects=*/false); | |
| ASSERT_NOK_WITH_MSG(signed_redirect, "redirect status 302"); | |
| ASSERT_NOK_WITH_MSG(signed_redirect, "not followed for signed requests"); | |
| ASSERT_EQ(302, checked_pointer_cast<RestErrorDetail>(signed_redirect.detail())->GetCode()); | |
| } | |
| TEST(RestApiErrorTest, MalformedSuccessBodyFails) { | |
| // a 200 response whose body is not the expected json must fail, not crash or | |
| // return partial data | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<MockRestServer> server, | |
| MockRestServer::Start([](const MockRestServer::Request& request) { | |
| MockRestServer::Response response; | |
| response.body = "not-a-json"; | |
| return response; | |
| })); | |
| std::map<std::string, std::string> options = { | |
| {CatalogOptions::URI, server->GetBaseUri()}, | |
| {CatalogOptions::TOKEN_PROVIDER, "bear"}, | |
| {CatalogOptions::TOKEN, kToken}, | |
| }; | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestApi> api, | |
| RestApi::Create(options, "", /*config_required=*/false)); | |
| Status list_status = api->ListDatabases().status(); | |
| ASSERT_NOK(list_status); | |
| // the body is not echoed into the error: a successful response may contain | |
| // credentials | |
| ASSERT_EQ(std::string::npos, list_status.ToString().find("not-a-json")) | |
| << list_status.ToString(); | |
| ASSERT_NOK(api->GetTable(Identifier("db1", "t1")).status()); | |
| Status config_status = RestApi::Create(options, "", /*config_required=*/true).status(); | |
| ASSERT_NOK(config_status); | |
| ASSERT_EQ(std::string::npos, config_status.ToString().find("not-a-json")) | |
| << config_status.ToString(); | |
| } | |
| TEST(RestApiErrorTest, PagedListingStopsOnEmptyPageWithToken) { | |
| // a server that keeps returning a page token with no data must not loop forever | |
| std::atomic<int32_t> request_count{0}; | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<MockRestServer> server, | |
| MockRestServer::Start([&](const MockRestServer::Request& request) { | |
| request_count++; | |
| MockRestServer::Response response; | |
| response.body = R"({"databases":[],"nextPageToken":"more"})"; | |
| return response; | |
| })); | |
| std::map<std::string, std::string> options = { | |
| {CatalogOptions::URI, server->GetBaseUri()}, | |
| {CatalogOptions::TOKEN_PROVIDER, "bear"}, | |
| {CatalogOptions::TOKEN, kToken}, | |
| }; | |
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<RestApi> api, | |
| RestApi::Create(options, "", /*config_required=*/false)); | |
| ASSERT_OK_AND_ASSIGN(std::vector<std::string> databases, api->ListDatabases()); | |
| ASSERT_TRUE(databases.empty()); | |
| ASSERT_EQ(1, request_count.load()); | |
| } | |
| } // namespace paimon::test |