Skip to content

Commit 2e68f1f

Browse files
address review: refresh uses cached per-table session; add RestCatalog session test
1 parent fbfb7e9 commit 2e68f1f

5 files changed

Lines changed: 309 additions & 4 deletions

File tree

‎src/iceberg/catalog/rest/rest_catalog.cc‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Status RestCatalog::RememberTableSession(
162162
}
163163

164164
std::shared_ptr<auth::AuthSession> RestCatalog::SessionFor(
165-
const TableIdentifier& identifier) {
165+
const TableIdentifier& identifier) const {
166166
std::lock_guard<std::mutex> lock(table_sessions_mutex_);
167167
auto it = table_sessions_.find(TableSessionKey(identifier));
168168
return it != table_sessions_.end() ? it->second : catalog_session_;
@@ -512,10 +512,12 @@ Result<std::string> RestCatalog::LoadTableInternal(
512512
params["snapshots"] = "all";
513513
}
514514

515+
// Refresh uses the cached per-table session; the initial load falls back to
516+
// the catalog session (no table session is cached yet).
515517
ICEBERG_ASSIGN_OR_RAISE(
516518
const auto response,
517519
client_->Get(path, params, /*headers=*/{}, *TableErrorHandler::Instance(),
518-
*catalog_session_));
520+
*SessionFor(identifier)));
519521
return response.body();
520522
}
521523

‎src/iceberg/catalog/rest/rest_catalog.h‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ class ICEBERG_REST_EXPORT RestCatalog : public Catalog,
117117
const std::unordered_map<std::string, std::string>& config);
118118

119119
/// \brief Returns the cached per-table session, or the catalog session.
120-
std::shared_ptr<auth::AuthSession> SessionFor(const TableIdentifier& identifier);
120+
std::shared_ptr<auth::AuthSession> SessionFor(const TableIdentifier& identifier) const;
121121

122122
Result<LoadTableResult> CreateTableInternal(
123123
const TableIdentifier& identifier, const std::shared_ptr<Schema>& schema,
@@ -134,7 +134,7 @@ class ICEBERG_REST_EXPORT RestCatalog : public Catalog,
134134
std::unique_ptr<auth::AuthManager> auth_manager_;
135135
std::shared_ptr<auth::AuthSession> catalog_session_;
136136
SnapshotMode snapshot_mode_;
137-
std::mutex table_sessions_mutex_;
137+
mutable std::mutex table_sessions_mutex_;
138138
std::unordered_map<std::string, std::shared_ptr<auth::AuthSession>> table_sessions_;
139139
};
140140

‎src/iceberg/test/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ if(ICEBERG_BUILD_REST)
286286
SOURCES
287287
auth_manager_test.cc
288288
endpoint_test.cc
289+
rest_catalog_session_test.cc
289290
rest_file_io_test.cc
290291
rest_json_serde_test.cc
291292
rest_util_test.cc)

‎src/iceberg/test/meson.build‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ if get_option('rest').enabled()
128128
'sources': files(
129129
'auth_manager_test.cc',
130130
'endpoint_test.cc',
131+
'rest_catalog_session_test.cc',
131132
'rest_file_io_test.cc',
132133
'rest_json_serde_test.cc',
133134
'rest_util_test.cc',
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
#include <gtest/gtest.h>
21+
22+
#ifndef _WIN32
23+
24+
# include <unistd.h>
25+
26+
# include <array>
27+
# include <atomic>
28+
# include <memory>
29+
# include <mutex>
30+
# include <string>
31+
# include <thread>
32+
# include <unordered_map>
33+
# include <vector>
34+
35+
# include <netinet/in.h>
36+
# include <sys/socket.h>
37+
38+
# include "iceberg/catalog/rest/auth/auth_manager.h"
39+
# include "iceberg/catalog/rest/auth/auth_managers.h"
40+
# include "iceberg/catalog/rest/auth/auth_properties.h"
41+
# include "iceberg/catalog/rest/auth/auth_session.h"
42+
# include "iceberg/catalog/rest/catalog_properties.h"
43+
# include "iceberg/catalog/rest/rest_catalog.h"
44+
# include "iceberg/file_io.h"
45+
# include "iceberg/file_io_registry.h"
46+
# include "iceberg/table_identifier.h"
47+
# include "iceberg/table_requirement.h"
48+
# include "iceberg/table_update.h"
49+
# include "iceberg/test/matchers.h"
50+
51+
namespace iceberg::rest {
52+
53+
namespace {
54+
55+
constexpr std::string_view kMetadataJson =
56+
R"({"format-version":2,"table-uuid":"test-uuid-1234","location":"s3://bucket/test",)"
57+
R"("last-sequence-number":0,"last-updated-ms":0,"last-column-id":1,)"
58+
R"("schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],)"
59+
R"("current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,)"
60+
R"("last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0})";
61+
62+
struct RecordedRequest {
63+
std::string method;
64+
std::string path;
65+
std::string auth_marker;
66+
};
67+
68+
class MiniRestServer {
69+
public:
70+
bool Start() {
71+
listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
72+
if (listen_fd_ < 0) return false;
73+
int reuse = 1;
74+
::setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
75+
sockaddr_in addr{};
76+
addr.sin_family = AF_INET;
77+
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
78+
addr.sin_port = 0;
79+
if (::bind(listen_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
80+
return false;
81+
}
82+
socklen_t len = sizeof(addr);
83+
::getsockname(listen_fd_, reinterpret_cast<sockaddr*>(&addr), &len);
84+
port_ = ntohs(addr.sin_port);
85+
if (::listen(listen_fd_, 8) < 0) return false;
86+
server_thread_ = std::thread([this, fd = listen_fd_] { Loop(fd); });
87+
return true;
88+
}
89+
90+
void Stop() {
91+
stopping_ = true;
92+
if (listen_fd_ >= 0) {
93+
::shutdown(listen_fd_, SHUT_RDWR);
94+
::close(listen_fd_);
95+
listen_fd_ = -1;
96+
}
97+
if (server_thread_.joinable()) server_thread_.join();
98+
}
99+
100+
int port() const { return port_; }
101+
102+
std::vector<RecordedRequest> requests() {
103+
std::lock_guard<std::mutex> lock(mutex_);
104+
return requests_;
105+
}
106+
107+
private:
108+
void Loop(int listen_fd) {
109+
while (!stopping_) {
110+
int fd = ::accept(listen_fd, nullptr, nullptr);
111+
if (fd < 0) break;
112+
HandleConnection(fd);
113+
::close(fd);
114+
}
115+
}
116+
117+
void HandleConnection(int fd) {
118+
std::string raw;
119+
std::array<char, 4096> buf{};
120+
size_t header_end = std::string::npos;
121+
while (header_end == std::string::npos) {
122+
ssize_t n = ::read(fd, buf.data(), buf.size());
123+
if (n <= 0) return;
124+
raw.append(buf.data(), static_cast<size_t>(n));
125+
header_end = raw.find("\r\n\r\n");
126+
}
127+
size_t content_length = 0;
128+
{
129+
std::string lower;
130+
lower.reserve(header_end);
131+
for (size_t i = 0; i < header_end; ++i) {
132+
lower.push_back(
133+
static_cast<char>(std::tolower(static_cast<unsigned char>(raw[i]))));
134+
}
135+
auto pos = lower.find("content-length:");
136+
if (pos != std::string::npos) {
137+
content_length = std::stoul(lower.substr(pos + 15));
138+
}
139+
}
140+
while (raw.size() < header_end + 4 + content_length) {
141+
ssize_t n = ::read(fd, buf.data(), buf.size());
142+
if (n <= 0) break;
143+
raw.append(buf.data(), static_cast<size_t>(n));
144+
}
145+
146+
auto line_end = raw.find("\r\n");
147+
auto request_line = raw.substr(0, line_end);
148+
auto sp1 = request_line.find(' ');
149+
auto sp2 = request_line.find(' ', sp1 + 1);
150+
RecordedRequest req;
151+
req.method = request_line.substr(0, sp1);
152+
req.path = request_line.substr(sp1 + 1, sp2 - sp1 - 1);
153+
req.auth_marker = HeaderValue(raw.substr(0, header_end), "x-test-auth");
154+
{
155+
std::lock_guard<std::mutex> lock(mutex_);
156+
requests_.push_back(req);
157+
}
158+
159+
Respond(fd, BodyFor(req));
160+
}
161+
162+
static std::string HeaderValue(const std::string& headers, std::string_view name) {
163+
std::string lower;
164+
lower.reserve(headers.size());
165+
for (char c : headers) {
166+
lower.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
167+
}
168+
auto pos = lower.find(std::string(name) + ":");
169+
if (pos == std::string::npos) return "";
170+
auto value_start = pos + name.size() + 1;
171+
auto value_end = headers.find("\r\n", value_start);
172+
auto value = headers.substr(value_start, value_end - value_start);
173+
auto first = value.find_first_not_of(' ');
174+
return first == std::string::npos ? "" : value.substr(first);
175+
}
176+
177+
std::string BodyFor(const RecordedRequest& req) {
178+
if (req.path.find("/v1/config") != std::string::npos) {
179+
return R"({"defaults":{},"overrides":{}})";
180+
}
181+
if (req.method == "GET" && req.path.find("/tables/") != std::string::npos) {
182+
return std::string(R"({"metadata-location":"s3://bucket/meta/v1.json",)") +
183+
R"("metadata":)" + std::string(kMetadataJson) +
184+
R"(,"config":{"token":"tbl-token-1"}})";
185+
}
186+
if (req.method == "POST" && req.path.find("/tables/") != std::string::npos) {
187+
return std::string(R"({"metadata-location":"s3://bucket/meta/v2.json",)") +
188+
R"("metadata":)" + std::string(kMetadataJson) + "}";
189+
}
190+
return "{}";
191+
}
192+
193+
static void Respond(int fd, const std::string& body) {
194+
std::string response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n";
195+
response += "Content-Length: " + std::to_string(body.size()) + "\r\n";
196+
response += "Connection: close\r\n\r\n";
197+
response += body;
198+
size_t sent = 0;
199+
while (sent < response.size()) {
200+
ssize_t n = ::write(fd, response.data() + sent, response.size() - sent);
201+
if (n <= 0) break;
202+
sent += static_cast<size_t>(n);
203+
}
204+
}
205+
206+
int listen_fd_ = -1;
207+
int port_ = 0;
208+
std::atomic<bool> stopping_{false};
209+
std::thread server_thread_;
210+
std::mutex mutex_;
211+
std::vector<RecordedRequest> requests_;
212+
};
213+
214+
class RecordingAuthManager : public auth::AuthManager {
215+
public:
216+
Result<std::shared_ptr<auth::AuthSession>> InitSession(
217+
HttpClient& /*init_client*/,
218+
const std::unordered_map<std::string, std::string>& /*properties*/) override {
219+
return auth::AuthSession::MakeDefault({{"x-test-auth", "init"}});
220+
}
221+
222+
Result<std::shared_ptr<auth::AuthSession>> CatalogSession(
223+
HttpClient& /*shared_client*/,
224+
const std::unordered_map<std::string, std::string>& /*properties*/) override {
225+
return auth::AuthSession::MakeDefault({{"x-test-auth", "catalog"}});
226+
}
227+
228+
Result<std::shared_ptr<auth::AuthSession>> TableSession(
229+
const TableIdentifier& /*table*/,
230+
const std::unordered_map<std::string, std::string>& properties,
231+
std::shared_ptr<auth::AuthSession> parent) override {
232+
auto token = properties.find("token");
233+
if (token == properties.end()) {
234+
return parent;
235+
}
236+
return auth::AuthSession::MakeDefault({{"x-test-auth", "table:" + token->second}});
237+
}
238+
};
239+
240+
class MockFileIO : public FileIO {};
241+
242+
} // namespace
243+
244+
TEST(RestCatalogSessionTest, RefreshAndCommitUseTableSessionFromResponseConfig) {
245+
MiniRestServer server;
246+
ASSERT_TRUE(server.Start());
247+
248+
auth::AuthManagers::Register(
249+
"test-session-recorder",
250+
[](std::string_view /*name*/,
251+
const std::unordered_map<std::string, std::string>& /*properties*/)
252+
-> Result<std::unique_ptr<auth::AuthManager>> {
253+
return std::make_unique<RecordingAuthManager>();
254+
});
255+
FileIORegistry::Register(
256+
"test.SessionMockFileIO",
257+
[](const std::unordered_map<std::string, std::string>& /*properties*/)
258+
-> Result<std::unique_ptr<FileIO>> { return std::make_unique<MockFileIO>(); });
259+
260+
auto config = RestCatalogProperties::FromMap(
261+
{{"uri", "http://127.0.0.1:" + std::to_string(server.port())},
262+
{auth::AuthProperties::kAuthType, "test-session-recorder"},
263+
{"io-impl", "test.SessionMockFileIO"}});
264+
265+
{
266+
auto catalog_result = RestCatalog::Make(config);
267+
ASSERT_THAT(catalog_result, IsOk());
268+
auto catalog = catalog_result.value();
269+
270+
TableIdentifier identifier{.ns = Namespace{{"ns1"}}, .name = "tbl1"};
271+
ASSERT_THAT(catalog->LoadTable(identifier), IsOk());
272+
ASSERT_THAT(catalog->LoadTable(identifier), IsOk());
273+
ASSERT_THAT(catalog->UpdateTable(identifier, {}, {}), IsOk());
274+
}
275+
276+
server.Stop();
277+
278+
auto requests = server.requests();
279+
ASSERT_EQ(requests.size(), 4);
280+
EXPECT_TRUE(requests[0].path.find("/v1/config") != std::string::npos);
281+
EXPECT_EQ(requests[0].auth_marker, "init");
282+
EXPECT_EQ(requests[1].method, "GET");
283+
EXPECT_TRUE(requests[1].path.find("/tables/tbl1") != std::string::npos);
284+
EXPECT_EQ(requests[1].auth_marker, "catalog");
285+
EXPECT_EQ(requests[2].method, "GET");
286+
EXPECT_TRUE(requests[2].path.find("/tables/tbl1") != std::string::npos);
287+
EXPECT_EQ(requests[2].auth_marker, "table:tbl-token-1");
288+
EXPECT_EQ(requests[3].method, "POST");
289+
EXPECT_TRUE(requests[3].path.find("/tables/tbl1") != std::string::npos);
290+
EXPECT_EQ(requests[3].auth_marker, "table:tbl-token-1");
291+
}
292+
293+
} // namespace iceberg::rest
294+
295+
#else
296+
297+
TEST(RestCatalogSessionTest, RefreshAndCommitUseTableSessionFromResponseConfig) {
298+
GTEST_SKIP() << "POSIX-socket test server is not available on Windows";
299+
}
300+
301+
#endif // _WIN32

0 commit comments

Comments
 (0)