Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions tree/ntuple/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ if(daos OR daos_mock)
endif()
endif()

# Enable RNTuple support for S3-compatible object storage
if(curl)
set(ROOTNTuple_EXTRA_HEADERS ${ROOTNTuple_EXTRA_HEADERS} ROOT/RPageStorageS3.hxx)
target_sources(ROOTNTuple PRIVATE src/RPageStorageS3.cxx)
target_link_libraries(ROOTNTuple PRIVATE nlohmann_json::nlohmann_json)
endif()

if(MSVC)
target_compile_definitions(ROOTNTuple PRIVATE _USE_MATH_DEFINES)
endif()
Expand Down
74 changes: 74 additions & 0 deletions tree/ntuple/inc/ROOT/RPageStorageS3.hxx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/// \file ROOT/RPageStorageS3.hxx
/// \author Jas Mehta <jasmehta805@gmail.com>
/// \date 2026-06-01

/*************************************************************************
* Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. *
* All rights reserved. *
* *
* For the licensing terms see $ROOTSYS/LICENSE. *
* For the list of contributors see $ROOTSYS/README/CREDITS. *
*************************************************************************/

#ifndef ROOT_RPageStorageS3
#define ROOT_RPageStorageS3

#include <ROOT/RError.hxx>
#include <ROOT/RNTuple.hxx>

#include <cstdint>
#include <string>

namespace ROOT {
namespace Experimental {
namespace Internal {

// clang-format off
/**
\class ROOT::Experimental::Internal::RNTupleAnchorS3
\ingroup NTuple
\brief Entry point for an RNTuple stored in S3-compatible object storage.

The anchor is serialized as a JSON object and stored at the base URL of the ntuple.
It contains the information needed to locate and read the header and footer envelopes.
The anchor is always the last object written during CommitDatasetImpl, ensuring atomicity:
if the anchor exists, the entire ntuple is complete.
*/
// clang-format on
struct RNTupleAnchorS3 {
/// Allows evolving the anchor JSON schema in future versions
std::uint32_t fVersionAnchor = 0;
/// Version of the RNTuple binary format supported by the writer
std::uint16_t fVersionEpoch = RNTuple::kVersionEpoch;
std::uint16_t fVersionMajor = RNTuple::kVersionMajor;
std::uint16_t fVersionMinor = RNTuple::kVersionMinor;
std::uint16_t fVersionPatch = RNTuple::kVersionPatch;
/// Pattern for resolving object IDs to full S3 URLs.
/// ${baseurl} is replaced with the anchor URL, ${objid} with the numeric object ID.
std::string fUrlTemplate;
/// Object ID and byte offset of the compressed header within the S3 object
std::uint64_t fHeaderObjId = 0;
std::uint64_t fHeaderOffset = 0;
/// Compressed and uncompressed sizes of the header envelope
std::uint64_t fNBytesHeader = 0;
std::uint64_t fLenHeader = 0;
/// Object ID and byte offset of the compressed footer within the S3 object
std::uint64_t fFooterObjId = 0;
std::uint64_t fFooterOffset = 0;
/// Compressed and uncompressed sizes of the footer envelope
std::uint64_t fNBytesFooter = 0;
std::uint64_t fLenFooter = 0;

bool operator==(const RNTupleAnchorS3 &other) const;

/// Serialize the anchor to a JSON string suitable for storage at the base URL
std::string ToJSON() const;
/// Deserialize the anchor from a JSON string. Returns an error on malformed or incompatible input.
static RResult<RNTupleAnchorS3> CreateFromJSON(const std::string &json);
};

} // namespace Internal
} // namespace Experimental
} // namespace ROOT

#endif
99 changes: 99 additions & 0 deletions tree/ntuple/src/RPageStorageS3.cxx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/// \file RPageStorageS3.cxx
/// \author Jas Mehta <jasmehta805@gmail.com>
/// \date 2026-06-01

/*************************************************************************
* Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. *
* All rights reserved. *
* *
* For the licensing terms see $ROOTSYS/LICENSE. *
* For the list of contributors see $ROOTSYS/README/CREDITS. *
*************************************************************************/

#include <ROOT/RPageStorageS3.hxx>

#include <nlohmann/json.hpp>

#include <string>

/// Field-by-field equality check across all 14 anchor members.
/// Used to verify round-trip correctness in tests.
bool ROOT::Experimental::Internal::RNTupleAnchorS3::operator==(const RNTupleAnchorS3 &other) const
{
return fVersionAnchor == other.fVersionAnchor && fVersionEpoch == other.fVersionEpoch &&
fVersionMajor == other.fVersionMajor && fVersionMinor == other.fVersionMinor &&
fVersionPatch == other.fVersionPatch && fUrlTemplate == other.fUrlTemplate &&
fHeaderObjId == other.fHeaderObjId && fHeaderOffset == other.fHeaderOffset &&
fNBytesHeader == other.fNBytesHeader && fLenHeader == other.fLenHeader &&
fFooterObjId == other.fFooterObjId && fFooterOffset == other.fFooterOffset &&
fNBytesFooter == other.fNBytesFooter && fLenFooter == other.fLenFooter;
}

/// Serialize the anchor to a pretty-printed JSON string (2-space indent).
/// nlohmann/json handles type conversion, string escaping, and uint64 precision.
/// The output is suitable for direct upload to S3 as the anchor object.
std::string ROOT::Experimental::Internal::RNTupleAnchorS3::ToJSON() const
{
nlohmann::json jsonAnchor;
jsonAnchor["anchorVersion"] = fVersionAnchor;
jsonAnchor["formatVersionEpoch"] = fVersionEpoch;
jsonAnchor["formatVersionMajor"] = fVersionMajor;
jsonAnchor["formatVersionMinor"] = fVersionMinor;
jsonAnchor["formatVersionPatch"] = fVersionPatch;
jsonAnchor["urlTemplate"] = fUrlTemplate;
jsonAnchor["headerObjId"] = fHeaderObjId;
jsonAnchor["headerOffset"] = fHeaderOffset;
jsonAnchor["nBytesHeader"] = fNBytesHeader;
jsonAnchor["lenHeader"] = fLenHeader;
jsonAnchor["footerObjId"] = fFooterObjId;
jsonAnchor["footerOffset"] = fFooterOffset;
jsonAnchor["nBytesFooter"] = fNBytesFooter;
jsonAnchor["lenFooter"] = fLenFooter;
return jsonAnchor.dump(2);
}

/// Construct an anchor from a JSON string.
/// The anchor version is checked first; if it does not match the current version,
/// parsing fails immediately. All remaining fields are extracted with jsonAnchor.at()
/// which throws on missing keys or type mismatches.
ROOT::RResult<ROOT::Experimental::Internal::RNTupleAnchorS3>
ROOT::Experimental::Internal::RNTupleAnchorS3::CreateFromJSON(const std::string &json)
{
nlohmann::json jsonAnchor;
try {
jsonAnchor = nlohmann::json::parse(json);
} catch (const nlohmann::json::parse_error &e) {
return R__FAIL("cannot parse S3 anchor JSON: " + std::string(e.what()));
}

RNTupleAnchorS3 anchor;

try {
anchor.fVersionAnchor = jsonAnchor.at("anchorVersion").get<std::uint32_t>();
} catch (const nlohmann::json::exception &e) {
return R__FAIL("missing or invalid 'anchorVersion' in S3 anchor: " + std::string(e.what()));
}

if (anchor.fVersionAnchor != RNTupleAnchorS3().fVersionAnchor)
return R__FAIL("unsupported S3 anchor version: " + std::to_string(anchor.fVersionAnchor));

try {
anchor.fVersionEpoch = jsonAnchor.at("formatVersionEpoch").get<std::uint16_t>();
anchor.fVersionMajor = jsonAnchor.at("formatVersionMajor").get<std::uint16_t>();
anchor.fVersionMinor = jsonAnchor.at("formatVersionMinor").get<std::uint16_t>();
anchor.fVersionPatch = jsonAnchor.at("formatVersionPatch").get<std::uint16_t>();
anchor.fUrlTemplate = jsonAnchor.at("urlTemplate").get<std::string>();
anchor.fHeaderObjId = jsonAnchor.at("headerObjId").get<std::uint64_t>();
anchor.fHeaderOffset = jsonAnchor.at("headerOffset").get<std::uint64_t>();
anchor.fNBytesHeader = jsonAnchor.at("nBytesHeader").get<std::uint64_t>();
anchor.fLenHeader = jsonAnchor.at("lenHeader").get<std::uint64_t>();
anchor.fFooterObjId = jsonAnchor.at("footerObjId").get<std::uint64_t>();
anchor.fFooterOffset = jsonAnchor.at("footerOffset").get<std::uint64_t>();
anchor.fNBytesFooter = jsonAnchor.at("nBytesFooter").get<std::uint64_t>();
anchor.fLenFooter = jsonAnchor.at("lenFooter").get<std::uint64_t>();
} catch (const nlohmann::json::exception &e) {
return R__FAIL("missing or invalid field in S3 anchor: " + std::string(e.what()));
}

return anchor;
}
4 changes: 4 additions & 0 deletions tree/ntuple/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ if(daos OR daos_mock)
endif()
endif()

if(curl)
ROOT_ADD_GTEST(ntuple_storage_s3 ntuple_storage_s3.cxx LIBRARIES ROOTNTuple)
endif()


# RNTuple Python interface tests
if(pyroot)
Expand Down
Loading
Loading