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
25#include <cctype>
26#include <cstring>
27#include <mutex>
28#include <string>
29#include <utility>
30
33
34/// Field-by-field equality check across all 14 anchor members.
35/// Used to verify round-trip correctness in tests.
37{
38 return fVersionAnchor == other.fVersionAnchor && fVersionEpoch == other.fVersionEpoch &&
39 fVersionMajor == other.fVersionMajor && fVersionMinor == other.fVersionMinor &&
40 fVersionPatch == other.fVersionPatch && fUrlTemplate == other.fUrlTemplate &&
41 fHeaderObjId == other.fHeaderObjId && fHeaderOffset == other.fHeaderOffset &&
42 fNBytesHeader == other.fNBytesHeader && fLenHeader == other.fLenHeader &&
43 fFooterObjId == other.fFooterObjId && fFooterOffset == other.fFooterOffset &&
44 fNBytesFooter == other.fNBytesFooter && fLenFooter == other.fLenFooter;
45}
46
47/// Serialize the anchor to a pretty-printed JSON string (2-space indent).
48/// nlohmann/json handles type conversion, string escaping, and uint64 precision.
49/// The output is suitable for direct upload to S3 as the anchor object.
51{
52 nlohmann::json jsonAnchor;
53 jsonAnchor["anchorVersion"] = fVersionAnchor;
54 jsonAnchor["formatVersionEpoch"] = fVersionEpoch;
55 jsonAnchor["formatVersionMajor"] = fVersionMajor;
56 jsonAnchor["formatVersionMinor"] = fVersionMinor;
57 jsonAnchor["formatVersionPatch"] = fVersionPatch;
58 jsonAnchor["urlTemplate"] = fUrlTemplate;
59 jsonAnchor["headerObjId"] = fHeaderObjId;
60 jsonAnchor["headerOffset"] = fHeaderOffset;
61 jsonAnchor["nBytesHeader"] = fNBytesHeader;
62 jsonAnchor["lenHeader"] = fLenHeader;
63 jsonAnchor["footerObjId"] = fFooterObjId;
64 jsonAnchor["footerOffset"] = fFooterOffset;
65 jsonAnchor["nBytesFooter"] = fNBytesFooter;
66 jsonAnchor["lenFooter"] = fLenFooter;
67 return jsonAnchor.dump(2);
68}
69
70/// Construct an anchor from a JSON string.
71/// The anchor version is checked first; if it does not match the current version,
72/// parsing fails immediately. All remaining fields are extracted with jsonAnchor.at()
73/// which throws on missing keys or type mismatches.
76{
77 nlohmann::json jsonAnchor;
78 try {
79 jsonAnchor = nlohmann::json::parse(json);
80 } catch (const nlohmann::json::parse_error &e) {
81 return R__FAIL("cannot parse S3 anchor JSON: " + std::string(e.what()));
82 }
83
85
86 try {
87 anchor.fVersionAnchor = jsonAnchor.at("anchorVersion").get<std::uint32_t>();
88 } catch (const nlohmann::json::exception &e) {
89 return R__FAIL("missing or invalid 'anchorVersion' in S3 anchor: " + std::string(e.what()));
90 }
91
92 if (anchor.fVersionAnchor != RNTupleAnchorS3().fVersionAnchor)
93 return R__FAIL("unsupported S3 anchor version: " + std::to_string(anchor.fVersionAnchor));
94
95 try {
96 anchor.fVersionEpoch = jsonAnchor.at("formatVersionEpoch").get<std::uint16_t>();
97 anchor.fVersionMajor = jsonAnchor.at("formatVersionMajor").get<std::uint16_t>();
98 anchor.fVersionMinor = jsonAnchor.at("formatVersionMinor").get<std::uint16_t>();
99 anchor.fVersionPatch = jsonAnchor.at("formatVersionPatch").get<std::uint16_t>();
100 anchor.fUrlTemplate = jsonAnchor.at("urlTemplate").get<std::string>();
101 anchor.fHeaderObjId = jsonAnchor.at("headerObjId").get<std::uint64_t>();
102 anchor.fHeaderOffset = jsonAnchor.at("headerOffset").get<std::uint64_t>();
103 anchor.fNBytesHeader = jsonAnchor.at("nBytesHeader").get<std::uint64_t>();
104 anchor.fLenHeader = jsonAnchor.at("lenHeader").get<std::uint64_t>();
105 anchor.fFooterObjId = jsonAnchor.at("footerObjId").get<std::uint64_t>();
106 anchor.fFooterOffset = jsonAnchor.at("footerOffset").get<std::uint64_t>();
107 anchor.fNBytesFooter = jsonAnchor.at("nBytesFooter").get<std::uint64_t>();
108 anchor.fLenFooter = jsonAnchor.at("lenFooter").get<std::uint64_t>();
109 } catch (const nlohmann::json::exception &e) {
110 return R__FAIL("missing or invalid field in S3 anchor: " + std::string(e.what()));
111 }
112
113 return anchor;
114}
115
116// S3 URI parsing
117
119{
120 const std::string uriStr(uri);
121
122 // The base URL is a plain bucket/path prefix (MakeObjectUrl() appends "/<id>") and S3 authentication
123 // comes from the environment via SigV4, not from the URL. Reject embedded userinfo, query strings,
124 // and fragments rather than silently mishandling them.
125 if (uriStr.find_first_of("@?#") != std::string::npos)
126 return R__FAIL("S3 URI must not contain userinfo ('@'), a query ('?') or a fragment ('#'): " + uriStr);
127
128 // The dedicated ntpl+s3 scheme marks an RNTuple stored natively as S3 objects, distinguishing it
129 // from a ROOT file stored on S3 (which is opened through the S3 handler for s3:// URLs). Use
130 // ntpl+s3+https:// in production; ntpl+s3+http:// targets local/testing endpoints such as MinIO and
131 // transmits data unencrypted. The scheme is matched case-insensitively (RFC 3986), but the host,
132 // bucket and key are kept verbatim because they are case-sensitive.
133 std::string schemeLower;
134 for (std::size_t i = 0; i < uriStr.size() && i < std::strlen("ntpl+s3+https://"); ++i)
135 schemeLower.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(uriStr[i]))));
136
137 std::string httpScheme;
138 std::size_t schemeLen = 0;
139 if (ROOT::StartsWith(schemeLower, "ntpl+s3+https://")) {
140 httpScheme = "https";
141 schemeLen = std::strlen("ntpl+s3+https://");
142 } else if (ROOT::StartsWith(schemeLower, "ntpl+s3+http://")) {
143 httpScheme = "http";
144 schemeLen = std::strlen("ntpl+s3+http://");
145 } else {
146 return R__FAIL("invalid S3 URI (expected ntpl+s3+http:// or ntpl+s3+https://): " + uriStr);
147 }
148
149 std::string hostAndPath = uriStr.substr(schemeLen);
150 // Drop trailing slashes so MakeObjectUrl() never produces "//" in an object key and the anchor key
151 // (the base URL itself) is not left ending in '/'.
152 while (!hostAndPath.empty() && hostAndPath.back() == '/')
153 hostAndPath.pop_back();
154
155 // There must be a host after the scheme; check for emptiness once the trailing slashes are removed,
156 // so a URI that is only slashes after the scheme (e.g. "ntpl+s3+http:///") is rejected as well.
157 if (hostAndPath.empty())
158 return R__FAIL("S3 URI has no host: " + uriStr);
159
160 return httpScheme + "://" + hostAndPath;
161}
162
163// RPageSinkS3
164
166 const ROOT::RNTupleWriteOptions &options)
167 : RPageSinkS3(ntupleName, ParseS3Url(uri).Unwrap(), options, RFromBaseUrl{})
168{
169}
170
173 : RPagePersistentSink(ntupleName, options), fBaseUrl(baseUrl), fConnection(fBaseUrl)
174{
175 static std::once_flag once;
176 std::call_once(once, []() {
177 R__LOG_WARNING(ROOT::Internal::NTupleLog()) << "The S3 backend is experimental and still under development. "
178 << "Do not store real data with this version of RNTuple!";
179 });
180 fConnection.SetCredentialsFromEnvironment();
181 EnableDefaultMetrics("RPageSinkS3");
182}
183
185
187{
188 return fBaseUrl + "/" + std::to_string(objId);
189}
190
191void ROOT::Experimental::Internal::RPageSinkS3::PutObject(const std::string &url, const unsigned char *data,
192 std::size_t size)
193{
194 // All objects share fConnection; retarget it to this object's URL (via SetUrl) so curl can keep
195 // the connection alive across uploads to the same host.
196 fConnection.SetUrl(url).ThrowOnError();
197 auto status = fConnection.SendPutReq(data, size);
198 if (!status)
199 throw ROOT::RException(R__FAIL("S3 PUT failed for " + url + ": " + status.fStatusMsg));
200}
201
203{
204 // fAnchor.fUrlTemplate keeps its default ("${baseurl}/${objid}").
205
207 auto szZipHeader =
208 RNTupleCompressor::Zip(serializedHeader, length, GetWriteOptions().GetCompression(), zipBuffer.get());
209
210 const auto headerObjId = fObjectId++;
211 {
212 Detail::RNTupleAtomicTimer timer(fCounters->fTimeWallWrite, fCounters->fTimeCpuWrite);
213 PutObject(MakeObjectUrl(headerObjId), zipBuffer.get(), szZipHeader);
214 }
215
216 fAnchor.fHeaderObjId = headerObjId;
217 fAnchor.fHeaderOffset = 0;
218 fAnchor.fNBytesHeader = szZipHeader;
219 fAnchor.fLenHeader = length;
220}
221
225{
226 // Mode B: one S3 object per sealed page, located by a kTypeObject64 locator
227 const auto pageObjId = fObjectId++;
228 {
229 Detail::RNTupleAtomicTimer timer(fCounters->fTimeWallWrite, fCounters->fTimeCpuWrite);
230 PutObject(MakeObjectUrl(pageObjId), reinterpret_cast<const unsigned char *>(sealedPage.GetBuffer()),
231 sealedPage.GetBufferSize());
232 }
233
236 result.SetNBytesOnStorage(sealedPage.GetDataSize());
238 fCounters->fNPageCommitted.Inc();
239 fCounters->fSzWritePayload.Add(sealedPage.GetBufferSize());
240 fNBytesCurrentCluster += sealedPage.GetBufferSize();
241 return result;
242}
243
245{
246 return std::exchange(fNBytesCurrentCluster, 0);
247}
248
251 std::uint32_t length)
252{
254 auto szPageListZip =
255 RNTupleCompressor::Zip(serializedPageList, length, GetWriteOptions().GetCompression(), bufPageListZip.get());
256
257 const auto objId = fObjectId++;
258 {
259 Detail::RNTupleAtomicTimer timer(fCounters->fTimeWallWrite, fCounters->fTimeCpuWrite);
260 PutObject(MakeObjectUrl(objId), bufPageListZip.get(), szPageListZip);
261 }
262
265 result.SetNBytesOnStorage(szPageListZip);
267 fCounters->fSzWritePayload.Add(static_cast<std::int64_t>(szPageListZip));
268 return result;
269}
270
273{
275 auto szFooterZip =
276 RNTupleCompressor::Zip(serializedFooter, length, GetWriteOptions().GetCompression(), bufFooterZip.get());
277
278 const auto footerObjId = fObjectId++;
279 {
280 Detail::RNTupleAtomicTimer timer(fCounters->fTimeWallWrite, fCounters->fTimeCpuWrite);
281 PutObject(MakeObjectUrl(footerObjId), bufFooterZip.get(), szFooterZip);
282 }
283
284 fAnchor.fFooterObjId = footerObjId;
285 fAnchor.fFooterOffset = 0;
286 fAnchor.fNBytesFooter = szFooterZip;
287 fAnchor.fLenFooter = length;
288
289 // Upload the anchor LAST: once it exists at the base URL, a reader can assume the whole ntuple
290 // is complete. Never upload it before all other objects are in place.
291 const auto anchorJson = fAnchor.ToJSON();
292 PutObject(fBaseUrl, reinterpret_cast<const unsigned char *>(anchorJson.data()), anchorJson.size());
293
294 // An S3 ntuple is self-locating: its anchor always lives at the base URL, so there is no anchor
295 // link to hand back here.
296 return {};
297}
298
299std::unique_ptr<ROOT::Internal::RPageSink>
301 const ROOT::RNTupleWriteOptions &opts) const
302{
303 // The hidden (attribute-set) ntuple is stored under a reserved "_clone" sub-prefix so its objects and
304 // anchor can never collide with the main ntuple's numeric object keys ($baseurl/0, $baseurl/1, ...).
305 std::string cloneBaseUrl = fBaseUrl + "/_clone/" + std::string(name);
306 return std::unique_ptr<ROOT::Internal::RPageSink>(new RPageSinkS3(name, cloneBaseUrl, opts, RFromBaseUrl{}));
307}
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 14 anchor 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::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).