Logo ROOT  
Reference Guide
 
Loading...
Searching...
No Matches
RPageStorageS3.cxx
Go to the documentation of this file.
1/// \file RPageStorageS3.cxx
2/// \author Jas Mehta <jasmehta805@gmail.com>
3/// \date 2026-06-01
4
5/*************************************************************************
6 * Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. *
7 * All rights reserved. *
8 * *
9 * For the licensing terms see $ROOTSYS/LICENSE. *
10 * For the list of contributors see $ROOTSYS/README/CREDITS. *
11 *************************************************************************/
12
14
15#include <ROOT/RCurlConnection.hxx>
16#include <ROOT/RLogger.hxx>
17#include <ROOT/RNTupleTypes.hxx>
18#include <ROOT/RNTupleUtils.hxx>
19#include <ROOT/RNTupleZip.hxx>
20#include <ROOT/RPage.hxx>
21#include <ROOT/StringUtils.hxx>
22
23#include <nlohmann/json.hpp>
24#include <xxhash.h>
25
26#include <cctype>
27#include <cstring>
28#include <mutex>
29#include <string>
30#include <utility>
31
34
35/// Field-by-field equality check across all data members.
37{
38 return fVersionAnchor == other.fVersionAnchor && fVersionEpoch == other.fVersionEpoch &&
39 fVersionMajor == other.fVersionMajor && fVersionMinor == other.fVersionMinor &&
40 fVersionPatch == other.fVersionPatch && fUrlTemplate == other.fUrlTemplate &&
41 fCloneTemplate == other.fCloneTemplate && fHeaderObjId == other.fHeaderObjId &&
42 fHeaderOffset == other.fHeaderOffset && fNBytesHeader == other.fNBytesHeader &&
43 fLenHeader == other.fLenHeader && fFooterObjId == other.fFooterObjId &&
44 fFooterOffset == other.fFooterOffset && fNBytesFooter == other.fNBytesFooter &&
45 fLenFooter == other.fLenFooter;
46}
47
48/// Serialize the anchor to a pretty-printed JSON string (2-space indent).
49/// The checksum is computed over the compact canonical form of the data fields;
50/// the stored JSON uses pretty-printing for readability.
52{
53 nlohmann::json jsonAnchor;
54 jsonAnchor["anchorVersion"] = fVersionAnchor;
55 jsonAnchor["formatVersionEpoch"] = fVersionEpoch;
56 jsonAnchor["formatVersionMajor"] = fVersionMajor;
57 jsonAnchor["formatVersionMinor"] = fVersionMinor;
58 jsonAnchor["formatVersionPatch"] = fVersionPatch;
59 jsonAnchor["urlTemplate"] = fUrlTemplate;
60 jsonAnchor["cloneTemplate"] = fCloneTemplate;
61 jsonAnchor["headerObjId"] = fHeaderObjId;
62 jsonAnchor["headerOffset"] = fHeaderOffset;
63 jsonAnchor["nBytesHeader"] = fNBytesHeader;
64 jsonAnchor["lenHeader"] = fLenHeader;
65 jsonAnchor["footerObjId"] = fFooterObjId;
66 jsonAnchor["footerOffset"] = fFooterOffset;
67 jsonAnchor["nBytesFooter"] = fNBytesFooter;
68 jsonAnchor["lenFooter"] = fLenFooter;
69
70 auto canonical = jsonAnchor.dump(-1);
71 jsonAnchor["checksum"] = XXH3_64bits(canonical.data(), canonical.size());
72 return jsonAnchor.dump(2);
73}
74
75/// Construct an anchor from a JSON string.
76/// The anchor version is checked first; if it does not match the current version,
77/// parsing fails immediately. All remaining fields are extracted with jsonAnchor.at()
78/// which throws on missing keys or type mismatches.
81{
82 nlohmann::json jsonAnchor;
83 try {
84 jsonAnchor = nlohmann::json::parse(json);
85 } catch (const nlohmann::json::parse_error &e) {
86 return R__FAIL("cannot parse S3 anchor JSON: " + std::string(e.what()));
87 }
88
90
91 try {
92 anchor.fVersionAnchor = jsonAnchor.at("anchorVersion").get<std::uint32_t>();
93 } catch (const nlohmann::json::exception &e) {
94 return R__FAIL("missing or invalid 'anchorVersion' in S3 anchor: " + std::string(e.what()));
95 }
96
97 if (anchor.fVersionAnchor != RNTupleAnchorS3().fVersionAnchor)
98 return R__FAIL("unsupported S3 anchor version: " + std::to_string(anchor.fVersionAnchor));
99
100 try {
101 anchor.fVersionEpoch = jsonAnchor.at("formatVersionEpoch").get<std::uint16_t>();
102 anchor.fVersionMajor = jsonAnchor.at("formatVersionMajor").get<std::uint16_t>();
103 anchor.fVersionMinor = jsonAnchor.at("formatVersionMinor").get<std::uint16_t>();
104 anchor.fVersionPatch = jsonAnchor.at("formatVersionPatch").get<std::uint16_t>();
105 anchor.fUrlTemplate = jsonAnchor.at("urlTemplate").get<std::string>();
106 anchor.fCloneTemplate = jsonAnchor.at("cloneTemplate").get<std::string>();
107 anchor.fHeaderObjId = jsonAnchor.at("headerObjId").get<std::uint64_t>();
108 anchor.fHeaderOffset = jsonAnchor.at("headerOffset").get<std::uint64_t>();
109 anchor.fNBytesHeader = jsonAnchor.at("nBytesHeader").get<std::uint64_t>();
110 anchor.fLenHeader = jsonAnchor.at("lenHeader").get<std::uint64_t>();
111 anchor.fFooterObjId = jsonAnchor.at("footerObjId").get<std::uint64_t>();
112 anchor.fFooterOffset = jsonAnchor.at("footerOffset").get<std::uint64_t>();
113 anchor.fNBytesFooter = jsonAnchor.at("nBytesFooter").get<std::uint64_t>();
114 anchor.fLenFooter = jsonAnchor.at("lenFooter").get<std::uint64_t>();
115 } catch (const nlohmann::json::exception &e) {
116 return R__FAIL("missing or invalid field in S3 anchor: " + std::string(e.what()));
117 }
118
119 if (!jsonAnchor.contains("checksum"))
120 return R__FAIL("missing 'checksum' field in S3 anchor");
121
122 std::uint64_t storedChecksum;
123 try {
124 storedChecksum = jsonAnchor.at("checksum").get<std::uint64_t>();
125 } catch (const nlohmann::json::exception &e) {
126 return R__FAIL("invalid 'checksum' field in S3 anchor: " + std::string(e.what()));
127 }
128
129 jsonAnchor.erase("checksum");
130 auto canonical = jsonAnchor.dump(-1);
131 auto computedChecksum = XXH3_64bits(canonical.data(), canonical.size());
132
134 return R__FAIL("S3 anchor checksum mismatch");
135
136 return anchor;
137}
138
139// S3 URI parsing
140
142{
143 const std::string uriStr(uri);
144
145 // The base URL is a plain bucket/path prefix (MakeObjectUrl() appends "/<id>") and S3 authentication
146 // comes from the environment via SigV4, not from the URL. Reject embedded userinfo, query strings,
147 // and fragments rather than silently mishandling them.
148 if (uriStr.find_first_of("@?#") != std::string::npos)
149 return R__FAIL("S3 URI must not contain userinfo ('@'), a query ('?') or a fragment ('#'): " + uriStr);
150
151 // The dedicated ntpl+s3 scheme marks an RNTuple stored natively as S3 objects, distinguishing it
152 // from a ROOT file stored on S3 (which is opened through the S3 handler for s3:// URLs). Use
153 // ntpl+s3+https:// in production; ntpl+s3+http:// targets local/testing endpoints such as MinIO and
154 // transmits data unencrypted. The scheme is matched case-insensitively (RFC 3986), but the host,
155 // bucket and key are kept verbatim because they are case-sensitive.
156 std::string schemeLower;
157 for (std::size_t i = 0; i < uriStr.size() && i < std::strlen("ntpl+s3+https://"); ++i)
158 schemeLower.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(uriStr[i]))));
159
160 std::string httpScheme;
161 std::size_t schemeLen = 0;
162 if (ROOT::StartsWith(schemeLower, "ntpl+s3+https://")) {
163 httpScheme = "https";
164 schemeLen = std::strlen("ntpl+s3+https://");
165 } else if (ROOT::StartsWith(schemeLower, "ntpl+s3+http://")) {
166 httpScheme = "http";
167 schemeLen = std::strlen("ntpl+s3+http://");
168 } else {
169 return R__FAIL("invalid S3 URI (expected ntpl+s3+http:// or ntpl+s3+https://): " + uriStr);
170 }
171
172 std::string hostAndPath = uriStr.substr(schemeLen);
173 // Drop trailing slashes so MakeObjectUrl() never produces "//" in an object key and the anchor key
174 // (the base URL itself) is not left ending in '/'.
175 while (!hostAndPath.empty() && hostAndPath.back() == '/')
176 hostAndPath.pop_back();
177
178 // There must be a host after the scheme; check for emptiness once the trailing slashes are removed,
179 // so a URI that is only slashes after the scheme (e.g. "ntpl+s3+http:///") is rejected as well.
180 if (hostAndPath.empty())
181 return R__FAIL("S3 URI has no host: " + uriStr);
182
183 return httpScheme + "://" + hostAndPath;
184}
185
186// RPageSinkS3
187
189 const ROOT::RNTupleWriteOptions &options)
190 : RPageSinkS3(ntupleName, ParseS3Url(uri).Unwrap(), options, RFromBaseUrl{})
191{
192}
193
196 : RPagePersistentSink(ntupleName, options), fBaseUrl(baseUrl), fConnection(fBaseUrl)
197{
198 static std::once_flag once;
199 std::call_once(once, []() {
200 R__LOG_WARNING(ROOT::Internal::NTupleLog()) << "The S3 backend is experimental and still under development. "
201 << "Do not store real data with this version of RNTuple!";
202 });
203 fConnection.SetCredentialsFromEnvironment();
204 EnableDefaultMetrics("RPageSinkS3");
205}
206
208
210{
211 return fBaseUrl + "/" + std::to_string(objId);
212}
213
214void ROOT::Experimental::Internal::RPageSinkS3::PutObject(const std::string &url, const unsigned char *data,
215 std::size_t size)
216{
217 // All objects share fConnection; retarget it to this object's URL (via SetUrl) so curl can keep
218 // the connection alive across uploads to the same host.
219 fConnection.SetUrl(url).ThrowOnError();
220 auto status = fConnection.SendPutReq(data, size);
221 if (!status)
222 throw ROOT::RException(R__FAIL("S3 PUT failed for " + url + ": " + status.fStatusMsg));
223}
224
226{
227 // fAnchor.fUrlTemplate keeps its default ("${baseurl}/${objid}").
228
230 auto szZipHeader =
231 RNTupleCompressor::Zip(serializedHeader, length, GetWriteOptions().GetCompression(), zipBuffer.get());
232
233 const auto headerObjId = fObjectId++;
234 {
235 Detail::RNTupleAtomicTimer timer(fCounters->fTimeWallWrite, fCounters->fTimeCpuWrite);
236 PutObject(MakeObjectUrl(headerObjId), zipBuffer.get(), szZipHeader);
237 }
238
239 fAnchor.fHeaderObjId = headerObjId;
240 fAnchor.fHeaderOffset = 0;
241 fAnchor.fNBytesHeader = szZipHeader;
242 fAnchor.fLenHeader = length;
243}
244
248{
249 // Mode B: one S3 object per sealed page, located by a kTypeObject64 locator
250 const auto pageObjId = fObjectId++;
251 {
252 Detail::RNTupleAtomicTimer timer(fCounters->fTimeWallWrite, fCounters->fTimeCpuWrite);
253 PutObject(MakeObjectUrl(pageObjId), reinterpret_cast<const unsigned char *>(sealedPage.GetBuffer()),
254 sealedPage.GetBufferSize());
255 }
256
259 result.SetNBytesOnStorage(sealedPage.GetDataSize());
261 fCounters->fNPageCommitted.Inc();
262 fCounters->fSzWritePayload.Add(sealedPage.GetBufferSize());
263 fNBytesCurrentCluster += sealedPage.GetBufferSize();
264 return result;
265}
266
268{
269 return std::exchange(fNBytesCurrentCluster, 0);
270}
271
274 std::uint32_t length)
275{
277 auto szPageListZip =
278 RNTupleCompressor::Zip(serializedPageList, length, GetWriteOptions().GetCompression(), bufPageListZip.get());
279
280 const auto objId = fObjectId++;
281 {
282 Detail::RNTupleAtomicTimer timer(fCounters->fTimeWallWrite, fCounters->fTimeCpuWrite);
283 PutObject(MakeObjectUrl(objId), bufPageListZip.get(), szPageListZip);
284 }
285
288 result.SetNBytesOnStorage(szPageListZip);
290 fCounters->fSzWritePayload.Add(static_cast<std::int64_t>(szPageListZip));
291 return result;
292}
293
296{
298 auto szFooterZip =
299 RNTupleCompressor::Zip(serializedFooter, length, GetWriteOptions().GetCompression(), bufFooterZip.get());
300
301 const auto footerObjId = fObjectId++;
302 {
303 Detail::RNTupleAtomicTimer timer(fCounters->fTimeWallWrite, fCounters->fTimeCpuWrite);
304 PutObject(MakeObjectUrl(footerObjId), bufFooterZip.get(), szFooterZip);
305 }
306
307 fAnchor.fFooterObjId = footerObjId;
308 fAnchor.fFooterOffset = 0;
309 fAnchor.fNBytesFooter = szFooterZip;
310 fAnchor.fLenFooter = length;
311
312 // Upload the anchor LAST: once it exists at the base URL, a reader can assume the whole ntuple
313 // is complete. Never upload it before all other objects are in place.
314 const auto anchorJson = fAnchor.ToJSON();
315 PutObject(fBaseUrl, reinterpret_cast<const unsigned char *>(anchorJson.data()), anchorJson.size());
316
317 // An S3 ntuple is self-locating: its anchor always lives at the base URL, so there is no anchor
318 // link to hand back here.
319 return {};
320}
321
322std::unique_ptr<ROOT::Internal::RPageSink>
324 const ROOT::RNTupleWriteOptions &opts) const
325{
326 // Resolve the clone template so the hidden ntuple's objects and anchor live under a sub-prefix
327 // that cannot collide with the main ntuple's numeric object keys.
328 std::string cloneBaseUrl = fAnchor.fCloneTemplate;
329
330 auto pos = cloneBaseUrl.find("${baseurl}");
331 if (pos != std::string::npos)
332 cloneBaseUrl.replace(pos, std::strlen("${baseurl}"), fBaseUrl);
333
334 pos = cloneBaseUrl.find("${name}");
335 if (pos != std::string::npos)
336 cloneBaseUrl.replace(pos, std::strlen("${name}"), name);
337
338 return std::unique_ptr<ROOT::Internal::RPageSink>(new RPageSinkS3(name, cloneBaseUrl, opts, RFromBaseUrl{}));
339}
nlohmann::json json
#define R__FAIL(msg)
Short-hand to return an RResult<T> in an error state; the RError is implicitly converted into RResult...
Definition RError.hxx:322
#define R__LOG_WARNING(...)
Definition RLogger.hxx:357
#define e(i)
Definition RSha256.hxx:103
size_t size(const MatrixT &matrix)
retrieve the size of a square matrix
ROOT::Detail::TRangeCast< T, true > TRangeDynCast
TRangeDynCast is an adapter class that allows the typed iteration through a TCollection.
Option_t Option_t TPoint TPoint const char GetTextMagnitude GetFillStyle GetLineColor GetLineWidth GetMarkerStyle GetTextAlign GetTextColor GetTextSize void data
Option_t Option_t TPoint TPoint const char GetTextMagnitude GetFillStyle GetLineColor GetLineWidth GetMarkerStyle GetTextAlign GetTextColor GetTextSize void char Point_t Rectangle_t WindowAttributes_t Float_t Float_t Float_t Int_t Int_t UInt_t UInt_t Rectangle_t result
Option_t Option_t TPoint TPoint const char GetTextMagnitude GetFillStyle GetLineColor GetLineWidth GetMarkerStyle GetTextAlign GetTextColor GetTextSize void char Point_t Rectangle_t WindowAttributes_t Float_t Float_t Float_t Int_t Int_t UInt_t UInt_t Rectangle_t Int_t Int_t Window_t TString Int_t GCValues_t GetPrimarySelectionOwner GetDisplay GetScreen GetColormap GetNativeEvent const char const char dpyName wid window const char font_name cursor keysym reg const char only_if_exist regb h Point_t winding char text const char depth char const char Int_t count const char ColorStruct_t color const char Pixmap_t Pixmap_t PictureAttributes_t attr const char char ret_data h unsigned char height h length
char name[80]
Definition TGX11.cxx:148
Storage provider that writes ntuple pages into S3-compatible object storage.
ROOT::Internal::RCurlConnection fConnection
One HTTP connection reused for every upload, so curl keeps it alive across objects on the same host i...
void InitImpl(unsigned char *serializedHeader, std::uint32_t length) final
std::uint64_t StageClusterImpl() final
Returns the number of bytes written to storage (excluding metadata)
RNTupleLocator CommitClusterGroupImpl(unsigned char *serializedPageList, std::uint32_t length) final
Returns the locator of the page list envelope of the given buffer that contains the serialized page l...
std::unique_ptr< ROOT::Internal::RPageSink > CloneAsHidden(std::string_view name, const ROOT::RNTupleWriteOptions &opts) const final
Creates a new sink with the same underlying storage as this but writing to a different RNTuple named ...
std::string MakeObjectUrl(std::uint64_t objId) const
Resolve a numeric object ID to its full HTTP URL.
RNTupleLocator CommitSealedPageImpl(ROOT::DescriptorId_t physicalColumnId, const RPageStorage::RSealedPage &sealedPage) final
RPageSinkS3(std::string_view ntupleName, std::string_view baseUrl, const ROOT::RNTupleWriteOptions &options, RFromBaseUrl)
Internal constructor used by CloneAsHidden: the public constructor derives the base URL by parsing an...
void PutObject(const std::string &url, const unsigned char *data, std::size_t size)
Upload raw bytes to the given S3 URL via an HTTP PUT request.
Helper class to compress data blocks in the ROOT compression frame format.
static std::size_t Zip(const void *from, std::size_t nbytes, int compression, void *to)
Returns the size of the compressed data, written into the provided output buffer.
Base class for a sink with a physical storage backend.
void EnableDefaultMetrics(const std::string &prefix)
Enables the default set of metrics provided by RPageSink.
Base class for all ROOT issued exceptions.
Definition RError.hxx:78
RNTupleLocator payload that is common for object stores using 64bit location information.
Generic information about the physical location of data.
Common user-tunable settings for storing RNTuples.
The class is used as a return type for operations that can fail; wraps a value of type T or an RError...
Definition RError.hxx:222
RResult< std::string > ParseS3Url(std::string_view uri)
Translate an ntpl+s3 URI into its plain HTTP(S) equivalent.
ROOT::RLogChannel & NTupleLog()
Log channel for RNTuple diagnostics.
std::unique_ptr< T[]> MakeUninitArray(std::size_t size)
Make an array of default-initialized elements.
std::uint64_t DescriptorId_t
Distriniguishes elements of the same type within a descriptor, e.g. different fields.
bool StartsWith(std::string_view string, std::string_view prefix)
Entry point for an RNTuple stored in S3-compatible object storage.
bool operator==(const RNTupleAnchorS3 &other) const
Field-by-field equality check across all data members.
std::uint64_t fHeaderObjId
Object ID and byte offset of the compressed header within the S3 object.
std::string fUrlTemplate
Pattern for resolving object IDs to full S3 URLs.
std::uint32_t fVersionAnchor
Allows evolving the anchor JSON schema in future versions.
std::uint16_t fVersionEpoch
Version of the RNTuple binary format supported by the writer.
std::string fCloneTemplate
Pattern for resolving clone (attribute-set) names to base URLs.
std::uint64_t fNBytesHeader
Compressed and uncompressed sizes of the header envelope.
std::string ToJSON() const
Serialize the anchor to a JSON string suitable for storage at the base URL.
std::uint64_t fNBytesFooter
Compressed and uncompressed sizes of the footer envelope.
static RResult< RNTupleAnchorS3 > CreateFromJSON(const std::string &json)
Deserialize the anchor from a JSON string. Returns an error on malformed or incompatible input.
std::uint64_t fFooterObjId
Object ID and byte offset of the compressed footer within the S3 object.
Tag to select the internal constructor that takes an already-resolved base URL.
A sealed page contains the bytes of a page as written to storage (packed & compressed).