Merge remote-tracking branch 'origin/dev' into feature-export-rng

# Conflicts:
#	include/xrpl/protocol/Feature.h
#	include/xrpl/protocol/detail/features.macro
This commit is contained in:
Nicholas Dudfield
2026-05-18 09:15:17 +07:00
46 changed files with 3650 additions and 733 deletions

View File

@@ -85,6 +85,7 @@ test.nodestore > xrpl.basics
test.nodestore > xrpld.core
test.nodestore > xrpld.nodestore
test.nodestore > xrpld.unity
test.nodestore > xrpl.protocol
test.overlay > test.jtx
test.overlay > test.toplevel
test.overlay > test.unit_test
@@ -119,6 +120,7 @@ test.rpc > xrpld.core
test.rpc > xrpld.net
test.rpc > xrpld.overlay
test.rpc > xrpld.rpc
test.rpc > xrpld.shamap
test.rpc > xrpl.hook
test.rpc > xrpl.json
test.rpc > xrpl.protocol

View File

@@ -12,17 +12,16 @@ echo "-- GITHUB_REPOSITORY: $1"
echo "-- GITHUB_SHA: $2"
echo "-- GITHUB_RUN_NUMBER: $4"
umask 0000;
umask 0000
####
cd /io;
mkdir -p src/certs;
curl --silent -k https://raw.githubusercontent.com/RichardAH/rippled-release-builder/main/ca-bundle/certbundle.h -o src/certs/certbundle.h;
if [ "`grep certbundle.h src/xrpld/net/detail/RegisterSSLCerts.cpp | wc -l`" -eq "0" ]
then
cp src/xrpld/net/detail/RegisterSSLCerts.cpp src/xrpld/net/detail/RegisterSSLCerts.cpp.old
perl -i -pe "s/^{/{
cd /io
mkdir -p src/certs
curl --silent -k https://raw.githubusercontent.com/RichardAH/rippled-release-builder/main/ca-bundle/certbundle.h -o src/certs/certbundle.h
if [ "$(grep certbundle.h src/xrpld/net/detail/RegisterSSLCerts.cpp | wc -l)" -eq "0" ]; then
cp src/xrpld/net/detail/RegisterSSLCerts.cpp src/xrpld/net/detail/RegisterSSLCerts.cpp.old
perl -i -pe "s/^{/{
#ifdef EMBEDDED_CA_BUNDLE
BIO *cbio = BIO_new_mem_buf(ca_bundle.data(), ca_bundle.size());
X509_STORE *cts = SSL_CTX_get_cert_store(ctx.native_handle());
@@ -68,15 +67,14 @@ fi
source /opt/rh/gcc-toolset-11/enable
export PATH=/usr/local/bin:$PATH
export CC='/usr/lib64/ccache/gcc' &&
export CXX='/usr/lib64/ccache/g++' &&
echo "-- Build Rippled --" &&
pwd &&
export CXX='/usr/lib64/ccache/g++' &&
echo "-- Build Rippled --" &&
pwd &&
echo "MOVING TO [ build-core.sh ]"
echo "MOVING TO [ build-core.sh ]";
printenv > .env.temp;
cat .env.temp | grep '=' | sed s/\\\(^[^=]\\+=\\\)/\\1\\\"/g|sed s/\$/\\\"/g > .env;
rm .env.temp;
printenv >.env.temp
cat .env.temp | grep '=' | sed s/\\\(^[^=]\\+=\\\)/\\1\\\"/g | sed s/\$/\\\"/g >.env
rm .env.temp
echo "Persisting ENV:"
cat .env

View File

@@ -33,35 +33,39 @@
*
* Steps required to add new features to the code:
*
* 1) In this file, increment `numFeatures` and add a uint256 declaration
* for the feature at the bottom
* 2) Add a uint256 definition for the feature to the corresponding source
* file (Feature.cpp). Use `registerFeature` to create the feature with
* the feature's name, `Supported::no`, and `VoteBehavior::DefaultNo`. This
* should be the only place the feature's name appears in code as a string.
* 3) Use the uint256 as the parameter to `view.rules.enabled()` to
* control flow into new code that this feature limits.
* 4) If the feature development is COMPLETE, and the feature is ready to be
* SUPPORTED, change the `registerFeature` parameter to Supported::yes.
* 5) When the feature is ready to be ENABLED, change the `registerFeature`
* parameter to `VoteBehavior::DefaultYes`.
* In general, any newly supported amendments (`Supported::yes`) should have
* a `VoteBehavior::DefaultNo` for at least one full release cycle. High
* priority bug fixes can be an exception to this rule of thumb.
* 1) Add the appropriate XRPL_FEATURE or XRPL_FIX macro definition for the
* feature to features.macro with the feature's name, `Supported::no`, and
* `VoteBehavior::DefaultNo`.
*
* 2) Use the generated variable name as the parameter to `view.rules.enabled()`
* to control flow into new code that this feature limits. (featureName or
* fixName)
*
* 3) If the feature development is COMPLETE, and the feature is ready to be
* SUPPORTED, change the macro parameter in features.macro to Supported::yes.
*
* 4) In general, any newly supported amendments (`Supported::yes`) should have
* a `VoteBehavior::DefaultNo` indefinitely so that external governance can
* make the decision on when to activate it. High priority bug fixes can be
* an exception to this rule. In such cases, ensure the fix has been
* clearly communicated to the community using appropriate channels,
* then change the macro parameter in features.macro to
* `VoteBehavior::DefaultYes`. The communication process is beyond
* the scope of these instructions.
*
*
* When a feature has been enabled for several years, the conditional code
* may be removed, and the feature "retired". To retire a feature:
* 1) Remove the uint256 declaration from this file.
* 2) MOVE the uint256 definition in Feature.cpp to the "retired features"
* section at the end of the file.
* 3) CHANGE the name of the variable to start with "retired".
* 4) CHANGE the parameters of the `registerFeature` call to `Supported::yes`
* and `VoteBehavior::DefaultNo`.
*
* 1) MOVE the macro definition in features.macro to the "retired features"
* section at the end of the file, and change the macro to XRPL_RETIRE.
*
* The feature must remain registered and supported indefinitely because it
* still exists in the ledger, but there is no need to vote for it because
* there's nothing to vote for. If it is removed completely from the code, any
* instances running that code will get amendment blocked. Removing the
* feature from the ledger is beyond the scope of these instructions.
* may exist in the Amendments object on ledger. There is no need to vote
* for it because there's nothing to vote for. If the feature definition is
* removed completely from the code, any instances running that code will get
* amendment blocked. Removing the feature from the ledger is beyond the scope
* of these instructions.
*
*/
@@ -76,11 +80,32 @@ allAmendments();
namespace detail {
#pragma push_macro("XRPL_FEATURE")
#undef XRPL_FEATURE
#pragma push_macro("XRPL_FIX")
#undef XRPL_FIX
#pragma push_macro("XRPL_RETIRE")
#undef XRPL_RETIRE
#define XRPL_FEATURE(name, supported, vote) +1
#define XRPL_FIX(name, supported, vote) +1
#define XRPL_RETIRE(name) +1
// This value SHOULD be equal to the number of amendments registered in
// Feature.cpp. Because it's only used to reserve storage, and determine how
// large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than
// the actual number of amendments. A LogicError on startup will verify this.
static constexpr std::size_t numFeatures = 118;
static constexpr std::size_t numFeatures =
(0 +
#include <xrpl/protocol/detail/features.macro>
);
#undef XRPL_RETIRE
#pragma pop_macro("XRPL_RETIRE")
#undef XRPL_FIX
#pragma pop_macro("XRPL_FIX")
#undef XRPL_FEATURE
#pragma pop_macro("XRPL_FEATURE")
/** Amendments that this server supports and the default voting behavior.
Whether they are enabled depends on the Rules defined in the validated
@@ -320,12 +345,17 @@ foreachFeature(FeatureBitset bs, F&& f)
#undef XRPL_FEATURE
#pragma push_macro("XRPL_FIX")
#undef XRPL_FIX
#pragma push_macro("XRPL_RETIRE")
#undef XRPL_RETIRE
#define XRPL_FEATURE(name, supported, vote) extern uint256 const feature##name;
#define XRPL_FIX(name, supported, vote) extern uint256 const fix##name;
#define XRPL_RETIRE(name)
#include <xrpl/protocol/detail/features.macro>
#undef XRPL_RETIRE
#pragma pop_macro("XRPL_RETIRE")
#undef XRPL_FIX
#pragma pop_macro("XRPL_FIX")
#undef XRPL_FEATURE

View File

@@ -23,6 +23,9 @@
#if !defined(XRPL_FIX)
#error "undefined macro: XRPL_FIX"
#endif
#if !defined(XRPL_RETIRE)
#error "undefined macro: XRPL_RETIRE"
#endif
// clang-format off
@@ -57,18 +60,18 @@ XRPL_FIX (DisallowIncomingV1, Supported::yes, VoteBehavior::DefaultYe
XRPL_FEATURE(XChainBridge, Supported::no, VoteBehavior::DefaultNo)
XRPL_FEATURE(AMM, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (ReducedOffersV1, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FEATURE(HooksUpdate2, Supported::yes, VoteBehavior::DefaultNo);
XRPL_FEATURE(HookOnV2, Supported::yes, VoteBehavior::DefaultNo);
XRPL_FEATURE(Export, Supported::yes, VoteBehavior::DefaultNo);
XRPL_FEATURE(ConsensusEntropy, Supported::yes, VoteBehavior::DefaultNo);
XRPL_FIX (HookAPI20251128, Supported::yes, VoteBehavior::DefaultYes);
XRPL_FIX (CronStacking, Supported::yes, VoteBehavior::DefaultYes);
XRPL_FEATURE(ExtendedHookState, Supported::yes, VoteBehavior::DefaultNo);
XRPL_FIX (InvalidTxFlags, Supported::yes, VoteBehavior::DefaultYes);
XRPL_FEATURE(Cron, Supported::yes, VoteBehavior::DefaultNo);
XRPL_FEATURE(IOUIssuerWeakTSH, Supported::yes, VoteBehavior::DefaultNo);
XRPL_FEATURE(DeepFreeze, Supported::yes, VoteBehavior::DefaultNo);
XRPL_FIX (ProvisionalDoubleThreading, Supported::yes, VoteBehavior::DefaultYes);
XRPL_FEATURE(HooksUpdate2, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(HookOnV2, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(Export, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(ConsensusEntropy, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (HookAPI20251128, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FIX (CronStacking, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FEATURE(ExtendedHookState, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (InvalidTxFlags, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FEATURE(Cron, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(IOUIssuerWeakTSH, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(DeepFreeze, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (ProvisionalDoubleThreading, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FEATURE(Clawback, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (RewardClaimFlags, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FEATURE(HookCanEmit, Supported::yes, VoteBehavior::DefaultNo)
@@ -152,4 +155,24 @@ XRPL_FIX (NFTokenDirV1, Supported::yes, VoteBehavior::Obsolete)
XRPL_FEATURE(NonFungibleTokensV1, Supported::yes, VoteBehavior::Obsolete)
XRPL_FEATURE(CryptoConditionsSuite, Supported::yes, VoteBehavior::Obsolete)
// The following amendments have been active for at least two years. Their
// pre-amendment code has been removed and the identifiers are deprecated.
// All known amendments and amendments that may appear in a validated
// ledger must be registered either here or above with the "active" amendments
XRPL_RETIRE(MultiSign)
XRPL_RETIRE(TrustSetAuth)
XRPL_RETIRE(FeeEscalation)
XRPL_RETIRE(PayChan)
XRPL_RETIRE(CryptoConditions)
XRPL_RETIRE(TickSize)
XRPL_RETIRE(fix1368)
XRPL_RETIRE(Escrow)
XRPL_RETIRE(fix1373)
XRPL_RETIRE(EnforceInvariants)
XRPL_RETIRE(SortedDirectories)
XRPL_RETIRE(fix1201)
XRPL_RETIRE(fix1512)
XRPL_RETIRE(fix1523)
XRPL_RETIRE(fix1528)
// clang-format on

View File

@@ -11,11 +11,11 @@ echo "START BUILDING (HOST)"
echo "Cleaning previously built binary"
rm -f release-build/xahaud
BUILD_CORES=$(echo "scale=0 ; `nproc` / 1.337" | bc)
BUILD_CORES=$(echo "scale=0 ; $(nproc) / 1.337" | bc)
if [[ "$GITHUB_REPOSITORY" == "" ]]; then
#Default
BUILD_CORES=${BUILD_CORES:-8}
BUILD_CORES=${BUILD_CORES:-8}
fi
# Ensure still works outside of GH Actions by setting these to /dev/null
@@ -31,21 +31,19 @@ echo "-- GITHUB_SHA: $GITHUB_SHA"
echo "-- GITHUB_RUN_NUMBER: $GITHUB_RUN_NUMBER"
echo "-- CONTAINER_NAME: $CONTAINER_NAME"
which docker 2> /dev/null 2> /dev/null
if [ "$?" -eq "1" ]
then
which docker 2>/dev/null 2>/dev/null
if [ "$?" -eq "1" ]; then
echo 'Docker not found. Install it first.'
exit 1
fi
stat .git 2> /dev/null 2> /dev/null
if [ "$?" -eq "1" ]
then
stat .git 2>/dev/null 2>/dev/null
if [ "$?" -eq "1" ]; then
echo 'Run this inside the source directory. (.git dir not found).'
exit 1
fi
STATIC_CONTAINER=$(docker ps -a | grep $CONTAINER_NAME |wc -l)
STATIC_CONTAINER=$(docker ps -a | grep $CONTAINER_NAME | wc -l)
CACHE_VOLUME_NAME="xahau-release-builder-cache"
@@ -57,13 +55,14 @@ if false; then
docker stop $CONTAINER_NAME
else
echo "No static container, build on temp container"
rm -rf release-build;
mkdir -p release-build;
rm -rf release-build
mkdir -p release-build
docker volume create $CACHE_VOLUME_NAME
# Create inline Dockerfile with environment setup for build-full.sh
DOCKERFILE_CONTENT=$(cat <<'DOCKERFILE_EOF'
DOCKERFILE_CONTENT=$(
cat <<'DOCKERFILE_EOF'
FROM ghcr.io/phusion/holy-build-box:4.0.1-amd64
ARG BUILD_CORES=8
@@ -218,7 +217,7 @@ RUN /hbb_exe/activate-exec bash -c "ccache -M 100G && \
ln -s ../../bin/ccache /usr/lib64/ccache/c++"
DOCKERFILE_EOF
)
)
# Build custom Docker image
IMAGE_NAME="xahaud-builder:latest"
@@ -228,14 +227,14 @@ DOCKERFILE_EOF
if [[ "$GITHUB_REPOSITORY" == "" ]]; then
# Non GH, local building
echo "Non-GH runner, local building, temp container"
docker run -i --user 0:$(id -g) --rm -v /data/builds:/data/builds -v `pwd`:/io -v "$CACHE_VOLUME_NAME":/cache --network host "$IMAGE_NAME" /hbb_exe/activate-exec bash -c "source /opt/rh/gcc-toolset-11/enable && bash -x /io/build-full.sh '$GITHUB_REPOSITORY' '$GITHUB_SHA' '$BUILD_CORES' '$GITHUB_RUN_NUMBER'"
docker run -i --user 0:$(id -g) --rm -v /data/builds:/data/builds -v $(pwd):/io -v "$CACHE_VOLUME_NAME":/cache --network host "$IMAGE_NAME" /hbb_exe/activate-exec bash -c "source /opt/rh/gcc-toolset-11/enable && bash -x /io/build-full.sh '$GITHUB_REPOSITORY' '$GITHUB_SHA' '$BUILD_CORES' '$GITHUB_RUN_NUMBER'"
else
# GH Action, runner
echo "GH Action, runner, clean & re-create create persistent container"
docker rm -f $CONTAINER_NAME
echo "echo 'Stopping container: $CONTAINER_NAME'" >> "$JOB_CLEANUP_SCRIPT"
echo "docker stop --time=15 \"$CONTAINER_NAME\" || echo 'Failed to stop container or container not running'" >> "$JOB_CLEANUP_SCRIPT"
docker run -di --user 0:$(id -g) --name $CONTAINER_NAME -v /data/builds:/data/builds -v `pwd`:/io -v "$CACHE_VOLUME_NAME":/cache --network host "$IMAGE_NAME" /hbb_exe/activate-exec bash
echo "echo 'Stopping container: $CONTAINER_NAME'" >>"$JOB_CLEANUP_SCRIPT"
echo "docker stop --time=15 \"$CONTAINER_NAME\" || echo 'Failed to stop container or container not running'" >>"$JOB_CLEANUP_SCRIPT"
docker run -di --user 0:$(id -g) --name $CONTAINER_NAME -v /data/builds:/data/builds -v $(pwd):/io -v "$CACHE_VOLUME_NAME":/cache --network host "$IMAGE_NAME" /hbb_exe/activate-exec bash
docker exec -i $CONTAINER_NAME /hbb_exe/activate-exec bash -c "source /opt/rh/gcc-toolset-11/enable && bash -x /io/build-full.sh '$GITHUB_REPOSITORY' '$GITHUB_SHA' '$BUILD_CORES' '$GITHUB_RUN_NUMBER'"
docker stop $CONTAINER_NAME
fi

View File

@@ -66,7 +66,7 @@ invalidAMMAsset(
Issue const& issue,
std::optional<std::pair<Issue, Issue>> const& pair)
{
if (badCurrency() == issue.currency)
if (isBadCurrency(issue.currency))
return temBAD_CURRENCY;
if (isXRP(issue) && issue.account.isNonZero())
return temBAD_ISSUER;

View File

@@ -250,12 +250,9 @@ FeatureCollections::registerFeature(
Feature const* i = getByName(name);
if (!i)
{
// If this check fails, and you just added a feature, increase the
// numFeatures value in Feature.h
check(
features.size() < detail::numFeatures,
"More features defined than allocated. Adjust numFeatures in "
"Feature.h.");
"More features defined than allocated.");
auto const f = sha512Half(Slice(name.data(), name.size()));
@@ -424,45 +421,26 @@ featureToName(uint256 const& f)
#undef XRPL_FEATURE
#pragma push_macro("XRPL_FIX")
#undef XRPL_FIX
#pragma push_macro("XRPL_RETIRE")
#undef XRPL_RETIRE
#define XRPL_FEATURE(name, supported, vote) \
uint256 const feature##name = registerFeature(#name, supported, vote);
#define XRPL_FIX(name, supported, vote) \
uint256 const fix##name = registerFeature("fix" #name, supported, vote);
#define XRPL_RETIRE(name) \
[[deprecated("The referenced amendment has been retired"), maybe_unused]] \
uint256 const retired##name = retireFeature(#name);
#include <xrpl/protocol/detail/features.macro>
#undef XRPL_RETIRE
#pragma pop_macro("XRPL_RETIRE")
#undef XRPL_FIX
#pragma pop_macro("XRPL_FIX")
#undef XRPL_FEATURE
#pragma pop_macro("XRPL_FEATURE")
// clang-format off
// The following amendments have been active for at least two years. Their
// pre-amendment code has been removed and the identifiers are deprecated.
// All known amendments and amendments that may appear in a validated
// ledger must be registered either here or above with the "active" amendments
[[deprecated("The referenced amendment has been retired"), maybe_unused]]
uint256 const
retiredMultiSign = retireFeature("MultiSign"),
retiredTrustSetAuth = retireFeature("TrustSetAuth"),
retiredFeeEscalation = retireFeature("FeeEscalation"),
retiredPayChan = retireFeature("PayChan"),
retiredCryptoConditions = retireFeature("CryptoConditions"),
retiredTickSize = retireFeature("TickSize"),
retiredFix1368 = retireFeature("fix1368"),
retiredEscrow = retireFeature("Escrow"),
retiredFix1373 = retireFeature("fix1373"),
retiredEnforceInvariants = retireFeature("EnforceInvariants"),
retiredSortedDirectories = retireFeature("SortedDirectories"),
retiredFix1201 = retireFeature("fix1201"),
retiredFix1512 = retireFeature("fix1512"),
retiredFix1523 = retireFeature("fix1523"),
retiredFix1528 = retireFeature("fix1528");
// clang-format on
// All of the features should now be registered, since variables in a cpp file
// are initialized from top to bottom.
//

View File

@@ -111,7 +111,7 @@ issueFromJson(Json::Value const& v)
}
auto const currency = to_currency(curStr.asString());
if (currency == badCurrency() || currency == noCurrency())
if (isBadCurrency(currency) || currency == noCurrency())
{
Throw<Json::error>("issueFromJson currency must be a valid currency");
}

View File

@@ -103,7 +103,7 @@ currencyFromJson(SField const& name, Json::Value const& v)
}
auto const currency = to_currency(v.asString());
if (currency == badCurrency() || currency == noCurrency())
if (isBadCurrency(currency) || currency == noCurrency())
{
Throw<std::runtime_error>(
"currencyFromJson currency must be a valid currency");

View File

@@ -20,6 +20,8 @@
#include <test/jtx.h>
#include <test/jtx/Env.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/ledger/detail/PublishGap.h>
#include <xrpl/basics/RangeSet.h>
#include <xrpl/protocol/jss.h>
namespace ripple {
@@ -117,13 +119,224 @@ class LedgerMaster_test : public beast::unit_test::suite
}
}
void
testCanSkipPinnedGap()
{
// detail::canSkipPinnedGap is the lifted decision used by
// findNewLedgersToPublish to decide whether pubSeq can leap
// across a gap in the publish-target set without violating
// contiguous publication of non-pinned ledgers. In production
// this guard rarely fires (pinned ranges are old historical
// catalogue data, pubSeq is at the live tip) — it matters in
// test/standalone catalogue-load scenarios where pinned seqs
// can sit near pubSeq. The invariant is small and crisp;
// unit-testing it directly is far cheaper than reconstructing
// a believable findNewLedgersToPublish state through Env.
testcase("detail::canSkipPinnedGap");
using detail::canSkipPinnedGap;
// No gap (pubSeq is already at intervalStart) — always OK.
{
RangeSet<std::uint32_t> empty;
BEAST_EXPECT(canSkipPinnedGap(10, 10, empty));
BEAST_EXPECT(canSkipPinnedGap(10, 5, empty)); // past
}
// One-element gap, exactly pinned: can skip.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(10u, 10u));
BEAST_EXPECT(canSkipPinnedGap(10, 11, pinned));
}
// One-element gap, NOT pinned: must halt.
{
RangeSet<std::uint32_t> empty;
BEAST_EXPECT(!canSkipPinnedGap(10, 11, empty));
}
// Multi-element gap entirely covered by pinned: can skip.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(11u, 15u));
BEAST_EXPECT(canSkipPinnedGap(11, 16, pinned));
}
// Multi-element gap, pinned covers most but leaves one
// non-pinned seq exposed: must halt. This is the bug the
// guard exists to prevent — silently publishing seq 16
// while seq 15 (non-pinned) is unfetched.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(11u, 14u)); // missing 15
BEAST_EXPECT(!canSkipPinnedGap(11, 16, pinned));
}
// Pinned set extends beyond the gap — only the gap window
// matters for the decision.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(0u, 1000u));
BEAST_EXPECT(canSkipPinnedGap(50, 100, pinned));
}
// Empty pinned, with a gap: must halt.
{
RangeSet<std::uint32_t> empty;
BEAST_EXPECT(!canSkipPinnedGap(50, 100, empty));
}
// Disjoint pinned that doesn't intersect the gap: must halt.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(200u, 300u));
BEAST_EXPECT(!canSkipPinnedGap(50, 100, pinned));
}
// Pinned partially intersects gap from the left only.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(50u, 75u));
BEAST_EXPECT(!canSkipPinnedGap(50, 100, pinned));
}
// Pinned partially intersects gap from the right only.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(75u, 99u));
BEAST_EXPECT(!canSkipPinnedGap(50, 100, pinned));
}
}
void
testPinUnpinSymmetry()
{
testcase("pin/unpin symmetry");
using namespace test::jtx;
Env env{*this, makeNetworkConfig(11111)};
auto const alice = Account("alice");
env.fund(XRP(1000), alice);
env.close();
auto& lm = env.app().getLedgerMaster();
// The most recently closed ledger is the one we'll pin.
auto const ledger = lm.getClosedLedger();
BEAST_EXPECT(ledger);
if (!ledger)
return;
auto const seq = ledger->info().seq;
BEAST_EXPECT(seq > 0);
// Baseline — not pinned.
BEAST_EXPECT(!lm.isPinned(seq));
// Pin via storeLedger(ledger, pin=true). This is the same path
// catalogue_load uses just before saveValidatedLedger, which is why
// a save failure must roll back via unpinLedger() to keep the
// in-memory pinned-set consistent with state.db.
lm.storeLedger(ledger, /*pin=*/true);
BEAST_EXPECT(lm.isPinned(seq));
BEAST_EXPECT(boost::icl::contains(lm.getPinnedLedgersRangeSet(), seq));
// Unpin reverses the pin (the rollback contract used by
// catalogue_load's RAII guard on save failure).
lm.unpinLedger(seq);
BEAST_EXPECT(!lm.isPinned(seq));
BEAST_EXPECT(!boost::icl::contains(lm.getPinnedLedgersRangeSet(), seq));
// Unpinning a seq that isn't pinned is a no-op (idempotent).
lm.unpinLedger(seq);
BEAST_EXPECT(!lm.isPinned(seq));
}
void
testSetPinnedRangesImmediateMerge()
{
// Regression test: when setPinnedLedgersRangeSet runs and
// mCompleteLedgers is already non-empty (LOAD/standalone startup
// already called setFullLedger), the deferred merge in
// setFullLedger never fires because the one-shot guard relies on
// a flag, not on emptiness. Without the immediate-merge branch
// in setPinnedLedgersRangeSet, pinned ranges restored from
// state.db would never appear in mCompleteLedgers and historical
// pinned ledgers would not be queryable until the next
// validation. This test asserts the immediate-merge behavior.
testcase("setPinnedLedgersRangeSet immediate merge");
using namespace test::jtx;
Env env{*this, makeNetworkConfig(11111)};
auto const alice = Account("alice");
env.fund(XRP(1000), alice);
env.close();
auto& lm = env.app().getLedgerMaster();
// Why mCompleteLedgers is non-empty here:
// env.close() above runs synchronous standalone consensus.
// That hits LedgerMaster::switchLCL, which (in standalone)
// calls setFullLedger directly on the calling thread, which
// inserts the closed ledger's seq into mCompleteLedgers.
// So by the time we get here, mCompleteLedgers has the
// genesis + funded seqs in it.
//
// This is the precondition that triggers the immediate-merge
// branch in setPinnedLedgersRangeSet (the LOAD/standalone
// startup case). On a NORMAL/NETWORK production startup,
// mCompleteLedgers would still be empty when state.db is read
// and setPinnedLedgersRangeSet is called, and the merge would
// be deferred to the first validated ledger via setFullLedger.
// We're specifically testing the *other* branch here.
auto const completeBefore = lm.getCompleteLedgersRangeSet();
BEAST_EXPECT(!completeBefore.empty());
// Pinned ranges are far outside the existing range so we can
// unambiguously detect them being merged in.
std::uint32_t const pinLo = 100000;
std::uint32_t const pinHi = 100099;
RangeSet<std::uint32_t> pinned;
pinned.insert(range(pinLo, pinHi));
// Restore pinned ranges (mirrors what SHAMapStoreImp::start does
// after reading state.db's PinnedLedgers row).
lm.setPinnedLedgersRangeSet(pinned);
// Pinned set is restored.
BEAST_EXPECT(lm.isPinned(pinLo));
BEAST_EXPECT(lm.isPinned(pinHi));
BEAST_EXPECT(lm.isPinned((pinLo + pinHi) / 2));
// Critical invariant: pinned ranges are now in mCompleteLedgers
// immediately, not deferred to the first validated ledger.
// This is what makes haveLedger() return true for pinned seqs
// after a LOAD/standalone restart, without which the historical
// pinned data is on disk but the system reports "we don't have
// it".
BEAST_EXPECT(lm.haveLedger(pinLo));
BEAST_EXPECT(lm.haveLedger(pinHi));
BEAST_EXPECT(lm.haveLedger((pinLo + pinHi) / 2));
// The pre-existing complete range is still complete (merge,
// not replace).
for (auto const& interval : completeBefore)
for (auto s = interval.lower(); s <= interval.upper(); ++s)
BEAST_EXPECT(lm.haveLedger(s));
}
public:
void
run() override
{
using namespace test::jtx;
FeatureBitset const all{supported_amendments() - featureXahauGenesis};
testCanSkipPinnedGap();
testWithFeats(all);
testPinUnpinSymmetry();
testSetPinnedRangesImmediateMerge();
}
void

View File

@@ -22,10 +22,14 @@
#include <xrpld/app/main/Application.h>
#include <xrpld/app/main/NodeStoreScheduler.h>
#include <xrpld/app/misc/SHAMapStore.h>
#include <xrpld/app/misc/SHAMapStoreImp.h>
#include <xrpld/app/misc/detail/OnlineDeleteRanges.h>
#include <xrpld/app/rdb/backend/SQLiteDatabase.h>
#include <xrpld/core/ConfigSections.h>
#include <xrpld/nodestore/detail/DatabaseRotatingImp.h>
#include <xrpl/beast/utility/temp_dir.h>
#include <xrpl/protocol/jss.h>
#include <boost/filesystem.hpp>
namespace ripple {
namespace test {
@@ -51,6 +55,25 @@ class SHAMapStore_test : public beast::unit_test::suite
return cfg;
}
static auto
pinnedPersistent(
std::unique_ptr<Config> cfg,
std::string const& dbPath,
std::string const& nodePath,
std::string const& pinnedPath)
{
cfg = onlineDelete(std::move(cfg));
cfg->legacy("database_path", dbPath);
cfg->overwrite(SECTION_RELATIONAL_DB, "backend", "sqlite");
auto& section = cfg->section(ConfigSection::nodeDatabase());
section.set("type", "rwdb");
section.set("path", nodePath);
section.set("pinned_type", "nudb");
section.set("pinned_path", pinnedPath);
return cfg;
}
bool
goodLedger(
jtx::Env& env,
@@ -648,13 +671,191 @@ public:
BEAST_EXPECT(dbr->getName() == "3");
}
void
testPinnedRangeRestoreRequiresPinnedData()
{
testcase("pinned range restore requires pinned data");
using namespace jtx;
beast::temp_dir tempDir;
auto const tempPath = boost::filesystem::path(tempDir.path());
auto const dbPath = (tempPath / "db").string();
auto const nodePath = (tempPath / "node").string();
auto const pinnedPath = (tempPath / "pinned").string();
boost::filesystem::create_directories(dbPath);
boost::filesystem::create_directories(nodePath);
boost::filesystem::create_directories(pinnedPath);
{
Env env(
*this,
envconfig(pinnedPersistent, dbPath, nodePath, pinnedPath));
RangeSet<std::uint32_t> ranges;
ranges.insert(range(100u, 200u));
env.app().getSHAMapStore().setPinnedRanges(ranges);
NodeStoreScheduler scheduler(env.app().getJobQueue());
auto const journal = env.app().journal("SHAMapStoreTest");
try
{
SHAMapStoreImp restoreCheck(env.app(), scheduler, journal);
restoreCheck.start();
fail(
"Expected startup failure for stale persisted pinned "
"ranges");
}
catch (std::runtime_error const& e)
{
BEAST_EXPECT(
std::string(e.what()).find(
"Persisted pinned interval 100-200") !=
std::string::npos);
}
}
}
// Helper: compare two RangeSet<uint32_t> for set-equality. The
// test cares about which sequences are in the result, not the
// particular interval representation.
static bool
sameRanges(
RangeSet<std::uint32_t> const& a,
RangeSet<std::uint32_t> const& b)
{
return a == b;
}
void
testComputeOnlineDeleteTargets()
{
// detail::computeOnlineDeleteTargets is the lifted interval
// arithmetic used by SHAMapStoreImp::clearSqlRanges to decide
// which ledger sequences to actually delete during online
// delete. The base window is [minSeq, lastRotated - 1] and
// pinned ranges are subtracted from it. The result must:
// (a) be empty when the base is empty or fully pinned,
// (b) preserve all pinned seqs (they survive rotation),
// (c) cover every non-pinned seq in the base.
// Lifting the math out keeps the contract testable without
// standing up a full SHAMapStoreImp + database fixture.
testcase("detail::computeOnlineDeleteTargets");
using detail::computeOnlineDeleteTargets;
// Empty base window (minSeq >= lastRotated): no work.
{
RangeSet<std::uint32_t> empty;
BEAST_EXPECT(computeOnlineDeleteTargets(100, 100, empty).empty());
BEAST_EXPECT(computeOnlineDeleteTargets(101, 100, empty).empty());
}
// No pins: the entire base window is the target.
{
RangeSet<std::uint32_t> empty;
RangeSet<std::uint32_t> expected;
expected.insert(range(50u, 99u)); // [50, 100-1]
BEAST_EXPECT(sameRanges(
computeOnlineDeleteTargets(50, 100, empty), expected));
}
// Pins fully cover the base window: nothing to delete. This
// is the bug the pinning feature exists to prevent — pinned
// ranges getting silently rotated away.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(50u, 99u));
BEAST_EXPECT(computeOnlineDeleteTargets(50, 100, pinned).empty());
}
// Pins extend beyond the base window: still nothing to delete.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(0u, 1000u));
BEAST_EXPECT(computeOnlineDeleteTargets(50, 100, pinned).empty());
}
// Pins cover only the leading section of the base. Result is
// the trailing remainder.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(50u, 70u));
RangeSet<std::uint32_t> expected;
expected.insert(range(71u, 99u));
BEAST_EXPECT(sameRanges(
computeOnlineDeleteTargets(50, 100, pinned), expected));
}
// Pins cover only the trailing section. Result is leading.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(80u, 99u));
RangeSet<std::uint32_t> expected;
expected.insert(range(50u, 79u));
BEAST_EXPECT(sameRanges(
computeOnlineDeleteTargets(50, 100, pinned), expected));
}
// Pins cut a hole in the middle. Result is two disjoint
// intervals — both get deleted, the hole is preserved.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(60u, 80u));
RangeSet<std::uint32_t> expected;
expected.insert(range(50u, 59u));
expected.insert(range(81u, 99u));
BEAST_EXPECT(sameRanges(
computeOnlineDeleteTargets(50, 100, pinned), expected));
}
// Multiple disjoint pinned ranges all inside the base. Result
// is the base minus all pinned holes.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(55u, 60u));
pinned.insert(range(70u, 75u));
pinned.insert(range(90u, 95u));
RangeSet<std::uint32_t> expected;
expected.insert(range(50u, 54u));
expected.insert(range(61u, 69u));
expected.insert(range(76u, 89u));
expected.insert(range(96u, 99u));
BEAST_EXPECT(sameRanges(
computeOnlineDeleteTargets(50, 100, pinned), expected));
}
// Pinned ranges entirely outside the base window: no effect.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(0u, 49u));
pinned.insert(range(100u, 200u));
RangeSet<std::uint32_t> expected;
expected.insert(range(50u, 99u));
BEAST_EXPECT(sameRanges(
computeOnlineDeleteTargets(50, 100, pinned), expected));
}
// Single-sequence base window with a single-sequence pin
// matching exactly: empty result.
{
RangeSet<std::uint32_t> pinned;
pinned.insert(range(99u, 99u));
BEAST_EXPECT(computeOnlineDeleteTargets(99, 100, pinned).empty());
}
}
void
run() override
{
testComputeOnlineDeleteTargets();
testClear();
testAutomatic();
testCanDelete();
testRotate();
testPinnedRangeRestoreRequiresPinnedData();
}
};

View File

@@ -1513,6 +1513,133 @@ r.ripple.com:51235
}
}
void
testDatabasePinnedValidation()
{
testcase("DatabasePinned config validation");
// Test 1: pinned_type=rwdb in non-standalone mode (should throw)
{
Config c;
std::string toLoad =
"[node_db]\n"
"type=NuDB\n"
"path=main\n"
"online_delete=256\n"
"pinned_type=rwdb\n"
"pinned_path=pinned\n";
c.setupControl(true, true, false); // standalone = false
try
{
c.loadFromString(toLoad);
fail("Expected exception for non-durable pinned_type");
}
catch (std::runtime_error const& e)
{
BEAST_EXPECT(
std::string(e.what()).find("durable backend") !=
std::string::npos);
pass();
}
}
// Test 2: pinned_type=none in non-standalone mode (should throw)
{
Config c;
std::string toLoad =
"[node_db]\n"
"type=NuDB\n"
"path=main\n"
"online_delete=256\n"
"pinned_type=none\n"
"pinned_path=pinned\n";
c.setupControl(true, true, false); // standalone = false
try
{
c.loadFromString(toLoad);
fail("Expected exception for non-durable pinned_type");
}
catch (std::runtime_error const& e)
{
BEAST_EXPECT(
std::string(e.what()).find("durable backend") !=
std::string::npos);
pass();
}
}
// Test 3: pinned_type=rwdb in standalone mode (should succeed
// — standalone is unrestricted)
{
Config c;
std::string toLoad =
"[node_db]\n"
"type=NuDB\n"
"path=main\n"
"online_delete=256\n"
"pinned_type=rwdb\n"
"pinned_path=pinned\n";
c.setupControl(true, true, true); // standalone = true
try
{
c.loadFromString(toLoad);
pass();
}
catch (std::runtime_error const& e)
{
fail("Should not throw in standalone mode");
}
}
// Test 4: pinned_type=NuDB in non-standalone mode (should succeed)
{
Config c;
std::string toLoad =
"[node_db]\n"
"type=NuDB\n"
"path=main\n"
"online_delete=256\n"
"pinned_type=NuDB\n"
"pinned_path=pinned\n";
c.setupControl(true, true, false); // standalone = false
try
{
c.loadFromString(toLoad);
pass();
}
catch (std::runtime_error const& e)
{
fail("Should not throw for durable pinned_type");
}
}
// Test 5: pinned_type without online_delete (should throw)
{
Config c;
std::string toLoad =
"[node_db]\n"
"type=NuDB\n"
"path=main\n"
"pinned_type=NuDB\n"
"pinned_path=pinned\n";
c.setupControl(true, true, false);
try
{
c.loadFromString(toLoad);
fail(
"Expected exception for pinned_type without "
"online_delete");
}
catch (std::runtime_error const& e)
{
BEAST_EXPECT(
std::string(e.what()).find("online_delete") !=
std::string::npos);
pass();
}
}
}
void
testOverlay()
{
@@ -1604,6 +1731,7 @@ r.ripple.com:51235
testGetters();
testAmendment();
testRWDBOnlineDelete();
testDatabasePinnedValidation();
testOverlay();
testNetworkID();
}

View File

@@ -0,0 +1,195 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <test/jtx.h>
#include <test/jtx/envconfig.h>
#include <xrpld/core/ConfigSections.h>
#include <xrpld/nodestore/Database.h>
#include <xrpld/nodestore/NodeObject.h>
#include <xrpld/nodestore/detail/DatabasePinnedImp.h>
#include <xrpl/basics/Buffer.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/protocol/digest.h>
namespace ripple {
namespace test {
class DatabasePinned_test : public beast::unit_test::suite
{
// Build an Env whose nodestore is wired up as DatabasePinnedImp.
// pinned_type=rwdb is allowed in standalone mode (the catalogue
// tests do the same).
static std::unique_ptr<Config>
pinnedEnvconfig()
{
auto cfg = jtx::envconfig();
auto& nodeDb = cfg->section(ConfigSection::nodeDatabase());
nodeDb.set("pinned_type", "rwdb");
nodeDb.set("online_delete", "256");
return cfg;
}
// Make a NodeObject with deterministic but unique-per-call hash so
// we can store it and fetch it back.
static std::shared_ptr<NodeObject>
makeObject(NodeObjectType type, std::uint8_t marker)
{
Blob data(64, marker);
// Hash is just a stable function of (type, marker) so each
// (type, marker) pair has a distinct key.
Serializer s;
s.add32(static_cast<std::uint32_t>(type));
s.add8(marker);
auto const hash = sha512Half(makeSlice(s.peekData()));
return NodeObject::createObject(type, std::move(data), hash);
}
// Borrow the running app's nodestore. With pinned_type configured
// it must be a DatabasePinnedImp; otherwise the test environment
// is set up wrong and we can't proceed.
NodeStore::DatabasePinnedImp*
getPinnedDb(jtx::Env& env)
{
auto* db = dynamic_cast<NodeStore::DatabasePinnedImp*>(
&env.app().getNodeStore());
BEAST_EXPECT(db != nullptr);
return db;
}
void
testStoreRoutingByType()
{
// The architectural core of DatabasePinnedImp: store() routes
// by NodeObjectType. pinned* types go to the persistent
// backend (which never rotates); everything else goes to the
// rotating backend (which is rotated by online_delete). If
// routing breaks, pinned data ends up rotating away and the
// whole feature fails silently.
testcase("store routing by NodeObjectType");
using namespace jtx;
Env env{*this, pinnedEnvconfig()};
auto* db = getPinnedDb(env);
if (!db)
return;
auto const pinnedBefore = db->pinnedStoreCount();
auto const hotBefore = db->hotStoreCount();
// Pinned types: should each increment pinnedStoreCount.
// The store() implementation also reduces them to their hot
// equivalents before serialization (single-byte on-disk type
// field), but the routing decision happens first.
for (auto const type :
{pinnedLEDGER, pinnedACCOUNT_NODE, pinnedTRANSACTION_NODE})
{
db->store(
type,
Blob(64, 0xAB),
sha512Half(
makeSlice(std::string{"pin-"} + std::to_string(type))),
/*ledgerSeq=*/100);
}
// Hot types: should each increment hotStoreCount.
for (auto const type :
{hotLEDGER, hotACCOUNT_NODE, hotTRANSACTION_NODE})
{
db->store(
type,
Blob(64, 0xCD),
sha512Half(
makeSlice(std::string{"hot-"} + std::to_string(type))),
/*ledgerSeq=*/100);
}
// 3 pinned stores routed to persistent_, 3 hot to rotating_.
BEAST_EXPECT(db->pinnedStoreCount() - pinnedBefore == 3u);
BEAST_EXPECT(db->hotStoreCount() - hotBefore == 3u);
}
void
testStoreFetchRoundTrip()
{
// Verifies that data routed to either backend is retrievable
// through the unified Database interface (fetchNodeObject
// returns the right object regardless of which backend it
// landed in). This is the read-side counterpart to the
// routing test — confirms callers don't need to know about
// the dual-backend split.
testcase("store/fetch round-trip across both backends");
using namespace jtx;
Env env{*this, pinnedEnvconfig()};
auto& nodeStore = env.app().getNodeStore();
// Store one object of each type with distinct content so the
// fetched data is unambiguously the one we stored.
struct Item
{
NodeObjectType type;
std::uint8_t marker;
std::shared_ptr<NodeObject> obj;
};
std::vector<Item> items{
{pinnedLEDGER, 0x01, nullptr},
{pinnedACCOUNT_NODE, 0x02, nullptr},
{pinnedTRANSACTION_NODE, 0x03, nullptr},
{hotLEDGER, 0x04, nullptr},
{hotACCOUNT_NODE, 0x05, nullptr},
{hotTRANSACTION_NODE, 0x06, nullptr},
};
for (auto& it : items)
{
it.obj = makeObject(it.type, it.marker);
// Database::store takes (type, data, hash, ledgerSeq).
// Pass a copy of the data because store() takes Blob&&.
nodeStore.store(
it.type,
Blob(it.obj->getData()),
it.obj->getHash(),
/*ledgerSeq=*/100);
}
for (auto const& it : items)
{
auto const fetched =
nodeStore.fetchNodeObject(it.obj->getHash(), 100);
BEAST_EXPECT(fetched != nullptr);
if (!fetched)
continue;
BEAST_EXPECT(fetched->getData() == it.obj->getData());
}
}
public:
void
run() override
{
testStoreRoutingByType();
testStoreFetchRoundTrip();
}
};
BEAST_DEFINE_TESTSUITE(DatabasePinned, nodestore, ripple);
} // namespace test
} // namespace ripple

View File

@@ -541,6 +541,124 @@ public:
}
}
void
testRangeDeletionOperations(
std::string const& backend,
std::unique_ptr<Config> config)
{
testcase("Range deletion operations - " + backend);
using namespace test::jtx;
config->LEDGER_HISTORY = 1000;
Env env(*this, std::move(config));
auto& db = env.app().getRelationalDatabase();
Account alice("alice");
Account bob("bob");
env.fund(XRP(10000), alice, bob);
env.close();
auto* sqliteDb = getInterface(db);
BEAST_EXPECT(sqliteDb != nullptr);
if (!sqliteDb)
return;
// Create 5 ledgers with transactions
for (int i = 0; i < 5; ++i)
{
env(pay(alice, bob, XRP(100 + i)));
env.close();
}
auto minSeq = sqliteDb->getMinLedgerSeq();
auto maxSeq = sqliteDb->getMaxLedgerSeq();
BEAST_EXPECT(minSeq && maxSeq);
if (!minSeq || !maxSeq)
return;
auto initialTxCount = sqliteDb->getTransactionCount();
auto initialAcctTxCount = sqliteDb->getAccountTransactionCount();
// Use a range that covers ledgers with transactions
// (funded ledger + 5 payment ledgers = sequences 3..7 typically)
LedgerIndex rangeStart = *minSeq;
LedgerIndex rangeEnd = *maxSeq;
// Test deleteTransactionsInRange with limit — ensures forward
// progress (no infinite loop on partial deletes)
{
std::size_t totalDeleted = 0;
std::size_t iterations = 0;
std::size_t const limit = 2;
while (true)
{
auto deleted = sqliteDb->deleteTransactionsInRange(
rangeStart, rangeEnd, limit);
if (deleted == 0)
break;
totalDeleted += deleted;
++iterations;
// Safety: prevent infinite loop in case of bug
if (iterations > initialTxCount + 1)
{
fail(
"deleteTransactionsInRange: too many iterations, "
"possible infinite loop");
break;
}
}
BEAST_EXPECT(totalDeleted > 0);
BEAST_EXPECT(totalDeleted <= initialTxCount);
auto afterTxCount = sqliteDb->getTransactionCount();
BEAST_EXPECT(afterTxCount == initialTxCount - totalDeleted);
}
// Test deleteAccountTransactionsInRange with limit
{
std::size_t totalDeleted = 0;
std::size_t iterations = 0;
std::size_t const limit = 2;
while (true)
{
auto deleted = sqliteDb->deleteAccountTransactionsInRange(
rangeStart, rangeEnd, limit);
if (deleted == 0)
break;
totalDeleted += deleted;
++iterations;
if (iterations > initialAcctTxCount + 1)
{
fail(
"deleteAccountTransactionsInRange: too many "
"iterations, possible infinite loop");
break;
}
}
BEAST_EXPECT(totalDeleted > 0);
BEAST_EXPECT(totalDeleted <= initialAcctTxCount);
auto afterAcctTxCount = sqliteDb->getAccountTransactionCount();
BEAST_EXPECT(afterAcctTxCount == initialAcctTxCount - totalDeleted);
}
// Test deleteLedgersInRange
auto initialLedgerCount = sqliteDb->getLedgerCountMinMax();
auto ledgersDeleted =
sqliteDb->deleteLedgersInRange(rangeStart + 1, rangeEnd - 1);
BEAST_EXPECT(ledgersDeleted > 0);
auto afterLedgerCount = sqliteDb->getLedgerCountMinMax();
BEAST_EXPECT(
afterLedgerCount.numberOfRows ==
initialLedgerCount.numberOfRows - ledgersDeleted);
// Test range deletion with no matching range
auto emptyDeleted = sqliteDb->deleteLedgersInRange(999999, 999999);
BEAST_EXPECT(emptyDeleted == 0);
}
void
testDatabaseSpaceOperations(
std::string const& backend,
@@ -744,6 +862,7 @@ public:
testAccountTransactionOperations(backend, makeConfig(backend));
testAccountTransactionPaging(backend, makeConfig(backend));
testDeletionOperations(backend, makeConfig(backend));
testRangeDeletionOperations(backend, makeConfig(backend));
testDatabaseSpaceOperations(backend, makeConfig(backend));
testTransactionMinLedgerSeq(backend, makeConfig(backend));
}

View File

@@ -0,0 +1,322 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <test/jtx.h>
#include <xrpld/rpc/detail/CatalogueStream.h>
#include <xrpld/shamap/SHAMap.h>
#include <xrpld/shamap/SHAMapItem.h>
#include <xrpld/shamap/SHAMapTreeNode.h>
#include <xrpl/beast/unit_test.h>
#include <boost/iostreams/device/array.hpp>
#include <cstdint>
#include <cstring>
#include <vector>
namespace ripple {
namespace test {
class CatalogueStream_test : public beast::unit_test::suite
{
// The deserialize* public API takes `CatalogueInputStream&` which is just
// `boost::iostreams::filtering_istream`. So no production-code change is
// needed for fault-injection — we hand-build a byte vector that violates
// the wire format, push it through an `array_source`, and feed it to
// deserialize. Each test below targets one error branch in
// CatalogueStream.cpp.
//
// Wire format reminder (from serializeSHAMapToStream):
// leaf : [type:u8 ][key:32][size:u32][data:size]
// remove: [254 :u8 ][key:32]
// end : [255 :u8 ]
using Bytes = std::vector<std::uint8_t>;
static void
appendByte(Bytes& b, std::uint8_t v)
{
b.push_back(v);
}
static void
appendBytes(Bytes& b, void const* p, std::size_t n)
{
auto const* src = reinterpret_cast<std::uint8_t const*>(p);
b.insert(b.end(), src, src + n);
}
static void
appendKey(Bytes& b, std::uint8_t fillByte = 0xAA)
{
b.insert(b.end(), 32, fillByte);
}
static void
appendU32(Bytes& b, std::uint32_t v)
{
// CatalogueStream writes raw bytes — no endian conversion. Match.
appendBytes(b, &v, sizeof(v));
}
// Build a fresh filtering_istream over `bytes`. The array_source is a
// value type stored by the filter chain, so `bytes` must outlive it.
static void
makeStream(Bytes const& bytes, RPC::CatalogueInputStream& out)
{
boost::iostreams::array_source src(
reinterpret_cast<char const*>(bytes.data()), bytes.size());
out.push(src);
}
// Construct an empty SHAMap of the requested type using the test app's
// node family. Same pattern used in src/test/consensus/UNLReport_test.cpp.
static std::shared_ptr<SHAMap>
makeMap(jtx::Env& env, SHAMapType type)
{
return std::make_shared<SHAMap>(type, env.app().getNodeFamily());
}
public:
void
testTruncatedKey()
{
testcase("deserialize: truncated read of key");
jtx::Env env(*this);
auto map = makeMap(env, SHAMapType::STATE);
// 1 byte (the node type) and nothing after — read of the 32-byte key
// hits EOF inside deserializeLeaf, triggers the "stream stopped while
// trying to read key" branch.
Bytes b;
appendByte(b, tnACCOUNT_STATE);
RPC::CatalogueInputStream stream;
makeStream(b, stream);
BEAST_EXPECT(!RPC::deserializeStateMapFromStream(
*map, stream, hotACCOUNT_NODE, env.app().journal("Test")));
}
void
testTruncatedSize()
{
testcase("deserialize: truncated read of size");
jtx::Env env(*this);
auto map = makeMap(env, SHAMapType::TRANSACTION);
// type + key but no 4-byte size — triggers the "stream stopped while
// trying to read size" branch. Use tx map so the type byte isn't a
// remove (which would short-circuit before the size read).
Bytes b;
appendByte(b, tnTRANSACTION_MD);
appendKey(b);
RPC::CatalogueInputStream stream;
makeStream(b, stream);
BEAST_EXPECT(!RPC::deserializeTxMapFromStream(
*map, stream, hotTRANSACTION_NODE, env.app().journal("Test")));
}
void
testSizeTooLarge()
{
testcase("deserialize: size > 1 GiB rejected");
jtx::Env env(*this);
auto map = makeMap(env, SHAMapType::STATE);
// type + key + size of 2 GiB — bails before any data read attempt.
Bytes b;
appendByte(b, tnACCOUNT_STATE);
appendKey(b);
appendU32(b, 2u * 1024u * 1024u * 1024u);
RPC::CatalogueInputStream stream;
makeStream(b, stream);
BEAST_EXPECT(!RPC::deserializeStateMapFromStream(
*map, stream, hotACCOUNT_NODE, env.app().journal("Test")));
}
void
testTruncatedData()
{
testcase("deserialize: truncated read of data payload");
jtx::Env env(*this);
auto map = makeMap(env, SHAMapType::STATE);
// type + key + size=10 + only 5 bytes — hits the "Unexpected EOF
// while reading data" branch.
Bytes b;
appendByte(b, tnACCOUNT_STATE);
appendKey(b);
appendU32(b, 10);
for (int i = 0; i < 5; ++i)
appendByte(b, 0x42);
RPC::CatalogueInputStream stream;
makeStream(b, stream);
BEAST_EXPECT(!RPC::deserializeStateMapFromStream(
*map, stream, hotACCOUNT_NODE, env.app().journal("Test")));
}
void
testRemoveDisallowedInTxMap()
{
testcase(
"deserialize: remove sentinel rejected when allowRemoval=false");
jtx::Env env(*this);
auto map = makeMap(env, SHAMapType::TRANSACTION);
// tx maps don't accept tnREMOVE — the deserialize for tx maps calls
// the inner helper with allowRemoval=false. We send a remove record
// and expect rejection at "unexpected removal in this map type".
Bytes b;
appendByte(b, tnREMOVE);
appendKey(b);
RPC::CatalogueInputStream stream;
makeStream(b, stream);
BEAST_EXPECT(!RPC::deserializeTxMapFromStream(
*map, stream, hotTRANSACTION_NODE, env.app().journal("Test")));
}
void
testRemoveOfAbsentKey()
{
testcase("deserialize: remove of absent key rejected");
jtx::Env env(*this);
auto map = makeMap(env, SHAMapType::STATE);
// State map allows tnREMOVE, but the key has to be present first.
// Empty map + remove of any key → "key is already absent" branch.
Bytes b;
appendByte(b, tnREMOVE);
appendKey(b, 0x77);
RPC::CatalogueInputStream stream;
makeStream(b, stream);
BEAST_EXPECT(!RPC::deserializeStateMapFromStream(
*map, stream, hotACCOUNT_NODE, env.app().journal("Test")));
}
void
testMissingTerminator()
{
testcase("deserialize: EOF before terminal sentinel");
jtx::Env env(*this);
auto map = makeMap(env, SHAMapType::STATE);
// One valid leaf, then EOF (no tnTERMINAL). The outer loop exits via
// stream.eof() with lastParsed = tnACCOUNT_STATE (not tnTERMINAL),
// so the "Unexpected EOF, terminal node not found" branch fires.
// Payload must be >= 12 bytes — SHAMapLeafNode asserts that for
// state-tree leaves.
Bytes b;
appendByte(b, tnACCOUNT_STATE);
appendKey(b, 0x11);
appendU32(b, 16);
for (int i = 0; i < 16; ++i)
appendByte(b, 0xCC);
RPC::CatalogueInputStream stream;
makeStream(b, stream);
BEAST_EXPECT(!RPC::deserializeStateMapFromStream(
*map, stream, hotACCOUNT_NODE, env.app().journal("Test")));
}
void
testEmptyStreamAcceptsTerminator()
{
testcase("deserialize: bare terminator → empty map, success");
jtx::Env env(*this);
auto map = makeMap(env, SHAMapType::STATE);
Bytes b;
appendByte(b, tnTERMINAL);
RPC::CatalogueInputStream stream;
makeStream(b, stream);
BEAST_EXPECT(RPC::deserializeStateMapFromStream(
*map, stream, hotACCOUNT_NODE, env.app().journal("Test")));
}
void
testHappyPathRoundTrip()
{
testcase("deserialize: valid leaf + terminator → success");
jtx::Env env(*this);
auto map = makeMap(env, SHAMapType::STATE);
// type + key + size=16 + 16 data bytes + terminator. Confirms the
// happy path through addGiveItem and flushDirty. Size must be >= 12
// — SHAMapLeafNode asserts that for state-tree leaves.
Bytes b;
appendByte(b, tnACCOUNT_STATE);
appendKey(b, 0x55);
appendU32(b, 16);
for (int i = 0; i < 16; ++i)
appendByte(b, static_cast<std::uint8_t>(i));
appendByte(b, tnTERMINAL);
RPC::CatalogueInputStream stream;
makeStream(b, stream);
BEAST_EXPECT(RPC::deserializeStateMapFromStream(
*map, stream, hotACCOUNT_NODE, env.app().journal("Test")));
uint256 key;
std::memset(key.data(), 0x55, 32);
BEAST_EXPECT(map->hasItem(key));
}
void
run() override
{
testTruncatedKey();
testTruncatedSize();
testSizeTooLarge();
testTruncatedData();
testRemoveDisallowedInTxMap();
testRemoveOfAbsentKey();
testMissingTerminator();
testEmptyStreamAcceptsTerminator();
testHappyPathRoundTrip();
}
};
BEAST_DEFINE_TESTSUITE(CatalogueStream, rpc, ripple);
} // namespace test
} // namespace ripple

View File

@@ -19,15 +19,103 @@
#include <test/jtx.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/core/ConfigSections.h>
#include <xrpl/beast/unit_test.h>
#include <xrpl/beast/utility/temp_dir.h>
#include <xrpl/protocol/jss.h>
#include <boost/filesystem.hpp>
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <grpc/impl/codegen/compression_types.h>
#include <thread>
namespace ripple {
namespace {
// Parameters for polling ledger retrieval
struct LedgerRetryParams
{
test::jtx::Env& env;
std::uint32_t seq = 0;
std::chrono::milliseconds pollInterval = std::chrono::milliseconds(500);
std::chrono::milliseconds maxWaitTime = std::chrono::milliseconds(10000);
bool requireValidated = false;
};
// Poll for ledger availability with retry logic
// Returns nullptr if ledger cannot be retrieved within the timeout period
std::shared_ptr<Ledger const>
getLedgerWithRetry(const LedgerRetryParams& params)
{
auto start = std::chrono::steady_clock::now();
while (true)
{
// First try to get the hash for this sequence
auto hash = params.env.app().getLedgerMaster().getHashBySeq(params.seq);
if (hash.isNonZero())
{
// Hash found, now try to get the ledger
auto ledger =
params.env.app().getLedgerMaster().getLedgerByHash(hash);
if (ledger)
{
// If we don't require validation, or if it's already validated,
// return it
if (!params.requireValidated || ledger->info().validated)
{
return ledger;
}
// Otherwise continue polling until it becomes validated
}
}
// Check if we've exceeded the timeout
auto elapsed = std::chrono::steady_clock::now() - start;
if (elapsed >= params.maxWaitTime)
{
return nullptr;
}
// Wait before next attempt
std::this_thread::sleep_for(params.pollInterval);
}
}
// Options for catalogueEnvconfig. Use designated initializers at call sites
// so each test reads as a small diff against the default — easy to scan and
// trivially extensible (add fields here, no call-site churn).
// catalogueEnvconfig() // happy path
// catalogueEnvconfig({.withPinnedType = false}) // exercise guard
struct CatalogueEnvOpts
{
// Set [node_db] pinned_type=rwdb so catalogue_load passes its guard.
// false → omit pinned_type entirely (drives the rejection path).
bool withPinnedType = true;
// [node_db] online_delete value (lifetime of rotating store, in ledgers).
std::string onlineDelete = "256";
};
// envconfig with pinned_type set so catalogue_load passes the config guard.
// Uses rwdb for in-process speed (allowed in standalone).
inline std::unique_ptr<Config>
catalogueEnvconfig(CatalogueEnvOpts const& opts = {})
{
auto cfg = test::jtx::envconfig();
auto& nodeDb = cfg->section(ConfigSection::nodeDatabase());
if (opts.withPinnedType)
nodeDb.set("pinned_type", "rwdb");
nodeDb.set("online_delete", opts.onlineDelete);
return cfg;
}
} // anonymous namespace
#pragma pack(push, 1) // pack the struct tightly
struct TestCATLHeader
{
@@ -215,7 +303,24 @@ class Catalogue_test : public beast::unit_test::suite
{
testcase("catalogue_load: Invalid parameters");
using namespace test::jtx;
Env env{*this, envconfig(), features};
Env env{*this, catalogueEnvconfig(), features};
// Missing [node_db] pinned_type — catalogue_load rejects up front so
// loaded data isn't lost on the next rotation. Use a separate Env
// because this guard fires before any input_file parsing.
{
Env envNoPin{
*this, catalogueEnvconfig({.withPinnedType = false}), features};
Json::Value params{Json::objectValue};
params[jss::input_file] = "/tmp/anything.catl";
auto const result =
envNoPin.client().invoke("catalogue_load", params)[jss::result];
BEAST_EXPECT(result[jss::error] == "invalidParams");
BEAST_EXPECT(result[jss::status] == "error");
BEAST_EXPECT(
result[jss::error_message].asString().find("pinned_type") !=
std::string::npos);
}
// No parameters
{
@@ -253,6 +358,42 @@ class Catalogue_test : public beast::unit_test::suite
BEAST_EXPECT(result[jss::error] == "internal");
BEAST_EXPECT(result[jss::status] == "error");
}
// Header has min_ledger > max_ledger
{
boost::filesystem::path tempDir =
boost::filesystem::temp_directory_path() /
boost::filesystem::unique_path();
boost::filesystem::create_directories(tempDir);
auto cataloguePath = (tempDir / "invalid-range.catl").string();
TestCATLHeader header;
header.min_ledger = 20;
header.max_ledger = 10;
header.version = 1;
header.network_id = env.app().config().NETWORK_ID;
header.filesize = sizeof(TestCATLHeader);
std::ofstream outfile(
cataloguePath.c_str(), std::ios::out | std::ios::binary);
BEAST_EXPECT(outfile.good());
outfile.write(
reinterpret_cast<char const*>(&header), sizeof(header));
outfile.close();
Json::Value params{Json::objectValue};
params[jss::input_file] = cataloguePath;
auto const result =
env.client().invoke("catalogue_load", params)[jss::result];
BEAST_EXPECT(result[jss::error] == "invalidParams");
BEAST_EXPECT(result[jss::status] == "error");
BEAST_EXPECT(
result[jss::error_message].asString().find(
"min_ledger must be <= max_ledger") != std::string::npos);
boost::filesystem::remove_all(tempDir);
}
}
void
@@ -262,9 +403,25 @@ class Catalogue_test : public beast::unit_test::suite
using namespace test::jtx;
// Create environment and test data
Env env{*this, envconfig(), features};
Env env{*this, catalogueEnvconfig(), nullptr, beast::severities::kNone};
prepareLedgerData(env, 5);
auto noop = [](test::jtx::Env& env,
std::string partition,
std::string severity) {
Json::Value params{Json::objectValue};
params[jss::severity] = severity;
params[jss::partition] = partition;
// env.client().invoke("log_level", params);
};
// Create journal for debugging
auto j = env.app().logs().journal("Catalogue_test");
noop(env, "CatalogueTools", "trace");
noop(env, "Catalogue_test", "trace");
JLOG(j.trace()) << "Test environment created, prepared ledger data";
// Store some key state information before catalogue creation
auto const sourceLedger = env.closed();
auto const bobKeylet = keylet::account(Account("bob").id());
@@ -274,6 +431,9 @@ class Catalogue_test : public beast::unit_test::suite
Account("bob").id(),
Currency(to_currency("EUR")));
JLOG(j.trace()) << "Source ledger seq: " << sourceLedger->info().seq
<< " hash: " << sourceLedger->info().hash;
// Get original state entries
auto const bobAcct = sourceLedger->read(bobKeylet);
auto const charlieAcct = sourceLedger->read(charlieKeylet);
@@ -302,6 +462,10 @@ class Catalogue_test : public beast::unit_test::suite
// First create a catalogue
uint32_t minLedger = 3;
uint32_t maxLedger = sourceLedger->info().seq;
JLOG(j.trace()) << "Creating catalogue from ledger " << minLedger
<< " to " << maxLedger;
{
Json::Value params{Json::objectValue};
params[jss::min_ledger] = minLedger;
@@ -311,27 +475,43 @@ class Catalogue_test : public beast::unit_test::suite
auto const result =
env.client().invoke("catalogue_create", params)[jss::result];
BEAST_EXPECT(result[jss::status] == jss::success);
JLOG(j.trace()) << "Catalogue created: " << result.toStyledString();
}
// Create a new environment for loading with unique port
// Create a new environment for loading the catalogue
// We use a separate environment with incremented ports to avoid
// conflicts Note: The default RWDB backend works fine - the key insight
// is that pinned ledgers bypass the cache, so we need to poll for
// availability as the async publishAcqLedger jobs complete
Env loadEnv{
*this,
test::jtx::envconfig(),
catalogueEnvconfig(),
features,
};
noop(loadEnv, "CatalogueTools", "trace");
noop(loadEnv, "Catalogue_test", "trace");
// Create journal for load environment
auto loadJ = loadEnv.app().logs().journal("Catalogue_test");
// Now load the catalogue
Json::Value params{Json::objectValue};
params[jss::input_file] = cataloguePath;
JLOG(loadJ.trace()) << "Loading catalogue from " << cataloguePath;
auto const result =
loadEnv.client().invoke("catalogue_load", params)[jss::result];
JLOG(loadJ.trace())
<< "Catalogue load result: " << result.toStyledString();
BEAST_EXPECT(result[jss::status] == jss::success);
BEAST_EXPECT(result[jss::ledger_min] == minLedger);
BEAST_EXPECT(result[jss::ledger_max] == maxLedger);
BEAST_EXPECT(result[jss::ledger_count] == (maxLedger - minLedger + 1));
// Verify complete_ledgers reflects loaded ledgers
auto const newCompleteLedgers =
loadEnv.app().getLedgerMaster().getCompleteLedgers();
@@ -346,20 +526,36 @@ class Catalogue_test : public beast::unit_test::suite
// Compare all ledgers from 3 to 16 inclusive
for (std::uint32_t seq = 3; seq <= 16; ++seq)
{
auto const sourceLedger =
env.app().getLedgerMaster().getLedgerByHash(
env.app().getLedgerMaster().getHashBySeq(seq));
JLOG(j.trace()) << "Comparing ledger " << seq;
auto const loadedLedger =
loadEnv.app().getLedgerMaster().getLedgerByHash(
loadEnv.app().getLedgerMaster().getHashBySeq(seq));
// Get the source ledger (doesn't need to be validated)
auto const sourceLedger = getLedgerWithRetry(
{.env = env,
.seq = seq,
.pollInterval = std::chrono::milliseconds(100),
.maxWaitTime = std::chrono::milliseconds(5000),
.requireValidated = false});
// Get the loaded ledger (must be validated)
auto const loadedLedger = getLedgerWithRetry(
{.env = loadEnv,
.seq = seq,
.pollInterval = std::chrono::milliseconds(500),
.maxWaitTime = std::chrono::milliseconds(30000),
.requireValidated = true});
if (!sourceLedger || !loadedLedger)
{
JLOG(j.trace())
<< "Failed to get ledger " << seq
<< " source: " << (sourceLedger ? "ok" : "missing")
<< " loaded: " << (loadedLedger ? "ok" : "missing");
BEAST_EXPECT(false); // Test failure
continue;
}
JLOG(j.trace()) << "Got both ledgers for seq " << seq;
// Check basic ledger properties
BEAST_EXPECT(sourceLedger->info().seq == loadedLedger->info().seq);
BEAST_EXPECT(
@@ -404,6 +600,10 @@ class Catalogue_test : public beast::unit_test::suite
std::size_t sourceCount = std::ranges::distance(sourceLedger->sles);
std::size_t loadedCount = std::ranges::distance(loadedLedger->sles);
JLOG(j.trace())
<< "Ledger " << seq << " SLE count - source: " << sourceCount
<< " loaded: " << loadedCount;
BEAST_EXPECT(sourceCount == loadedCount);
// Check existence of imported keylets
@@ -411,6 +611,13 @@ class Catalogue_test : public beast::unit_test::suite
{
auto const key = sle->key();
bool exists = loadedLedger->exists(keylet::unchecked(key));
if (!exists)
{
JLOG(j.trace())
<< "Ledger " << seq << " missing key: " << key;
}
BEAST_EXPECT(exists);
// If it exists, check the serialized form matches
@@ -421,6 +628,13 @@ class Catalogue_test : public beast::unit_test::suite
sle->add(s1);
loadedSle->add(s2);
bool serializedEqual = (s1.peekData() == s2.peekData());
if (!serializedEqual)
{
JLOG(j.trace())
<< "Ledger " << seq << " mismatch for key: " << key;
}
BEAST_EXPECT(serializedEqual);
}
}
@@ -429,10 +643,20 @@ class Catalogue_test : public beast::unit_test::suite
for (auto const& sle : loadedLedger->sles)
{
auto const key = sle->key();
BEAST_EXPECT(sourceLedger->exists(keylet::unchecked(key)));
bool exists = sourceLedger->exists(keylet::unchecked(key));
if (!exists)
{
JLOG(j.trace())
<< "Ledger " << seq << " extra key in loaded: " << key;
}
BEAST_EXPECT(exists);
}
}
JLOG(j.trace()) << "Ledger comparison complete";
auto const loadedBobAcct = loadedLedger->read(bobKeylet);
auto const loadedCharlieAcct = loadedLedger->read(charlieKeylet);
auto const loadedEurTrust = loadedLedger->read(eurTrustKeylet);
@@ -523,10 +747,11 @@ class Catalogue_test : public beast::unit_test::suite
// Try to load catalogue in environment with different network ID
Env env2{
*this,
envconfig([](std::unique_ptr<Config> cfg) {
[&]() {
auto cfg = catalogueEnvconfig();
cfg->NETWORK_ID = 456;
return cfg;
}),
}(),
features,
};
@@ -553,7 +778,7 @@ class Catalogue_test : public beast::unit_test::suite
// Create environment and test data
Env env{
*this,
envconfig(),
catalogueEnvconfig(),
features,
nullptr,
beast::severities::kDisabled,
@@ -650,7 +875,7 @@ class Catalogue_test : public beast::unit_test::suite
// Create environment and test data
Env env{
*this,
envconfig(),
catalogueEnvconfig(),
features,
nullptr,
beast::severities::kDisabled,
@@ -729,7 +954,7 @@ class Catalogue_test : public beast::unit_test::suite
using namespace test::jtx;
// Create environment and test data
Env env{*this, envconfig(), features};
Env env{*this, catalogueEnvconfig(), features};
prepareLedgerData(env, 5);
boost::filesystem::path tempDir =
@@ -818,7 +1043,7 @@ class Catalogue_test : public beast::unit_test::suite
using namespace test::jtx;
// Create environment
Env env{*this, envconfig(), features};
Env env{*this, catalogueEnvconfig(), features};
boost::filesystem::path tempDir =
boost::filesystem::temp_directory_path() /

View File

@@ -1044,7 +1044,8 @@ pendSaveValidated(
Application& app,
std::shared_ptr<Ledger const> const& ledger,
bool isSynchronous,
bool isCurrent)
bool isCurrent,
std::function<void(bool)> callback)
{
if (!app.getHashRouter().setFlags(ledger->info().hash, SF_SAVED))
{
@@ -1056,6 +1057,8 @@ pendSaveValidated(
{
// Either we don't need it to be finished
// or it is finished
if (callback)
callback(true);
return true;
}
}
@@ -1069,6 +1072,8 @@ pendSaveValidated(
JLOG(stream) << "Pend save with seq in pending saves "
<< ledger->info().seq;
if (callback)
callback(true);
return true;
}
@@ -1077,15 +1082,20 @@ pendSaveValidated(
app.getJobQueue().addJob(
isCurrent ? jtPUBLEDGER : jtPUBOLDLEDGER,
std::to_string(ledger->seq()),
[&app, ledger, isCurrent]() {
saveValidatedLedger(app, ledger, isCurrent);
[&app, ledger, isCurrent, callback]() {
bool success = saveValidatedLedger(app, ledger, isCurrent);
if (callback)
callback(success);
}))
{
return true;
}
// The JobQueue won't do the Job. Do the save synchronously.
return saveValidatedLedger(app, ledger, isCurrent);
bool success = saveValidatedLedger(app, ledger, isCurrent);
if (callback)
callback(success);
return success;
}
void

View File

@@ -452,7 +452,8 @@ pendSaveValidated(
Application& app,
std::shared_ptr<Ledger const> const& ledger,
bool isSynchronous,
bool isCurrent);
bool isCurrent,
std::function<void(bool)> callback = {});
std::shared_ptr<Ledger>
loadLedgerHelper(LedgerInfo const& sinfo, Application& app, bool acquire);

View File

@@ -139,6 +139,9 @@ public:
RangeSet<std::uint32_t>
getPinnedLedgersRangeSet();
void
setPinnedLedgersRangeSet(const RangeSet<std::uint32_t>& range_set);
/** Apply held transactions to the open ledger
This is normally called as we close the ledger.
The open ledger remains open to handle new transactions
@@ -205,6 +208,10 @@ public:
void
clearLedger(std::uint32_t seq);
bool
isPinned(std::uint32_t seq);
void
unpinLedger(std::uint32_t seq);
bool
isValidated(ReadView const& ledger);
bool
getValidatedRange(std::uint32_t& minVal, std::uint32_t& maxVal);
@@ -359,7 +366,12 @@ private:
std::recursive_mutex mCompleteLock;
RangeSet<std::uint32_t> mCompleteLedgers;
// When both locks are needed, acquire them together with std::scoped_lock.
std::mutex mPinnedLock;
RangeSet<std::uint32_t> mPinnedLedgers; // Track pinned ledger ranges
bool mPinnedMergedToComplete{
false}; // One-shot: pinned merged into mCompleteLedgers
// Publish thread is running.
bool mAdvanceThread{false};

View File

@@ -24,6 +24,7 @@
#include <xrpld/app/ledger/OpenLedger.h>
#include <xrpld/app/ledger/OrderBookDB.h>
#include <xrpld/app/ledger/PendingSaves.h>
#include <xrpld/app/ledger/detail/PublishGap.h>
#include <xrpld/app/main/Application.h>
#include <xrpld/app/misc/AmendmentTable.h>
#include <xrpld/app/misc/HashRouter.h>
@@ -446,14 +447,25 @@ bool
LedgerMaster::storeLedger(std::shared_ptr<Ledger const> ledger, bool pin)
{
bool validated = ledger->info().validated;
// Returns true if we already had the ledger
if (!mLedgerHistory.insert(std::move(ledger), validated))
// Returns true if we already had the ledger.
//
// When pin=true, we deliberately skip mLedgerHistory insertion.
// A single catalogue pack can contain tens of thousands of ledgers,
// each holding a full SHAMap of account/transaction nodes. Keeping
// them all in the in-memory history cache would consume hundreds of
// GB of RAM. Instead, pinned ledgers are persisted to the nodestore
// and SQLite, and are retrievable via RPC (which reads from SQLite),
// but are NOT kept in memory.
if (!pin && !mLedgerHistory.insert(std::move(ledger), validated))
return false;
if (pin)
{
uint32_t seq = ledger->info().seq;
mPinnedLedgers.insert(range(seq, seq));
{
std::lock_guard sl(mPinnedLock);
mPinnedLedgers.insert(range(seq, seq));
}
JLOG(m_journal.info()) << "Pinned ledger : " << seq;
}
return true;
@@ -512,7 +524,7 @@ LedgerMaster::haveLedger(std::uint32_t seq)
void
LedgerMaster::clearLedger(std::uint32_t seq)
{
std::lock_guard sl(mCompleteLock);
std::scoped_lock lock(mCompleteLock, mPinnedLock);
// Don't clear pinned ledgers
if (boost::icl::contains(mPinnedLedgers, seq))
@@ -525,6 +537,20 @@ LedgerMaster::clearLedger(std::uint32_t seq)
mCompleteLedgers.erase(seq);
}
bool
LedgerMaster::isPinned(std::uint32_t seq)
{
std::lock_guard sl(mPinnedLock);
return boost::icl::contains(mPinnedLedgers, seq);
}
void
LedgerMaster::unpinLedger(std::uint32_t seq)
{
std::lock_guard sl(mPinnedLock);
mPinnedLedgers.erase(range(seq, seq));
}
bool
LedgerMaster::isValidated(ReadView const& ledger)
{
@@ -861,7 +887,19 @@ LedgerMaster::setFullLedger(
pendSaveValidated(app_, ledger, isSynchronous, isCurrent);
{
std::lock_guard ml(mCompleteLock);
std::scoped_lock lock(mCompleteLock, mPinnedLock);
// One-time merge of pinned ranges into mCompleteLedgers.
// For NORMAL/NETWORK startup, this fires on the first validated
// ledger (network quorum). For LOAD/standalone, the merge
// already happened in setPinnedLedgersRangeSet() and this
// is a no-op (mPinnedMergedToComplete is already true).
if (!mPinnedMergedToComplete && !mPinnedLedgers.empty())
{
for (auto const& interval : mPinnedLedgers)
mCompleteLedgers.insert(interval);
mPinnedMergedToComplete = true;
}
mCompleteLedgers.insert(ledger->info().seq);
}
@@ -1286,53 +1324,134 @@ LedgerMaster::findNewLedgersToPublish(
auto valLedger = mValidLedger.get();
std::uint32_t valSeq = valLedger->info().seq;
// Create a range of ledgers we need to publish
RangeSet<std::uint32_t> toPublish;
toPublish.insert(range(pubSeq, valSeq));
// CRITICAL: Skip publishing pinned ledgers (except the most recent).
//
// When loading catalogues, we deliberately avoid storing pinned ledgers in
// the AcceptedLedger cache to prevent memory bloat with millions of
// ledgers. However, NetworkOPs::pubLedger() expects every published ledger
// to either:
// 1. Already exist in the AcceptedLedger cache, OR
// 2. Be able to create and cache a new AcceptedLedger
//
// For pinned ledgers, we skip the cache entirely during
// saveValidatedLedger. If we try to publish them, pubLedger creates a new
// AcceptedLedger, but canonicalize_replace_client might return a different
// instance, causing: "Assertion failed: alpAccepted->getLedger().get() ==
// lpAccepted.get()"
//
// This primarily affects STANDALONE MODE where catalogue_load calls
// switchLCL when loading historical ledgers newer than the current closed
// ledger. In live network mode, historical ledgers are typically older than
// the current ledger, so switchLCL -> tryAdvance -> publish isn't
// triggered.
//
// Solution: Only publish the most recent validated ledger (which needs
// publishing) and skip all intermediate pinned ledgers (already on disk).
{
std::lock_guard sll(mPinnedLock);
RangeSet<std::uint32_t> pinnedExceptLast = mPinnedLedgers;
// Remove the most recent from the pinned set so we always publish it
if (boost::icl::contains(pinnedExceptLast, valSeq))
pinnedExceptLast.erase(range(valSeq, valSeq));
// Subtract pinned ledgers from the set to publish
toPublish -= pinnedExceptLast;
}
scope_unlock sul{sl};
try
{
for (std::uint32_t seq = pubSeq; seq <= valSeq; ++seq)
for (auto const& interval : toPublish)
{
JLOG(m_journal.trace())
<< "Trying to fetch/publish valid ledger " << seq;
// Some sequences may be absent from toPublish because they are
// pinned ledgers already persisted to disk and intentionally not
// republished. The decision logic — "can pubSeq safely jump
// across this gap?" — lives in detail::canSkipPinnedGap so it
// is unit-testable without spinning up a full LedgerMaster.
//
// Core invariant (enforced by canSkipPinnedGap): never publish
// across a gap in non-pinned ledgers. Publication must remain
// contiguous for every non-pinned sequence from
// mPubLedgerSeq + 1 forward. If pubSeq is lagging because an
// earlier non-pinned ledger could not be fetched/published, we
// stop here rather than publish newer ledgers out of order.
//
// In production this branch is essentially dead code: pinned
// ranges are old historical catalogue data and pubSeq tracks
// the recent published tip, so toPublish won't have pinned-
// induced gaps. It only matters in test/standalone catalogue-
// load scenarios where pinned ranges can sit near pubSeq.
if (pubSeq < interval.first())
{
bool canSkip;
{
std::lock_guard sll(mPinnedLock);
canSkip = detail::canSkipPinnedGap(
pubSeq, interval.first(), mPinnedLedgers);
}
std::shared_ptr<Ledger const> ledger;
// This can throw
auto hash = hashOfSeq(*valLedger, seq, m_journal);
// VFALCO TODO Restructure this code so that zero is not
// used.
if (!hash)
hash = beast::zero; // kludge
if (seq == valSeq)
{
// We need to publish the ledger we just fully validated
ledger = valLedger;
}
else if (hash->isZero())
{
JLOG(m_journal.fatal()) << "Ledger: " << valSeq
<< " does not have hash for " << seq;
UNREACHABLE(
"ripple::LedgerMaster::findNewLedgersToPublish : ledger "
"not found");
}
else
{
ledger = mLedgerHistory.getLedgerByHash(*hash);
if (!canSkip)
{
JLOG(m_journal.trace())
<< "Stopping publish at seq " << pubSeq
<< " — skipped span [" << pubSeq << ", "
<< interval.first() << ") contains non-pinned ledgers";
break;
}
pubSeq = interval.first();
}
if (!app_.config().LEDGER_REPLAY)
for (std::uint32_t seq = interval.first(); seq <= interval.last();
++seq)
{
// Can we try to acquire the ledger we need?
if (!ledger && (++acqCount < ledger_fetch_size_))
ledger = app_.getInboundLedgers().acquire(
*hash, seq, InboundLedger::Reason::GENERIC);
}
JLOG(m_journal.trace())
<< "Trying to fetch/publish valid ledger " << seq;
// Did we acquire the next ledger we need to publish?
if (ledger && (ledger->info().seq == pubSeq))
{
ledger->setValidated();
ret.push_back(ledger);
++pubSeq;
std::shared_ptr<Ledger const> ledger;
// This can throw
auto hash = hashOfSeq(*valLedger, seq, m_journal);
// VFALCO TODO Restructure this code so that zero is not
// used.
if (!hash)
hash = beast::zero; // kludge
if (seq == valSeq)
{
// We need to publish the ledger we just fully validated
ledger = valLedger;
}
else if (hash->isZero())
{
JLOG(m_journal.fatal())
<< "Ledger: " << valSeq << " does not have hash for "
<< seq;
UNREACHABLE(
"ripple::LedgerMaster::findNewLedgersToPublish : "
"ledger not found");
}
else
{
ledger = mLedgerHistory.getLedgerByHash(*hash);
}
if (!app_.config().LEDGER_REPLAY)
{
// Can we try to acquire the ledger we need?
if (!ledger && (++acqCount < ledger_fetch_size_))
ledger = app_.getInboundLedgers().acquire(
*hash, seq, InboundLedger::Reason::GENERIC);
}
// Did we acquire the next ledger we need to publish?
if (ledger && (ledger->info().seq == pubSeq))
{
ledger->setValidated();
ret.push_back(ledger);
++pubSeq;
}
}
}
@@ -1632,7 +1751,7 @@ LedgerMaster::getCompleteLedgers()
std::string
LedgerMaster::getPinnedLedgers()
{
std::lock_guard sl(mCompleteLock);
std::lock_guard sl(mPinnedLock);
return to_string(mPinnedLedgers);
}
@@ -1646,10 +1765,46 @@ LedgerMaster::getCompleteLedgersRangeSet()
RangeSet<std::uint32_t>
LedgerMaster::getPinnedLedgersRangeSet()
{
std::lock_guard sl(mCompleteLock);
std::lock_guard sl(mPinnedLock);
return mPinnedLedgers;
}
void
LedgerMaster::setPinnedLedgersRangeSet(const RangeSet<std::uint32_t>& range_set)
{
std::scoped_lock lock(mCompleteLock, mPinnedLock);
if (!mPinnedLedgers.empty())
{
Throw<std::runtime_error>(
"Expected mPinnedLedgers to be empty on startup");
}
mPinnedLedgers.assign(range_set);
// Normally, we defer merging pinned ranges into mCompleteLedgers until
// the first validated ledger (in setFullLedger), so that pinned history
// only becomes queryable after the node has network quorum. But if
// mCompleteLedgers is already non-empty (LOAD/standalone startup already
// called setFullLedger before we got here), the one-shot in
// setFullLedger won't fire again. Merge immediately in that case.
if (!mCompleteLedgers.empty())
{
for (auto const& interval : mPinnedLedgers)
mCompleteLedgers.insert(interval);
mPinnedMergedToComplete = true;
JLOG(m_journal.info())
<< "Merged pinned ranges into complete ledgers immediately "
<< "(startup already past setFullLedger): " << to_string(range_set);
}
else
{
// mCompleteLedgers is empty — defer merge to setFullLedger()
// when the first validated ledger arrives (network quorum).
JLOG(m_journal.info())
<< "Loaded pinned ranges, will merge on first validation: "
<< to_string(range_set);
}
}
std::optional<NetClock::time_point>
LedgerMaster::getCloseTimeBySeq(LedgerIndex ledgerIndex)
{
@@ -1813,15 +1968,18 @@ LedgerMaster::setLedgerRangePresent(
std::uint32_t maxV,
bool pin)
{
std::lock_guard sl(mCompleteLock);
mCompleteLedgers.insert(range(minV, maxV));
if (pin)
{
std::scoped_lock lock(mCompleteLock, mPinnedLock);
mCompleteLedgers.insert(range(minV, maxV));
mPinnedLedgers.insert(range(minV, maxV));
JLOG(m_journal.info())
<< "Pinned ledger range: " << minV << " - " << maxV;
return;
}
std::lock_guard cl(mCompleteLock);
mCompleteLedgers.insert(range(minV, maxV));
}
void
@@ -1841,7 +1999,7 @@ LedgerMaster::getCacheHitRate()
void
LedgerMaster::clearPriorLedgers(LedgerIndex seq)
{
std::lock_guard sl(mCompleteLock);
std::scoped_lock lock(mCompleteLock, mPinnedLock);
if (seq <= 0)
return;

View File

@@ -0,0 +1,87 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_APP_LEDGER_DETAIL_PUBLISH_GAP_H_INCLUDED
#define RIPPLE_APP_LEDGER_DETAIL_PUBLISH_GAP_H_INCLUDED
#include <xrpl/basics/RangeSet.h>
#include <cstdint>
namespace ripple {
namespace detail {
/** Decide whether pubSeq can safely advance across a gap in the
publication set, given the pinned-ledger ranges.
Used by LedgerMaster::findNewLedgersToPublish: when the
publish-target set has been split into intervals (because pinned
ranges were subtracted from it), pubSeq may be behind the start of
the next interval. We may only "jump" pubSeq forward across that
gap if every skipped sequence is pinned — pinned ledgers are
persisted on disk and are intentionally never republished, so
skipping them does not violate the contiguous-publication
invariant. If any sequence in the gap is *not* pinned, then pubSeq
is lagging because an earlier non-pinned ledger could not be
fetched/published; we must halt rather than publish newer ledgers
out of order.
Practical note: in production this guard is essentially never
exercised. Pinned ranges are old historical catalogue ranges and
pubSeq tracks the recent published tip — they don't overlap. The
guard matters only in test/standalone catalogue-load scenarios
where a catalogue can be loaded into an environment whose ledger
history places pinned seqs near the publish horizon. We keep the
guard rather than rely on the assumption holding because (a) the
cost is one RangeSet subtraction per gap and (b) silently
publishing across a non-pinned gap would corrupt the stream
contract.
@param pubSeq The next sequence the publisher would like
to publish.
@param intervalStart The first sequence in the next non-pinned
interval to publish.
@param pinned The current pinned-ledger ranges.
@return true pubSeq can safely jump to intervalStart
(pubSeq >= intervalStart, or every skipped seq is
pinned).
@return false the gap [pubSeq, intervalStart) contains at least
one non-pinned sequence; the publisher must stop.
*/
inline bool
canSkipPinnedGap(
std::uint32_t pubSeq,
std::uint32_t intervalStart,
RangeSet<std::uint32_t> const& pinned)
{
// No gap.
if (pubSeq >= intervalStart)
return true;
RangeSet<std::uint32_t> skipped;
skipped.insert(range(pubSeq, intervalStart - 1));
skipped -= pinned;
return skipped.empty();
}
} // namespace detail
} // namespace ripple
#endif

View File

@@ -152,4 +152,4 @@ deletion routine.
* [fetch_depth] will be silently set to equal the online_delete setting if
online_delete is greater than fetch_depth.
* In the [node_db] section, there is a performance tuning option, delete_batch,
which sets the maximum size in ledgers for each SQL DELETE query.
which sets the maximum number of SQL rows deleted per batch.

View File

@@ -22,6 +22,7 @@
#include <xrpld/app/ledger/Ledger.h>
#include <xrpld/nodestore/Manager.h>
#include <xrpl/basics/RangeSet.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <optional>
@@ -61,6 +62,9 @@ public:
virtual LedgerIndex
setCanDelete(LedgerIndex canDelete) = 0;
virtual void
setPinnedRanges(RangeSet<std::uint32_t> const& ranges) = 0;
/** Whether advisory delete is enabled. */
virtual bool
advisoryDelete() const = 0;

View File

@@ -21,15 +21,20 @@
#include <xrpld/app/ledger/TransactionMaster.h>
#include <xrpld/app/misc/NetworkOPs.h>
#include <xrpld/app/misc/detail/OnlineDeleteRanges.h>
#include <xrpld/app/rdb/State.h>
#include <xrpld/app/rdb/backend/SQLiteDatabase.h>
#include <xrpld/core/ConfigSections.h>
#include <xrpld/nodestore/Scheduler.h>
#include <xrpld/nodestore/detail/DatabasePinnedImp.h>
#include <xrpld/nodestore/detail/DatabaseRotatingImp.h>
#include <xrpld/shamap/SHAMapMissingNode.h>
#include <xrpl/beast/core/CurrentThreadName.h>
#include <boost/algorithm/string/predicate.hpp>
#include <iostream>
#include <xrpl/json/json_reader.h>
namespace ripple {
void
@@ -79,6 +84,20 @@ SHAMapStoreImp::SavedStateDB::setLastRotated(LedgerIndex seq)
ripple::setLastRotated(sqlDb_, seq);
}
std::string
SHAMapStoreImp::SavedStateDB::getPinnedRanges()
{
std::lock_guard lock(mutex_);
return ripple::getPinnedRanges(sqlDb_);
}
void
SHAMapStoreImp::SavedStateDB::setPinnedRanges(std::string const& ranges)
{
std::lock_guard lock(mutex_);
ripple::setPinnedRanges(sqlDb_, ranges);
}
//------------------------------------------------------------------------------
SHAMapStoreImp::SHAMapStoreImp(
@@ -117,6 +136,9 @@ SHAMapStoreImp::SHAMapStoreImp(
get_if_exists(section, "online_delete", deleteInterval_);
// Always initialize state database for pinned ranges persistence
state_db_.init(config, dbName_);
if (deleteInterval_)
{
// Configuration that affects the behavior of online delete
@@ -153,7 +175,6 @@ SHAMapStoreImp::SHAMapStoreImp(
std::to_string(config.LEDGER_HISTORY) + ")");
}
state_db_.init(config, dbName_);
if (!config.mem_backend())
dbPaths();
}
@@ -183,6 +204,8 @@ SHAMapStoreImp::makeNodeStore(int readThreads)
{
SavedState state = state_db_.getState();
// Create the rotation backends - needed for both DatabaseRotating and
// DatabasePinned
auto writableBackend = makeBackendRotating(state.writableDb);
auto archiveBackend = makeBackendRotating(state.archiveDb);
if (!state.writableDb.size())
@@ -192,19 +215,58 @@ SHAMapStoreImp::makeNodeStore(int readThreads)
state_db_.setState(state);
}
// Create NodeStore with two backends to allow online deletion of
// data
auto dbr = std::make_unique<NodeStore::DatabaseRotatingImp>(
app_,
scheduler_,
readThreads,
std::move(writableBackend),
std::move(archiveBackend),
nscfg,
app_.logs().journal(nodeStoreName_));
fdRequired_ += dbr->fdRequired();
dbRotating_ = dbr.get();
db.reset(dynamic_cast<NodeStore::Database*>(dbr.release()));
// Check if DatabasePinned should be created
if (nscfg.exists("pinned_type"))
{
// DatabasePinned uses the same rotation backends as
// DatabaseRotating but adds a persistent backend for pinned nodes
// Create persistent backend for pinned data
Section pinnedConfig = nscfg;
pinnedConfig.set("type", *nscfg.get("pinned_type"));
if (auto pinnedPath = nscfg.get("pinned_path"))
pinnedConfig.set("path", *pinnedPath);
auto pinnedBackend = NodeStore::Manager::instance().make_Backend(
pinnedConfig,
megabytes(app_.config().getValueFor(
SizedItem::burstSize, std::nullopt)),
scheduler_,
app_.logs().journal(nodeStoreName_));
pinnedBackend->open();
// Create DatabasePinned with rotation backends + persistent
auto dbp = std::make_unique<NodeStore::DatabasePinnedImp>(
app_,
scheduler_,
readThreads,
std::move(writableBackend),
std::move(archiveBackend),
std::move(pinnedBackend),
nscfg,
app_.logs().journal(NodeStore::DatabasePinnedImp::JournalName));
fdRequired_ += dbp->fdRequired();
dbRotating_ =
dbp.get(); // DatabasePinned inherits from DatabaseRotating
db.reset(dynamic_cast<NodeStore::Database*>(dbp.release()));
}
else
{
// Create NodeStore with two backends to allow online deletion of
// data
auto dbr = std::make_unique<NodeStore::DatabaseRotatingImp>(
app_,
scheduler_,
readThreads,
std::move(writableBackend),
std::move(archiveBackend),
nscfg,
app_.logs().journal(nodeStoreName_));
fdRequired_ += dbr->fdRequired();
dbRotating_ = dbr.get();
db.reset(dynamic_cast<NodeStore::Database*>(dbr.release()));
}
}
else
{
@@ -265,6 +327,58 @@ SHAMapStoreImp::copyNode(std::uint64_t& nodeCount, SHAMapTreeNode const& node)
return true;
}
void
SHAMapStoreImp::loadPinnedRanges()
{
auto rangesStr = state_db_.getPinnedRanges();
if (rangesStr.empty())
return;
auto const& nscfg = app_.config().section(ConfigSection::nodeDatabase());
if (!nscfg.exists("pinned_type"))
{
JLOG(journal_.warn())
<< "Ignoring persisted pinned ranges because [node_db] "
"pinned_type is not configured: "
<< rangesStr;
return;
}
RangeSet<std::uint32_t> persistedRanges;
if (!from_string(persistedRanges, rangesStr))
{
Throw<std::runtime_error>(
"Failed to parse persisted pinned ranges in state.db: " +
rangesStr);
}
// state.db alone is not sufficient evidence that pinned history is
// available in the currently configured backend. Validate each interval
// boundary against the actual node store before advertising it as pinned.
auto const hasLedgerData = [this](std::uint32_t seq) {
auto const hash = app_.getRelationalDatabase().getHashByIndex(seq);
return hash.isNonZero() &&
static_cast<bool>(app_.getNodeStore().fetchNodeObject(hash, seq));
};
for (auto const& interval : persistedRanges)
{
if (!hasLedgerData(interval.lower()) ||
!hasLedgerData(interval.upper()))
{
Throw<std::runtime_error>(
"Persisted pinned interval " +
std::to_string(interval.lower()) + "-" +
std::to_string(interval.upper()) +
" is not present in the currently configured pinned store");
}
}
JLOG(journal_.info()) << "Loaded pinned ranges from database: "
<< rangesStr;
app_.getLedgerMaster().setPinnedLedgersRangeSet(persistedRanges);
}
void
SHAMapStoreImp::run()
{
@@ -486,17 +600,19 @@ SHAMapStoreImp::makeBackendRotating(std::string path)
Section section{app_.config().section(ConfigSection::nodeDatabase())};
boost::filesystem::path newPath;
if (path.size())
if (!path.empty())
{
newPath = path;
}
else
{
// Create new empty backend with unique path
boost::filesystem::path p = get(section, "path");
p /= dbPrefix_;
p += ".%%%%";
newPath = boost::filesystem::unique_path(p);
}
section.set("path", newPath.string());
auto backend{NodeStore::Manager::instance().make_Backend(
@@ -510,56 +626,43 @@ SHAMapStoreImp::makeBackendRotating(std::string path)
}
void
SHAMapStoreImp::clearSql(
SHAMapStoreImp::clearSqlRanges(
LedgerIndex lastRotated,
std::string const& TableName,
RangeSet<std::uint32_t> const& pinned,
std::string const& tableName,
std::function<std::optional<LedgerIndex>()> const& getMinSeq,
std::function<void(LedgerIndex)> const& deleteBeforeSeq)
std::function<void(RangeSet<std::uint32_t> const&)> const& deleteInRanges)
{
XRPL_ASSERT(
deleteInterval_,
"ripple::SHAMapStoreImp::clearSql : nonzero delete interval");
LedgerIndex min = std::numeric_limits<LedgerIndex>::max();
{
JLOG(journal_.trace())
<< "Begin: Look up lowest value of: " << TableName;
auto m = getMinSeq();
JLOG(journal_.trace()) << "End: Look up lowest value of: " << TableName;
if (!m)
return;
min = *m;
}
if (min > lastRotated || healthWait() == stopping)
auto m = getMinSeq();
if (!m)
return;
if (min == lastRotated)
if (healthWait() == stopping)
return;
// The pure interval-math is in detail::computeOnlineDeleteTargets so
// it can be unit-tested without spinning up SHAMapStoreImp. It
// returns the disjoint deletable intervals after subtracting pinned
// ranges from the base window [minSeq, lastRotated - 1].
auto const target =
detail::computeOnlineDeleteTargets(*m, lastRotated, pinned);
if (target.empty())
{
// Micro-optimization mainly to clarify logs
JLOG(journal_.trace()) << "Nothing to delete from " << TableName;
JLOG(journal_.trace()) << "Nothing to delete from " << tableName
<< " after considering pins.";
return;
}
JLOG(journal_.debug()) << "start deleting in: " << TableName << " from "
<< min << " to " << lastRotated;
while (min < lastRotated)
{
min = std::min(lastRotated, min + deleteBatch_);
JLOG(journal_.trace())
<< "Begin: Delete up to " << deleteBatch_
<< " rows with LedgerSeq < " << min << " from: " << TableName;
deleteBeforeSeq(min);
JLOG(journal_.trace())
<< "End: Delete up to " << deleteBatch_ << " rows with LedgerSeq < "
<< min << " from: " << TableName;
if (healthWait() == stopping)
return;
if (min < lastRotated)
std::this_thread::sleep_for(backOff_);
if (healthWait() == stopping)
return;
}
JLOG(journal_.debug()) << "finished deleting from: " << TableName;
JLOG(journal_.debug()) << "Pruning " << tableName
<< ". Target ranges: " << to_string(target);
// The deleteInRanges lambda will handle the actual database operations
deleteInRanges(target);
JLOG(journal_.debug()) << "finished deleting from: " << tableName;
}
void
@@ -581,6 +684,14 @@ SHAMapStoreImp::freshenCaches()
void
SHAMapStoreImp::clearPrior(LedgerIndex lastRotated)
{
// Get pinned ranges to exclude from deletion
auto pinnedRanges = app_.getLedgerMaster().getPinnedLedgersRangeSet();
if (!pinnedRanges.empty())
{
JLOG(journal_.info())
<< "Online delete with pinned ranges: " << to_string(pinnedRanges);
}
// Do not allow ledgers to be acquired from the network
// that are about to be deleted.
minimumOnline_ = lastRotated + 1;
@@ -600,36 +711,96 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated)
if (app_.config().useTxTables())
{
clearSql(
clearSqlRanges(
lastRotated,
pinnedRanges,
"Transactions",
[&db]() -> std::optional<LedgerIndex> {
return db->getTransactionsMinLedgerSeq();
},
[&db](LedgerIndex min) -> void {
db->deleteTransactionsBeforeLedgerSeq(min);
[this, &db](RangeSet<std::uint32_t> const& ranges) -> void {
for (auto const& interval : ranges)
{
// Simple delete loop with LIMIT
while (true)
{
if (healthWait() == stopping)
return;
auto deleted = db->deleteTransactionsInRange(
interval.lower(), interval.upper(), deleteBatch_);
if (deleted == 0)
break;
JLOG(journal_.trace()) << "Deleted " << deleted
<< " rows from Transactions";
std::this_thread::sleep_for(backOff_);
}
}
});
if (healthWait() == stopping)
return;
clearSql(
clearSqlRanges(
lastRotated,
pinnedRanges,
"AccountTransactions",
[&db]() -> std::optional<LedgerIndex> {
return db->getAccountTransactionsMinLedgerSeq();
},
[&db](LedgerIndex min) -> void {
db->deleteAccountTransactionsBeforeLedgerSeq(min);
[this, &db](RangeSet<std::uint32_t> const& ranges) -> void {
for (auto const& interval : ranges)
{
// Simple delete loop with LIMIT
while (true)
{
if (healthWait() == stopping)
return;
auto deleted = db->deleteAccountTransactionsInRange(
interval.lower(), interval.upper(), deleteBatch_);
if (deleted == 0)
break;
JLOG(journal_.trace())
<< "Deleted " << deleted
<< " rows from AccountTransactions";
std::this_thread::sleep_for(backOff_);
}
}
});
if (healthWait() == stopping)
return;
}
clearSql(
clearSqlRanges(
lastRotated,
pinnedRanges,
"Ledgers",
[db]() -> std::optional<LedgerIndex> { return db->getMinLedgerSeq(); },
[db](LedgerIndex min) -> void { db->deleteBeforeLedgerSeq(min); });
[this, db](RangeSet<std::uint32_t> const& ranges) -> void {
for (auto const& interval : ranges)
{
// Simple delete loop with LIMIT
while (true)
{
if (healthWait() == stopping)
return;
auto deleted = db->deleteLedgersInRange(
interval.lower(), interval.upper(), deleteBatch_);
if (deleted == 0)
break;
JLOG(journal_.trace())
<< "Deleted " << deleted << " rows from Ledgers";
std::this_thread::sleep_for(backOff_);
}
}
});
if (healthWait() == stopping)
return;
}

View File

@@ -24,8 +24,10 @@
#include <xrpld/app/misc/SHAMapStore.h>
#include <xrpld/app/rdb/RelationalDatabase.h>
#include <xrpld/app/rdb/State.h>
#include <xrpld/app/rdb/backend/SQLiteDatabase.h>
#include <xrpld/core/DatabaseCon.h>
#include <xrpld/nodestore/DatabaseRotating.h>
#include <xrpl/basics/RangeSet.h>
#include <xrpld/nodestore/Scheduler.h>
#include <atomic>
@@ -67,6 +69,10 @@ private:
setState(SavedState const& state);
void
setLastRotated(LedgerIndex seq);
std::string
getPinnedRanges();
void
setPinnedRanges(std::string const& ranges);
};
Application& app_;
@@ -132,6 +138,12 @@ public:
: fetch_depth;
}
bool
isOnlineDeleteEnabled() const
{
return deleteInterval_ > 0;
}
std::unique_ptr<NodeStore::Database>
makeNodeStore(int readThreads) override;
@@ -168,6 +180,14 @@ public:
void
onLedgerClosed(std::shared_ptr<Ledger const> const& ledger) override;
void
setPinnedRanges(RangeSet<std::uint32_t> const& ranges) override
{
// Save pinned ranges to database for persistence
std::string rangesStr = to_string(ranges);
state_db_.setPinnedRanges(rangesStr);
}
void
rendezvous() const override;
int
@@ -210,11 +230,14 @@ private:
* Call with mutex object unlocked.
*/
void
clearSql(
clearSqlRanges(
LedgerIndex lastRotated,
std::string const& TableName,
RangeSet<std::uint32_t> const& pinned,
std::string const& tableName,
std::function<std::optional<LedgerIndex>()> const& getMinSeq,
std::function<void(LedgerIndex)> const& deleteBeforeSeq);
std::function<void(RangeSet<std::uint32_t> const&)> const&
deleteInRanges);
void
clearCaches(LedgerIndex validatedSeq);
void
@@ -233,12 +256,24 @@ private:
[[nodiscard]] HealthResult
healthWait();
void
loadPinnedRanges();
public:
void
start() override
{
// Always load pinned ranges on startup
loadPinnedRanges();
// NOTE: Startup cleanup of unpinned ledgers was removed - it caused
// SQLite lock contention and confusing pauses. Database will shrink
// incrementally during rotation. Custom tools will provide pruning
// management for specialized needs.
if (deleteInterval_)
{
thread_ = std::thread(&SHAMapStoreImp::run, this);
}
}
void

View File

@@ -0,0 +1,77 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_APP_MISC_DETAIL_ONLINE_DELETE_RANGES_H_INCLUDED
#define RIPPLE_APP_MISC_DETAIL_ONLINE_DELETE_RANGES_H_INCLUDED
#include <xrpl/basics/RangeSet.h>
#include <cstdint>
namespace ripple {
namespace detail {
/** Compute the actual SQL-deletion target windows for an online-delete
pass, given a base [minSeq, lastRotated - 1] window and the pinned
ranges that must be preserved.
The base window is the half-open span the caller would normally
delete (everything older than `lastRotated`, but no older than the
minimum sequence currently stored in the table being cleared).
Pinned ranges (catalogue history that must survive rotation) are
subtracted from the base — the result may be empty (everything is
pinned), one interval (no overlap, or pinned only trims an edge),
or multiple disjoint intervals (pins cut a hole in the base).
Pure function so the math is unit-testable without standing up
SHAMapStoreImp. The actual side-effecting deletions (driven by
a per-table lambda in clearSqlRanges) operate on whatever interval
set this helper returns.
@param minSeq The smallest sequence currently stored in the
table being cleared. If >= lastRotated, the
base window is empty and we return {}.
@param lastRotated The exclusive upper bound — sequences from
this seq forward are kept.
@param pinned Pinned-ledger ranges to preserve.
@return The set of intervals to actually delete. Empty if either
the base window is empty or pinned ranges fully cover it.
*/
inline RangeSet<std::uint32_t>
computeOnlineDeleteTargets(
std::uint32_t minSeq,
std::uint32_t lastRotated,
RangeSet<std::uint32_t> const& pinned)
{
// Empty base window: nothing newer than minSeq qualifies for
// deletion.
if (minSeq >= lastRotated)
return {};
RangeSet<std::uint32_t> target;
target.insert(range(minSeq, lastRotated - 1));
target -= pinned;
return target;
}
} // namespace detail
} // namespace ripple
#endif

View File

@@ -92,6 +92,23 @@ setSavedState(soci::session& session, SavedState const& state);
void
setLastRotated(soci::session& session, LedgerIndex seq);
/**
* @brief getPinnedRanges Returns the serialized pinned ledger ranges.
* @param session Session with the database.
* @return Serialized range set string (e.g.,
* "1000000-2000000,3000000-3500000").
*/
std::string
getPinnedRanges(soci::session& session);
/**
* @brief setPinnedRanges Updates the pinned ledger ranges.
* @param session Session with the database.
* @param ranges Serialized range set string to save.
*/
void
setPinnedRanges(soci::session& session, std::string const& ranges);
} // namespace ripple
#endif

View File

@@ -8,6 +8,7 @@
#include <xrpld/app/ledger/TransactionMaster.h>
#include <xrpld/app/misc/detail/AccountTxPaging.h>
#include <xrpld/app/rdb/backend/SQLiteDatabase.h>
#include <xrpl/basics/RangeSet.h>
#include <algorithm>
#include <map>
#include <mutex>
@@ -566,6 +567,120 @@ public:
// No-op for in-memory database
}
std::size_t
deleteLedgersInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit = std::nullopt) override
{
XRPL_ASSERT(
minSeq <= maxSeq,
"RWDBDatabase::deleteLedgersInRange : minSeq <= maxSeq");
XRPL_ASSERT(
!rowLimit || *rowLimit > 0,
"RWDBDatabase::deleteLedgersInRange : rowLimit must be positive");
std::unique_lock<std::shared_mutex> lock(mutex_);
auto it = ledgers_.lower_bound(minSeq);
auto end = ledgers_.upper_bound(maxSeq);
std::size_t count = 0;
while (it != end)
{
if (rowLimit && count >= *rowLimit)
break;
ledgerHashToSeq_.erase(it->second.info.hash);
it = ledgers_.erase(it);
++count;
}
return count;
}
std::size_t
deleteTransactionsInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit = std::nullopt) override
{
XRPL_ASSERT(
minSeq <= maxSeq,
"RWDBDatabase::deleteTransactionsInRange : minSeq <= maxSeq");
XRPL_ASSERT(
!rowLimit || *rowLimit > 0,
"RWDBDatabase::deleteTransactionsInRange : rowLimit must be "
"positive");
if (!useTxTables_)
return 0;
std::unique_lock<std::shared_mutex> lock(mutex_);
auto it = ledgers_.lower_bound(minSeq);
auto end = ledgers_.upper_bound(maxSeq);
// Erase from both the per-ledger map and the global index.
// Important: erase from per-ledger map as we go so that partial
// deletes (hitting limit) make forward progress on the next call.
std::size_t count = 0;
while (it != end)
{
auto txIt = it->second.transactions.begin();
while (txIt != it->second.transactions.end())
{
if (rowLimit && count >= *rowLimit)
return count;
transactionMap_.erase(txIt->first);
txIt = it->second.transactions.erase(txIt);
++count;
}
++it;
}
return count;
}
std::size_t
deleteAccountTransactionsInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit = std::nullopt) override
{
XRPL_ASSERT(
minSeq <= maxSeq,
"RWDBDatabase::deleteAccountTransactionsInRange : minSeq <= "
"maxSeq");
XRPL_ASSERT(
!rowLimit || *rowLimit > 0,
"RWDBDatabase::deleteAccountTransactionsInRange : rowLimit must be "
"positive");
if (!useTxTables_)
return 0;
std::unique_lock<std::shared_mutex> lock(mutex_);
std::size_t count = 0;
for (auto& [_, accountData] : accountTxMap_)
{
auto txIt = accountData.ledgerTxMap.lower_bound(minSeq);
auto txEnd = accountData.ledgerTxMap.upper_bound(maxSeq);
while (txIt != txEnd)
{
std::size_t toDelete = txIt->second.size();
if (rowLimit && count + toDelete > *rowLimit)
{
// Partial deletion from this ledger
toDelete = *rowLimit - count;
txIt->second.resize(txIt->second.size() - toDelete);
count += toDelete;
return count;
}
count += toDelete;
txIt = accountData.ledgerTxMap.erase(txIt);
}
}
return count;
}
~RWDBDatabase()
{
// Regular maps can use standard clear

View File

@@ -21,6 +21,7 @@
#define RIPPLE_APP_RDB_BACKEND_SQLITEDATABASE_H_INCLUDED
#include <xrpld/app/rdb/RelationalDatabase.h>
#include <xrpl/basics/RangeSet.h>
namespace ripple {
@@ -295,6 +296,47 @@ public:
virtual uint32_t
getKBUsedTransaction() = 0;
/**
* @brief deleteLedgersInRange Deletes ledgers within the specified range.
* @param minSeq Minimum ledger sequence (inclusive).
* @param maxSeq Maximum ledger sequence (inclusive).
* @param rowLimit Optional limit on number of rows to delete.
* @return Number of rows deleted.
*/
virtual std::size_t
deleteLedgersInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit = std::nullopt) = 0;
/**
* @brief deleteTransactionsInRange Deletes transactions within the
* specified ledger sequence range.
* @param minSeq Minimum ledger sequence (inclusive).
* @param maxSeq Maximum ledger sequence (inclusive).
* @param rowLimit Optional limit on number of rows to delete.
* @return Number of rows deleted.
*/
virtual std::size_t
deleteTransactionsInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit = std::nullopt) = 0;
/**
* @brief deleteAccountTransactionsInRange Deletes account transactions
* within the specified ledger sequence range.
* @param minSeq Minimum ledger sequence (inclusive).
* @param maxSeq Maximum ledger sequence (inclusive).
* @param rowLimit Optional limit on number of rows to delete.
* @return Number of rows deleted.
*/
virtual std::size_t
deleteAccountTransactionsInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit = std::nullopt) = 0;
/**
* @brief Closes the ledger database
*/

View File

@@ -157,6 +157,45 @@ deleteBeforeLedgerSeq(
<< ledgerSeq << ";";
}
std::size_t
deleteRange(
soci::session& session,
TableType type,
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit)
{
XRPL_ASSERT(
minSeq <= maxSeq, "ripple::detail::deleteRange : minSeq <= maxSeq");
XRPL_ASSERT(
!rowLimit || *rowLimit > 0,
"ripple::detail::deleteRange : rowLimit must be positive");
std::string sql;
if (rowLimit)
{
// SQLite doesn't support DELETE...LIMIT unless compiled with
// SQLITE_ENABLE_UPDATE_DELETE_LIMIT. Use subquery workaround.
sql = "DELETE FROM " + to_string(type) +
" WHERE rowid IN (SELECT rowid FROM " + to_string(type) +
" WHERE LedgerSeq >= " + std::to_string(minSeq) +
" AND LedgerSeq <= " + std::to_string(maxSeq) +
" ORDER BY LedgerSeq ASC LIMIT " + std::to_string(*rowLimit) + ");";
}
else
{
sql = "DELETE FROM " + to_string(type) +
" WHERE LedgerSeq >= " + std::to_string(minSeq) +
" AND LedgerSeq <= " + std::to_string(maxSeq) + ";";
}
session << sql;
// Get the number of rows deleted
long changes = 0;
session << "SELECT changes();", soci::into(changes);
return static_cast<std::size_t>(changes);
}
std::size_t
getRows(soci::session& session, TableType type)
{
@@ -224,8 +263,11 @@ saveValidatedLedger(
Serializer s(128);
s.add32(HashPrefix::ledgerMaster);
addRaw(ledger->info(), s);
// Use pinnedLEDGER type for pinned ledgers, hotLEDGER for others
auto ledgerType =
app.getLedgerMaster().isPinned(seq) ? pinnedLEDGER : hotLEDGER;
app.getNodeStore().store(
hotLEDGER, std::move(s.modData()), ledger->info().hash, seq);
ledgerType, std::move(s.modData()), ledger->info().hash, seq);
}
std::shared_ptr<AcceptedLedger> aLedger;
@@ -235,8 +277,13 @@ saveValidatedLedger(
if (!aLedger)
{
aLedger = std::make_shared<AcceptedLedger>(ledger, app);
app.getAcceptedLedgerCache().canonicalize_replace_client(
ledger->info().hash, aLedger);
// Only cache if the ledger is NOT in the pinned range
if (!app.getLedgerMaster().isPinned(ledger->info().seq))
{
app.getAcceptedLedgerCache().canonicalize_replace_client(
ledger->info().hash, aLedger);
}
}
}
catch (std::exception const&)

View File

@@ -102,6 +102,25 @@ deleteBeforeLedgerSeq(
TableType type,
LedgerIndex ledgerSeq);
/**
* @brief deleteRange Deletes entries in given table
* for the ledgers within the specified range (inclusive)
* with an optional limit on the number of rows to delete.
* @param session Session with database.
* @param type Table ID from which entries will be deleted.
* @param minSeq Minimum ledger sequence (inclusive).
* @param maxSeq Maximum ledger sequence (inclusive).
* @param rowLimit Optional limit on number of rows to delete.
* @return Number of rows actually deleted.
*/
std::size_t
deleteRange(
soci::session& session,
TableType type,
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit);
/**
* @brief getRows Returns number of rows in given table.
* @param session Session with database.

View File

@@ -83,6 +83,24 @@ public:
void
deleteAccountTransactionsBeforeLedgerSeq(LedgerIndex ledgerSeq) override;
std::size_t
deleteLedgersInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit = std::nullopt) override;
std::size_t
deleteTransactionsInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit = std::nullopt) override;
std::size_t
deleteAccountTransactionsInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit = std::nullopt) override;
std::size_t
getTransactionCount() override;
@@ -949,6 +967,48 @@ SQLiteDatabaseImp::closeLedgerDB()
lgrdb_.reset();
}
std::size_t
SQLiteDatabaseImp::deleteLedgersInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit)
{
if (!existsLedger())
return 0;
auto db = checkoutLedger();
return detail::deleteRange(
*db, detail::TableType::Ledgers, minSeq, maxSeq, rowLimit);
}
std::size_t
SQLiteDatabaseImp::deleteTransactionsInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit)
{
if (!existsTransaction())
return 0;
auto db = checkoutTransaction();
return detail::deleteRange(
*db, detail::TableType::Transactions, minSeq, maxSeq, rowLimit);
}
std::size_t
SQLiteDatabaseImp::deleteAccountTransactionsInRange(
LedgerIndex minSeq,
LedgerIndex maxSeq,
std::optional<std::size_t> rowLimit)
{
if (!existsTransaction())
return 0;
auto db = checkoutTransaction();
return detail::deleteRange(
*db, detail::TableType::AccountTransactions, minSeq, maxSeq, rowLimit);
}
void
SQLiteDatabaseImp::closeTransactionDB()
{

View File

@@ -43,6 +43,11 @@ initStateDB(
" CanDeleteSeq INTEGER"
");";
session << "CREATE TABLE IF NOT EXISTS PinnedLedgers ("
" Key INTEGER PRIMARY KEY,"
" RangeSet TEXT"
");";
std::int64_t count = 0;
{
// SOCI requires boost::optional (not std::optional) as the parameter.
@@ -75,6 +80,17 @@ initStateDB(
{
session << "INSERT INTO CanDelete VALUES (1, 0);";
}
// Initialize PinnedLedgers table with empty range if not exists
{
boost::optional<std::int64_t> countO;
session << "SELECT COUNT(Key) FROM PinnedLedgers WHERE Key = 1;",
soci::into(countO);
if (!countO || *countO == 0)
{
session << "INSERT INTO PinnedLedgers VALUES (1, '');";
}
}
}
LedgerIndex
@@ -127,4 +143,23 @@ setLastRotated(soci::session& session, LedgerIndex seq)
soci::use(seq);
}
std::string
getPinnedRanges(soci::session& session)
{
boost::optional<std::string> rangeStr;
session << "SELECT RangeSet FROM PinnedLedgers WHERE Key = 1;",
soci::into(rangeStr);
if (rangeStr)
return *rangeStr;
return "";
}
void
setPinnedRanges(soci::session& session, std::string const& ranges)
{
session << "UPDATE PinnedLedgers SET RangeSet = :ranges WHERE Key = 1;",
soci::use(ranges);
}
} // namespace ripple

View File

@@ -1077,6 +1077,57 @@ Config::loadFromString(std::string const& fileContents)
}
}
}
// DatabasePinned validation (applies to all modes including standalone)
auto db_section = section(ConfigSection::nodeDatabase());
if (db_section.exists("pinned_type"))
{
// Ensure base type is specified
if (!db_section.exists("type"))
{
Throw<std::runtime_error>(
"type must be specified when using pinned_type");
}
// Ensure pinned_path is specified for filesystem-backed types.
// In-memory backends (rwdb) don't need a path.
auto pinnedTypeForPath = get(db_section, "pinned_type", "");
boost::algorithm::to_lower(pinnedTypeForPath);
bool const needsPath =
pinnedTypeForPath != "rwdb" && pinnedTypeForPath != "memory";
if (needsPath && !db_section.exists("pinned_path"))
{
Throw<std::runtime_error>(
"pinned_path is required when pinned_type is set");
}
// Ensure online_delete is set (pinning without deletion is
// redundant)
if (auto delete_interval = get(db_section, "online_delete", 0);
delete_interval == 0)
{
Throw<std::runtime_error>(
"pinned_type requires online_delete (pinning without "
"deletion is redundant)");
}
// In non-standalone mode, require a durable backend.
// Standalone mode is unrestricted (tests use rwdb, etc).
if (!RUN_STANDALONE)
{
auto pinnedType = get(db_section, "pinned_type", "");
boost::algorithm::to_lower(pinnedType);
if (pinnedType != "nudb" && pinnedType != "rocksdb")
{
Throw<std::runtime_error>(
"pinned_type must be a durable backend (NuDB or "
"RocksDB), got '" +
pinnedType +
"'. Non-durable backends lose pinned data on "
"restart.");
}
}
}
}
boost::filesystem::path

View File

@@ -34,9 +34,43 @@ enum NodeObjectType : std::uint32_t {
hotLEDGER = 1,
hotACCOUNT_NODE = 3,
hotTRANSACTION_NODE = 4,
hotDUMMY = 512 // an invalid or missing object
hotDUMMY = 512, // an invalid or missing object
// Uncached variants - these bypass the cache when stored.
// These are routing-only values that MUST be reduced to their hot
// equivalents before serialization (on-disk format is a single byte).
pinnedACCOUNT_NODE = 1003,
pinnedTRANSACTION_NODE = 1004,
pinnedLEDGER = 1005
};
/** Returns true if the type is a pinned (routing-only) variant. */
inline bool
isPinnedType(NodeObjectType type)
{
return type == pinnedACCOUNT_NODE || type == pinnedTRANSACTION_NODE ||
type == pinnedLEDGER;
}
/** Map pinned types back to their serializable hot equivalents.
Returns the type unchanged if it is not a pinned variant.
*/
inline NodeObjectType
toHotType(NodeObjectType type)
{
switch (type)
{
case pinnedACCOUNT_NODE:
return hotACCOUNT_NODE;
case pinnedTRANSACTION_NODE:
return hotTRANSACTION_NODE;
case pinnedLEDGER:
return hotLEDGER;
default:
return type;
}
}
/** A simple object that the Ledger uses to store entries.
NodeObjects are comprised of a type, a hash, and a blob.
They can be uniquely identified by the hash, which is a half-SHA512 of

View File

@@ -32,9 +32,16 @@ DatabaseNodeImp::store(
{
storeStats(1, data.size());
// Pinned types bypass the cache and get reduced to hot equivalents
bool skipCache = isPinnedType(type);
if (skipCache)
type = toHotType(type);
auto obj = NodeObject::createObject(type, std::move(data), hash);
backend_->store(obj);
if (cache_)
// Only add to cache if it's not an uncached type
if (cache_ && !skipCache)
{
// After the store, replace a negative cache entry if there is one
cache_->canonicalize(

View File

@@ -26,7 +26,6 @@
namespace ripple {
namespace NodeStore {
class DatabaseNodeImp : public Database
{
public:

View File

@@ -0,0 +1,272 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <xrpld/app/ledger/Ledger.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/nodestore/detail/DatabasePinnedImp.h>
#include <atomic>
#include <iostream>
namespace ripple {
namespace NodeStore {
DatabasePinnedImp::DatabasePinnedImp(
Application& app,
Scheduler& scheduler,
int readThreads,
std::shared_ptr<Backend> writableBackend,
std::shared_ptr<Backend> archiveBackend,
std::shared_ptr<Backend> persistent,
Section const& config,
beast::Journal j)
: DatabaseRotating(scheduler, readThreads, config, j)
, app_(app)
, rotating_(
app,
scheduler,
readThreads,
std::move(writableBackend),
std::move(archiveBackend),
config,
j)
, persistent_(std::move(persistent))
{
// Update fdRequired to include all backends
fdRequired_ = rotating_.fdRequired();
if (persistent_)
fdRequired_ += persistent_->fdRequired();
}
void
DatabasePinnedImp::store(
NodeObjectType type,
Blob&& data,
uint256 const& hash,
std::uint32_t ledgerSeq)
{
// Route based on type
if (isPinnedType(type))
{
// Reduce to serializable hot type before storage
type = toHotType(type);
// Pinned types go to persistent storage
auto count = ++pinnedStoreCount_;
if (count % 1000 == 0)
{
JLOG(j_.trace())
<< "Pinned stores: " << count << " (type=" << type << ")";
}
auto nObj = NodeObject::createObject(type, std::move(data), hash);
persistent_->store(nObj);
storeStats(1, nObj->getData().size());
}
else
{
// Hot types go through rotating storage
auto count = ++hotStoreCount_;
if (count % 10000 == 0)
{
JLOG(j_.trace())
<< "Hot stores: " << count << " (type=" << type << ")";
}
rotating_.store(type, std::move(data), hash, ledgerSeq);
}
}
std::shared_ptr<NodeObject>
DatabasePinnedImp::fetchNodeObject(
uint256 const& hash,
std::uint32_t ledgerSeq,
FetchReport& fetchReport,
bool duplicate)
{
// Optimization: Use ledgerSeq to predict backend order
// Most callers (13/18) provide reliable ledgerSeq: SHAMap node fetches,
// ledger headers, tx metadata, ledger copying operations. For these cases,
// checking the likely backend first reduces unnecessary lookups.
// When ledgerSeq=0 (unknown), falls back to default order.
// IMPORTANT: Always checks both backends for correctness.
if (ledgerSeq != 0)
{
refreshPinnedRangesCache();
if (likelyPinned(ledgerSeq))
{
// Try persistent backend first (pinned data)
if (auto obj = tryPersistent(hash, fetchReport))
return obj;
// Fallback: try rotating backends
return rotating_.fetchNodeObject(
hash, ledgerSeq, fetchReport, duplicate);
}
else
{
// Not pinned: fall through to default path below
}
}
// Default path: try rotating backends first (hot data)
if (auto obj =
rotating_.fetchNodeObject(hash, ledgerSeq, fetchReport, duplicate))
return obj;
// Fallback: try persistent backend (pinned data)
return tryPersistent(hash, fetchReport);
}
void
DatabasePinnedImp::rotate(
std::unique_ptr<NodeStore::Backend>&& newBackend,
std::function<void(
std::string const& writableName,
std::string const& archiveName)> const& f)
{
// Delegate to the inner rotating database. Pinned data lives in
// persistent_ and is never touched by rotation — only hot data
// in rotating_ participates in the rotate/archive/delete cycle.
rotating_.rotate(std::move(newBackend), f);
}
std::string
DatabasePinnedImp::getName() const
{
return "Pinned:" + rotating_.getName() + "+" + persistent_->getName();
}
std::int32_t
DatabasePinnedImp::getWriteLoad() const
{
return rotating_.getWriteLoad() + persistent_->getWriteLoad();
}
void
DatabasePinnedImp::importDatabase(Database& source)
{
// Import is not supported with DatabasePinned. The dual-backend
// routing relies on knowing which ledger ranges are pinned, which
// is only possible with catalogue packs where the ranges are known
// quantities. A generic import would need to scan for ledger
// headers and walk SHAMap trees to determine routing — not
// impossible, but not currently implemented.
Throw<std::runtime_error>(
"--import is not supported when [node_db] pinned_type is configured. "
"DatabasePinned uses separate rotating and persistent backends, and a "
"generic node import cannot determine pinned ledger ranges or update "
"state.db pinned range metadata. Use catalogue_load for pinned "
"history import, or run --import with a non-pinned node store.");
}
bool
DatabasePinnedImp::isSameDB(std::uint32_t s1, std::uint32_t s2)
{
// Used by async read threads to determine if a fetched object can
// be reused for requests at different sequences. Since rotating and
// persistent are both part of the same logical database (same hash
// space), objects are always reusable regardless of which backend
// they came from.
return true;
}
void
DatabasePinnedImp::sync()
{
rotating_.sync();
persistent_->sync();
}
bool
DatabasePinnedImp::storeLedger(std::shared_ptr<Ledger const> const& srcLedger)
{
// Store to persistent since ledger headers should be pinned
return Database::storeLedger(*srcLedger, persistent_);
}
void
DatabasePinnedImp::sweep()
{
// Delegate to rotating - persistent has no cache
rotating_.sweep();
}
void
DatabasePinnedImp::for_each(std::function<void(std::shared_ptr<NodeObject>)> f)
{
// Visit both rotating and persistent backends
rotating_.for_each(f);
persistent_->for_each(f);
}
void
DatabasePinnedImp::refreshPinnedRangesCache() const
{
using namespace std::chrono;
auto now = steady_clock::now();
auto lastRefreshTime =
steady_clock::time_point(steady_clock::duration(lastRefresh_.load()));
// Only refresh if cache is stale (>1 second old)
if (now - lastRefreshTime < seconds(1))
return;
// Create new RangeSet snapshot from LedgerMaster
auto newRanges = std::make_shared<RangeSet<std::uint32_t>>(
app_.getLedgerMaster().getPinnedLedgersRangeSet());
// Atomic swap - all concurrent readers see old or new, never torn state
std::atomic_store(&cachedPinnedRanges_, newRanges);
lastRefresh_.store(now.time_since_epoch().count());
}
bool
DatabasePinnedImp::likelyPinned(std::uint32_t ledgerSeq) const
{
auto ranges = std::atomic_load(&cachedPinnedRanges_);
return ranges && boost::icl::contains(*ranges, ledgerSeq);
}
std::shared_ptr<NodeObject>
DatabasePinnedImp::tryPersistent(uint256 const& hash, FetchReport& fetchReport)
{
std::shared_ptr<NodeObject> nodeObject;
Status status;
try
{
status = persistent_->fetch(hash.data(), &nodeObject);
}
catch (std::exception const& e)
{
JLOG(j_.fatal()) << "Exception fetching from persistent: " << e.what();
Rethrow();
}
if (status == ok && nodeObject)
{
fetchReport.wasFound = true;
// Note: We do NOT copy pinned data to rotating storage even if
// duplicate=true. Pinned data stays in persistent storage.
}
return nodeObject;
}
} // namespace NodeStore
} // namespace ripple

View File

@@ -0,0 +1,154 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_NODESTORE_DATABASEPINNEDIMP_H_INCLUDED
#define RIPPLE_NODESTORE_DATABASEPINNEDIMP_H_INCLUDED
#include <xrpld/app/main/Application.h>
#include <xrpld/nodestore/Backend.h>
#include <xrpld/nodestore/DatabaseRotating.h>
#include <xrpld/nodestore/NodeObject.h>
#include <xrpld/nodestore/detail/DatabaseRotatingImp.h>
#include <xrpl/basics/RangeSet.h>
#include <atomic>
#include <chrono>
#include <memory>
namespace ripple {
namespace NodeStore {
/**
* DatabasePinned routes nodes to either rotating memory storage (via
* DatabaseRotatingImp) or persistent storage (NuDB) based on NodeObjectType
* values.
*
* Uses composition: DatabaseRotatingImp handles all rotation logic for hot
* nodes, while this class adds a persistent layer for pinned nodes.
*
* Pinned types (pinnedACCOUNT_NODE, pinnedTRANSACTION_NODE, pinnedLEDGER) go to
* persistent storage and stay forever. Hot types go through the normal
* rotation.
*/
class DatabasePinnedImp : public DatabaseRotating
{
private:
Application& app_; // For accessing LedgerMaster's pinned ranges
DatabaseRotatingImp rotating_; // Handles rotation for hot nodes
std::shared_ptr<Backend> persistent_; // NuDB for pinned nodes
// Debug counters for store routing
std::atomic<uint64_t> pinnedStoreCount_{0};
std::atomic<uint64_t> hotStoreCount_{0};
// Lock-free cached pinned ranges for backend selection hint
// Use std::atomic_load/store free functions for thread-safe access
mutable std::shared_ptr<RangeSet<std::uint32_t>> cachedPinnedRanges_;
mutable std::atomic<std::chrono::steady_clock::time_point::rep>
lastRefresh_{0};
public:
static constexpr auto JournalName = "DatabasePinned";
DatabasePinnedImp(
Application& app,
Scheduler& scheduler,
int readThreads,
std::shared_ptr<Backend> writableBackend,
std::shared_ptr<Backend> archiveBackend,
std::shared_ptr<Backend> persistent,
Section const& config,
beast::Journal j);
~DatabasePinnedImp() override
{
stop();
}
// Total count of store() calls routed to the persistent backend
// (pinned types). Exposed for tests and diagnostics.
uint64_t
pinnedStoreCount() const
{
return pinnedStoreCount_.load(std::memory_order_relaxed);
}
// Total count of store() calls routed to the rotating backend
// (hot types). Exposed for tests and diagnostics.
uint64_t
hotStoreCount() const
{
return hotStoreCount_.load(std::memory_order_relaxed);
}
// DatabaseRotating interface - delegates to rotating_
void
rotate(
std::unique_ptr<NodeStore::Backend>&& newBackend,
std::function<void(
std::string const& writableName,
std::string const& archiveName)> const& f) override;
// Database interface implementation
std::string
getName() const override;
std::int32_t
getWriteLoad() const override;
void
importDatabase(Database& source) override;
bool
isSameDB(std::uint32_t s1, std::uint32_t s2) override;
void
store(
NodeObjectType type,
Blob&& data,
uint256 const& hash,
std::uint32_t ledgerSeq) override;
void
sync() override;
bool
storeLedger(std::shared_ptr<Ledger const> const& srcLedger) override;
void
sweep() override;
private:
std::shared_ptr<NodeObject>
fetchNodeObject(
uint256 const& hash,
std::uint32_t ledgerSeq,
FetchReport& fetchReport,
bool duplicate) override;
// Helper methods for backend selection optimization
void
refreshPinnedRangesCache() const;
bool
likelyPinned(std::uint32_t ledgerSeq) const;
std::shared_ptr<NodeObject>
tryPersistent(uint256 const& hash, FetchReport& fetchReport);
void
for_each(std::function<void(std::shared_ptr<NodeObject>)> f) override;
};
} // namespace NodeStore
} // namespace ripple
#endif

View File

@@ -61,57 +61,7 @@ DatabaseRotatingImp::rotate(
{
std::lock_guard lock(mutex_);
// Before rotating, ensure all pinned ledgers are in the writable
// backend
JLOG(j_.info())
<< "Ensuring pinned ledgers are preserved before backend rotation";
// Use a lambda to handle the preservation of pinned ledgers
auto ensurePinnedLedgersInWritable = [this]() {
// Get list of pinned ledgers
auto pinnedLedgers =
app_.getLedgerMaster().getPinnedLedgersRangeSet();
for (auto const& range : pinnedLedgers)
{
for (auto seq = range.lower(); seq <= range.upper(); ++seq)
{
uint256 hash = app_.getLedgerMaster().getHashBySeq(seq);
if (hash.isZero())
continue;
// Try to load the ledger
auto ledger = app_.getLedgerMaster().getLedgerByHash(hash);
if (ledger && ledger->isImmutable())
{
// If we have the ledger, store it in the writable
// backend
JLOG(j_.debug()) << "Ensuring pinned ledger " << seq
<< " is in writable backend";
// TQ: TODO: check this
Database::storeLedger(*ledger, writableBackend_);
}
else
{
// If we don't have the ledger in memory, try to fetch
// its objects directly
JLOG(j_.debug())
<< "Attempting to copy pinned ledger " << seq
<< " header to writable backend";
std::shared_ptr<NodeObject> headerObj;
Status status =
archiveBackend_->fetch(hash.data(), &headerObj);
if (status == ok && headerObj)
writableBackend_->store(headerObj);
}
}
}
};
// Execute the lambda
ensurePinnedLedgersInWritable();
// Now it's safe to mark the archive backend for deletion
// Mark the archive backend for deletion
archiveBackend_->setDeletePath();
oldArchiveBackend = std::move(archiveBackend_);

View File

@@ -29,6 +29,9 @@ namespace NodeStore {
class DatabaseRotatingImp : public DatabaseRotating
{
friend class DatabasePinnedImp; // Allow DatabasePinnedImp to access
// private members
public:
DatabaseRotatingImp() = delete;
DatabaseRotatingImp(DatabaseRotatingImp const&) = delete;

View File

@@ -0,0 +1,363 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2012-2014 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <xrpld/rpc/detail/CatalogueStream.h>
#include <xrpl/basics/Slice.h>
#include <chrono>
#include <limits>
#include <mutex>
#include <unordered_map>
#include <vector>
namespace ripple {
namespace RPC {
namespace {
std::size_t
serializeSHAMapToStream(
SHAMap const& shaMap,
CatalogueOutputStream& stream,
SHAMapNodeType nodeType,
std::optional<std::reference_wrapper<SHAMap const>> baseSHAMap =
std::nullopt)
{
using StreamState =
std::pair<std::uint64_t, std::chrono::steady_clock::time_point>;
static std::mutex streamStateMutex;
static std::unordered_map<void*, StreamState> streamBytesWritten;
constexpr std::uint64_t flushThreshold = 256 * 1024 * 1024;
std::uint64_t localBytesWritten = 0;
auto tryFlush = [](auto& s) {
if constexpr (requires(decltype(s) str) { str.flush(); })
{
s.flush();
}
};
{
std::lock_guard<std::mutex> lock(streamStateMutex);
auto const now = std::chrono::steady_clock::now();
if (auto const it =
streamBytesWritten.find(static_cast<void*>(&stream));
it != streamBytesWritten.end())
{
localBytesWritten = it->second.first;
}
for (auto it = streamBytesWritten.begin();
it != streamBytesWritten.end();)
{
if (now - it->second.second > std::chrono::minutes(5))
it = streamBytesWritten.erase(it);
else
++it;
}
}
auto serializeLeaf = [&stream, &localBytesWritten, &tryFlush](
SHAMapItem const& item,
SHAMapNodeType type) -> bool {
stream.write(reinterpret_cast<char const*>(&type), 1);
localBytesWritten += 1;
auto const key = item.key();
stream.write(reinterpret_cast<char const*>(key.data()), 32);
localBytesWritten += 32;
auto const data = item.slice();
std::uint32_t size = data.size();
stream.write(reinterpret_cast<char const*>(&size), 4);
localBytesWritten += 4;
stream.write(reinterpret_cast<char const*>(data.data()), size);
localBytesWritten += size;
if (localBytesWritten >= flushThreshold)
{
tryFlush(stream);
localBytesWritten = 0;
}
return !stream.fail();
};
auto serializeRemovedLeaf =
[&stream, &localBytesWritten, &tryFlush](uint256 const& key) -> bool {
auto t = SHAMapNodeType::tnREMOVE;
stream.write(reinterpret_cast<char const*>(&t), 1);
localBytesWritten += 1;
stream.write(reinterpret_cast<char const*>(key.data()), 32);
localBytesWritten += 32;
if (localBytesWritten >= flushThreshold)
{
tryFlush(stream);
localBytesWritten = 0;
}
return !stream.fail();
};
std::size_t nodeCount = 0;
if (baseSHAMap)
{
auto const& baseMap = baseSHAMap->get();
if (shaMap.getHash() != baseMap.getHash())
{
SHAMap::Delta differences;
if (shaMap.compare(
baseMap, differences, std::numeric_limits<int>::max()))
{
for (auto const& [key, deltaItem] : differences)
{
auto const& newItem = deltaItem.first;
auto const& oldItem = deltaItem.second;
if (!oldItem && newItem)
{
if (serializeLeaf(*newItem, nodeType))
++nodeCount;
}
else if (oldItem && !newItem)
{
if (serializeRemovedLeaf(key))
++nodeCount;
}
else if (
oldItem && newItem &&
oldItem->slice() != newItem->slice())
{
if (serializeLeaf(*newItem, nodeType))
++nodeCount;
}
}
auto t = SHAMapNodeType::tnTERMINAL;
stream.write(reinterpret_cast<char const*>(&t), 1);
localBytesWritten += 1;
if (localBytesWritten >= flushThreshold)
{
tryFlush(stream);
localBytesWritten = 0;
}
std::lock_guard<std::mutex> lock(streamStateMutex);
streamBytesWritten[static_cast<void*>(&stream)] = {
localBytesWritten, std::chrono::steady_clock::now()};
return nodeCount;
}
}
else
{
return 0;
}
}
for (auto const& item : shaMap)
{
if (serializeLeaf(item, nodeType))
++nodeCount;
}
auto t = SHAMapNodeType::tnTERMINAL;
stream.write(reinterpret_cast<char const*>(&t), 1);
localBytesWritten += 1;
if (localBytesWritten >= flushThreshold)
{
tryFlush(stream);
localBytesWritten = 0;
}
{
std::lock_guard<std::mutex> lock(streamStateMutex);
streamBytesWritten[static_cast<void*>(&stream)] = {
localBytesWritten, std::chrono::steady_clock::now()};
}
return nodeCount;
}
bool
deserializeSHAMapFromStream(
SHAMap& shaMap,
CatalogueInputStream& stream,
SHAMapNodeType nodeType,
NodeObjectType flushType,
bool allowRemoval,
beast::Journal const& j)
{
try
{
auto deserializeLeaf = [&shaMap, &stream, &j, nodeType, allowRemoval](
SHAMapNodeType& parsedType) -> bool {
stream.read(reinterpret_cast<char*>(&parsedType), 1);
if (parsedType == SHAMapNodeType::tnTERMINAL)
return false;
uint256 key;
std::uint32_t size{0};
stream.read(reinterpret_cast<char*>(key.data()), 32);
if (stream.fail())
{
JLOG(j.error())
<< "Deserialization: stream stopped unexpectedly "
<< "while trying to read key of next entry";
return false;
}
if (parsedType == SHAMapNodeType::tnREMOVE)
{
if (!allowRemoval)
{
JLOG(j.error())
<< "Deserialization: unexpected removal in this map "
"type";
return false;
}
if (!shaMap.hasItem(key))
{
JLOG(j.error())
<< "Deserialization: removal of key " << to_string(key)
<< " but key is already absent.";
return false;
}
shaMap.delItem(key);
return true;
}
stream.read(reinterpret_cast<char*>(&size), 4);
if (stream.fail())
{
JLOG(j.error())
<< "Deserialization: stream stopped unexpectedly while "
<< "trying to read size of data for key " << to_string(key);
return false;
}
if (size > 1024 * 1024 * 1024)
{
JLOG(j.error())
<< "Deserialization: size of " << to_string(key)
<< " is suspiciously large (" << size << " bytes), "
<< "bailing.";
return false;
}
std::vector<std::uint8_t> data(size);
stream.read(reinterpret_cast<char*>(data.data()), size);
if (stream.fail())
{
JLOG(j.error())
<< "Deserialization: Unexpected EOF while reading data "
<< "for " << to_string(key);
return false;
}
auto item = make_shamapitem(key, makeSlice(data));
if (shaMap.hasItem(key))
return shaMap.updateGiveItem(nodeType, std::move(item));
return shaMap.addGiveItem(nodeType, std::move(item));
};
auto lastParsed = SHAMapNodeType::tnREMOVE;
while (!stream.eof() && deserializeLeaf(lastParsed))
;
if (lastParsed != SHAMapNodeType::tnTERMINAL)
{
JLOG(j.error())
<< "Deserialization: Unexpected EOF, terminal node not found.";
return false;
}
shaMap.flushDirty(flushType);
return true;
}
catch (std::exception const& e)
{
JLOG(j.error()) << "Exception during deserialization: " << e.what();
return false;
}
}
} // namespace
std::size_t
serializeStateMapToStream(
SHAMap const& stateMap,
CatalogueOutputStream& stream,
std::optional<std::reference_wrapper<SHAMap const>> prevStateMap)
{
return serializeSHAMapToStream(
stateMap, stream, SHAMapNodeType::tnACCOUNT_STATE, prevStateMap);
}
std::size_t
serializeTxMapToStream(SHAMap const& txMap, CatalogueOutputStream& stream)
{
return serializeSHAMapToStream(
txMap, stream, SHAMapNodeType::tnTRANSACTION_MD);
}
bool
deserializeStateMapFromStream(
SHAMap& stateMap,
CatalogueInputStream& stream,
NodeObjectType flushType,
beast::Journal const& j)
{
return deserializeSHAMapFromStream(
stateMap, stream, SHAMapNodeType::tnACCOUNT_STATE, flushType, true, j);
}
bool
deserializeTxMapFromStream(
SHAMap& txMap,
CatalogueInputStream& stream,
NodeObjectType flushType,
beast::Journal const& j)
{
return deserializeSHAMapFromStream(
txMap, stream, SHAMapNodeType::tnTRANSACTION_MD, flushType, false, j);
}
} // namespace RPC
} // namespace ripple

View File

@@ -0,0 +1,65 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2012-2014 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_RPC_CATALOGUESTREAM_H_INCLUDED
#define RIPPLE_RPC_CATALOGUESTREAM_H_INCLUDED
#include <xrpld/nodestore/NodeObject.h>
#include <xrpld/shamap/SHAMap.h>
#include <xrpl/beast/utility/Journal.h>
#include <boost/iostreams/filtering_stream.hpp>
#include <functional>
#include <optional>
namespace ripple {
namespace RPC {
using CatalogueInputStream = boost::iostreams::filtering_istream;
using CatalogueOutputStream = boost::iostreams::filtering_ostream;
std::size_t
serializeStateMapToStream(
SHAMap const& stateMap,
CatalogueOutputStream& stream,
std::optional<std::reference_wrapper<SHAMap const>> prevStateMap =
std::nullopt);
std::size_t
serializeTxMapToStream(SHAMap const& txMap, CatalogueOutputStream& stream);
bool
deserializeStateMapFromStream(
SHAMap& stateMap,
CatalogueInputStream& stream,
NodeObjectType flushType,
beast::Journal const& j);
bool
deserializeTxMapFromStream(
SHAMap& txMap,
CatalogueInputStream& stream,
NodeObjectType flushType,
beast::Journal const& j);
} // namespace RPC
} // namespace ripple
#endif

View File

@@ -21,10 +21,13 @@
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/ledger/LedgerToJson.h>
#include <xrpld/app/main/Application.h>
#include <xrpld/app/misc/SHAMapStore.h>
#include <xrpld/app/rdb/backend/SQLiteDatabase.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/rpc/Context.h>
#include <xrpld/rpc/GRPCHandlers.h>
#include <xrpld/rpc/Role.h>
#include <xrpld/rpc/detail/CatalogueStream.h>
#include <xrpld/rpc/detail/RPCHelpers.h>
#include <xrpld/rpc/detail/Tuning.h>
#include <xrpld/shamap/SHAMapItem.h>
@@ -678,10 +681,10 @@ doCatalogueCreate(RPC::JsonContext& context)
return false;
}
size_t stateNodesWritten =
ledger->stateMap().serializeToStream(*compStream, prevStateMap);
size_t stateNodesWritten = RPC::serializeStateMapToStream(
ledger->stateMap(), *compStream, prevStateMap);
size_t txNodesWritten =
ledger->txMap().serializeToStream(*compStream);
RPC::serializeTxMapToStream(ledger->txMap(), *compStream);
predictor.addLedger(info.seq, byteCounter.getBytesWritten());
@@ -910,6 +913,21 @@ doCatalogueLoad(RPC::JsonContext& context)
}
} opCleanup;
// Reject if DatabasePinned is not configured. Without it, loaded
// data lands in rotating storage and will be rotated away.
{
auto const& nscfg =
context.app.config().section(ConfigSection::nodeDatabase());
if (!nscfg.exists("pinned_type"))
{
return rpcError(
rpcINVALID_PARAMS,
"catalogue_load requires [node_db] pinned_type to be "
"configured. Without it, loaded data will be lost on "
"the next database rotation.");
}
}
if (!context.params.isMember(jss::input_file))
return rpcError(rpcINVALID_PARAMS, "expected input_file");
@@ -971,6 +989,10 @@ doCatalogueLoad(RPC::JsonContext& context)
uint8_t version = getCatalogueVersion(header.version);
uint8_t compressionLevel = getCompressionLevel(header.version);
if (header.min_ledger > header.max_ledger)
return rpcError(
rpcINVALID_PARAMS, "catalogue min_ledger must be <= max_ledger");
// Initialize status tracking
{
std::unique_lock<std::shared_mutex> writeLock(catalogueStatusMutex);
@@ -1191,7 +1213,11 @@ doCatalogueLoad(RPC::JsonContext& context)
ledger->setLedgerInfo(info);
// Deserialize the complete state map from leaf nodes
if (!ledger->stateMap().deserializeFromStream(*decompStream))
if (!RPC::deserializeStateMapFromStream(
ledger->stateMap(),
*decompStream,
pinnedACCOUNT_NODE,
context.j))
{
JLOG(context.j.error())
<< "Failed to deserialize base ledger state";
@@ -1217,7 +1243,11 @@ doCatalogueLoad(RPC::JsonContext& context)
*snapshot);
// Apply delta (only leaf-node changes)
if (!ledger->stateMap().deserializeFromStream(*decompStream))
if (!RPC::deserializeStateMapFromStream(
ledger->stateMap(),
*decompStream,
pinnedACCOUNT_NODE,
context.j))
{
JLOG(context.j.error())
<< "Failed to apply delta to ledger " << info.seq;
@@ -1226,17 +1256,17 @@ doCatalogueLoad(RPC::JsonContext& context)
}
// pull in the tx map
if (!ledger->txMap().deserializeFromStream(*decompStream))
if (!RPC::deserializeTxMapFromStream(
ledger->txMap(),
*decompStream,
pinnedTRANSACTION_NODE,
context.j))
{
JLOG(context.j.error())
<< "Failed to apply delta to ledger " << info.seq;
return rpcError(rpcINTERNAL, "Failed to apply ledger delta");
}
// Finalize the ledger
ledger->stateMap().flushDirty(hotACCOUNT_NODE);
ledger->txMap().flushDirty(hotTRANSACTION_NODE);
ledger->setAccepted(
info.closeTime,
info.closeTimeResolution,
@@ -1260,12 +1290,84 @@ doCatalogueLoad(RPC::JsonContext& context)
rpcINTERNAL, "Catalogue file contains a corrupted ledger.");
}
// Save in database
pendSaveValidated(context.app, ledger, false, false);
// Store in ledger master
// IMPORTANT: Mark as pinned BEFORE saving to database.
// This ensures isPinned() returns true when saveValidatedLedger
// checks, which: (a) routes to persistent backend via pinnedLEDGER
// type, and (b) skips AcceptedLedgerCache to avoid memory bloat.
context.app.getLedgerMaster().storeLedger(ledger, true);
// Scope guard: un-pin if we exit without a successful save.
// Dismissed on success below.
bool saveDone = false;
auto unpinGuard = [&]() {
if (!saveDone)
context.app.getLedgerMaster().unpinLedger(ledger->info().seq);
};
// Use a simple RAII wrapper to guarantee the guard runs
struct OnExit
{
std::function<void()> fn;
~OnExit()
{
fn();
}
} unpinOnExit{unpinGuard};
// Save in database - wait for completion to avoid memory bloat.
// Uses pendSaveValidated to respect job queue tuning on live
// servers (jtPUBOLDLEDGER), runs synchronously in standalone.
{
auto savePromise = std::make_shared<std::promise<bool>>();
auto saveFuture = savePromise->get_future();
bool queued = pendSaveValidated(
context.app,
ledger,
context.app.config().standalone(),
false,
[savePromise](bool success) {
savePromise->set_value(success);
});
if (!queued)
return rpcError(rpcINTERNAL, "Failed to save ledger");
// Wait for the async save to complete. Note: if the save
// job throws, the JobQueue has no exception handling — the
// process will std::terminate before we ever see a
// broken_promise here. The catch is defensive in case the
// job queue gains exception handling in the future.
bool saved = false;
try
{
saved = saveFuture.get();
}
catch (std::future_error const& e)
{
JLOG(context.j.error())
<< "Save job for ledger " << ledger->info().seq
<< " failed with exception (promise broken): " << e.what();
return rpcError(
rpcINTERNAL,
"Save job crashed for ledger " +
std::to_string(ledger->info().seq));
}
if (!saved)
{
JLOG(context.j.error()) << "Failed to save ledger "
<< ledger->info().seq << " to SQLite";
return rpcError(
rpcINTERNAL,
"Failed to save ledger " +
std::to_string(ledger->info().seq) +
" to SQLite database");
}
// Save succeeded — dismiss the guard
saveDone = true;
}
if (info.seq == header.max_ledger &&
context.app.getLedgerMaster().getClosedLedger()->info().seq <
info.seq)
@@ -1277,6 +1379,20 @@ doCatalogueLoad(RPC::JsonContext& context)
context.app.getLedgerMaster().setLedgerRangePresent(
header.min_ledger, info.seq, true);
// Persist pinned ranges to state.db after every ledger.
// This is a single row UPDATE, so it's cheap.
//
// DURABILITY NOTE: There is a small crash window between the
// ledger save above and this setPinnedRanges call. If the
// process crashes in that window, the ledger data is safely
// in the persistent backend (always opened based on config,
// independent of state.db), and fetchNodeObject will still
// find it via the tryPersistent fallback. However, state.db
// won't record the pinned range, so mCompleteLedgers won't
// include those seqs until catalogue_load is re-run.
context.app.getSHAMapStore().setPinnedRanges(
context.app.getLedgerMaster().getPinnedLedgersRangeSet());
// Store the ledger
prevLedger = ledger;
ledgersLoaded++;

View File

@@ -367,35 +367,6 @@ public:
void
invariants() const;
public:
/**
* Serialize a SHAMap to a stream, optionally as a delta from another map
* Only leaf nodes are serialized since inner nodes can be reconstructed.
*
* @param stream The output stream to write to
* @param writtenNodes Set to track written node hashes to avoid duplicates
* @param baseSHAMap Optional base map to compute delta against
* @return Number of nodes written
*/
template <typename StreamType>
std::size_t
serializeToStream(
StreamType& stream,
std::optional<std::reference_wrapper<const SHAMap>> baseSHAMap =
std::nullopt) const;
/**
* Deserialize a SHAMap from a stream
* Reconstructs the full tree from leaf nodes.
*
* @param stream The input stream to read from
* @param baseSHAMap Optional base map to apply deltas to
* @return True if deserialization succeeded
*/
template <typename StreamType>
bool
deserializeFromStream(StreamType& stream);
private:
using SharedPtrNodeStack =
std::stack<std::pair<std::shared_ptr<SHAMapTreeNode>, SHAMapNodeID>>;

View File

@@ -1301,395 +1301,4 @@ SHAMap::invariants() const
node->invariants(true);
}
template <typename StreamType>
std::size_t
SHAMap::serializeToStream(
StreamType& stream,
std::optional<std::reference_wrapper<const SHAMap>> baseSHAMap) const
{
// Static map to track bytes written to streams
static std::mutex streamMapMutex;
static std::unordered_map<
void*,
std::pair<uint64_t, std::chrono::steady_clock::time_point>>
streamBytesWritten;
// Flush threshold: 256 MiB
constexpr uint64_t flushThreshold = 256 * 1024 * 1024;
// Local byte counter for this stream
uint64_t localBytesWritten = 0;
// Single lambda that uses compile-time check for flush method existence
auto tryFlush = [](auto& s) {
if constexpr (requires(decltype(s) str) { str.flush(); })
{
s.flush();
}
// No-op if flush doesn't exist - compiler will optimize this branch out
};
// Get the current bytes written from the global map (with lock)
{
std::lock_guard<std::mutex> lock(streamMapMutex);
auto it = streamBytesWritten.find(static_cast<void*>(&stream));
if (it != streamBytesWritten.end())
{
localBytesWritten = it->second.first;
}
// Random cleanup of old entries (while we have the lock)
if (!streamBytesWritten.empty())
{
auto now = std::chrono::steady_clock::now();
size_t randomIndex = std::rand() % streamBytesWritten.size();
auto cleanupIt = std::next(streamBytesWritten.begin(), randomIndex);
// If entry is older than 5 minutes, remove it
if (now - cleanupIt->second.second > std::chrono::minutes(5))
{
streamBytesWritten.erase(cleanupIt);
}
}
}
std::unordered_set<SHAMapHash, beast::uhash<>> writtenNodes;
if (!root_)
return 0;
std::size_t nodeCount = 0;
auto serializeLeaf = [&stream, &localBytesWritten, &tryFlush](
SHAMapLeafNode const& node) -> bool {
// write the node type
auto t = node.getType();
stream.write(reinterpret_cast<char const*>(&t), 1);
localBytesWritten += 1;
// write the key
auto const key = node.peekItem()->key();
stream.write(reinterpret_cast<char const*>(key.data()), 32);
localBytesWritten += 32;
// write the data size
auto data = node.peekItem()->slice();
uint32_t size = data.size();
stream.write(reinterpret_cast<char const*>(&size), 4);
localBytesWritten += 4;
// write the data
stream.write(reinterpret_cast<char const*>(data.data()), size);
localBytesWritten += size;
// Check if we should flush without locking
if (localBytesWritten >= flushThreshold)
{
tryFlush(stream);
localBytesWritten = 0;
}
return !stream.fail();
};
auto serializeRemovedLeaf =
[&stream, &localBytesWritten, &tryFlush](uint256 const& key) -> bool {
// to indicate a node is removed it is written with a removal type
auto t = SHAMapNodeType::tnREMOVE;
stream.write(reinterpret_cast<char const*>(&t), 1);
localBytesWritten += 1;
// write the key
stream.write(reinterpret_cast<char const*>(key.data()), 32);
localBytesWritten += 32;
// Check if we should flush without locking
if (localBytesWritten >= flushThreshold)
{
tryFlush(stream);
localBytesWritten = 0;
}
return !stream.fail();
};
// If we're creating a delta, first compute the differences
if (baseSHAMap && baseSHAMap->get().root_)
{
const SHAMap& baseMap = baseSHAMap->get();
// Only compute delta if the maps are different
if (getHash() != baseMap.getHash())
{
Delta differences;
if (compare(baseMap, differences, std::numeric_limits<int>::max()))
{
// Process each difference
for (auto const& [key, deltaItem] : differences)
{
auto const& newItem = deltaItem.first;
auto const& oldItem = deltaItem.second;
if (!oldItem && newItem)
{
// Added item
SHAMapLeafNode* leaf = findKey(key);
if (leaf && serializeLeaf(*leaf))
++nodeCount;
}
else if (oldItem && !newItem)
{
// Removed item
if (serializeRemovedLeaf(key))
++nodeCount;
}
else if (
oldItem && newItem &&
oldItem->slice() != newItem->slice())
{
// Modified item
SHAMapLeafNode* leaf = findKey(key);
if (leaf && serializeLeaf(*leaf))
++nodeCount;
}
}
// write a terminal symbol to indicate the map stream has ended
auto t = SHAMapNodeType::tnTERMINAL;
stream.write(reinterpret_cast<char const*>(&t), 1);
localBytesWritten += 1;
// Check if we should flush without locking
if (localBytesWritten >= flushThreshold)
{
tryFlush(stream);
localBytesWritten = 0;
}
// Update the global counter at the end (with lock)
{
std::lock_guard<std::mutex> lock(streamMapMutex);
auto& streamData =
streamBytesWritten[static_cast<void*>(&stream)];
streamData.first = localBytesWritten;
streamData.second = std::chrono::steady_clock::now();
}
return nodeCount;
}
}
else
{
// Maps are identical, nothing to write
return 0;
}
}
// Otherwise walk the entire tree and serialize all leaf nodes
std::function<void(SHAMapTreeNode const&, SHAMapNodeID const&)> walkTree =
[&](SHAMapTreeNode const& node, SHAMapNodeID const& nodeID) {
if (node.isLeaf())
{
auto const& leaf = static_cast<SHAMapLeafNode const&>(node);
auto const& hash = leaf.getHash();
// Avoid duplicates
if (writtenNodes.insert(hash).second)
{
if (serializeLeaf(leaf))
++nodeCount;
}
return;
}
// It's an inner node, process its children
auto const& inner = static_cast<SHAMapInnerNode const&>(node);
for (int i = 0; i < branchFactor; ++i)
{
if (!inner.isEmptyBranch(i))
{
auto const& childHash = inner.getChildHash(i);
// Skip already written nodes
if (writtenNodes.find(childHash) != writtenNodes.end())
continue;
auto childNode =
descendThrow(const_cast<SHAMapInnerNode*>(&inner), i);
if (childNode)
{
SHAMapNodeID childID = nodeID.getChildNodeID(i);
walkTree(*childNode, childID);
}
}
}
};
// Start walking from root
walkTree(*root_, SHAMapNodeID());
// write a terminal symbol to indicate the map stream has ended
auto t = SHAMapNodeType::tnTERMINAL;
stream.write(reinterpret_cast<char const*>(&t), 1);
localBytesWritten += 1;
// Check if we should flush one last time without locking
if (localBytesWritten >= flushThreshold)
{
tryFlush(stream);
localBytesWritten = 0;
}
// Update the global counter at the end (with lock)
{
std::lock_guard<std::mutex> lock(streamMapMutex);
auto& streamData = streamBytesWritten[static_cast<void*>(&stream)];
streamData.first = localBytesWritten;
streamData.second = std::chrono::steady_clock::now();
}
return nodeCount;
}
template <typename StreamType>
bool
SHAMap::deserializeFromStream(StreamType& stream)
{
try
{
JLOG(journal_.info()) << "Deserialization: Starting to deserialize "
"from stream";
if (state_ != SHAMapState::Modifying && state_ != SHAMapState::Synching)
return false;
if (!root_)
root_ = std::make_shared<SHAMapInnerNode>(cowid_);
// Define a lambda to deserialize a leaf node
auto deserializeLeaf =
[this, &stream](SHAMapNodeType& nodeType /* out */) -> bool {
stream.read(reinterpret_cast<char*>(&nodeType), 1);
if (nodeType == SHAMapNodeType::tnTERMINAL)
{
// end of map
return false;
}
uint256 key;
uint32_t size{0};
stream.read(reinterpret_cast<char*>(key.data()), 32);
if (stream.fail())
{
JLOG(journal_.error())
<< "Deserialization: stream stopped unexpectedly "
<< "while trying to read key of next entry";
return false;
}
if (nodeType == SHAMapNodeType::tnREMOVE)
{
// deletion
if (!hasItem(key))
{
JLOG(journal_.error())
<< "Deserialization: removal of key " << to_string(key)
<< " but key is already absent.";
return false;
}
delItem(key);
return true;
}
stream.read(reinterpret_cast<char*>(&size), 4);
if (stream.fail())
{
JLOG(journal_.error())
<< "Deserialization: stream stopped unexpectedly"
<< " while trying to read size of data for key "
<< to_string(key);
return false;
}
if (size > 1024 * 1024 * 1024)
{
JLOG(journal_.error())
<< "Deserialization: size of " << to_string(key)
<< " is suspiciously large (" << size
<< " bytes), bailing.";
return false;
}
std::vector<uint8_t> data;
data.resize(size);
stream.read(reinterpret_cast<char*>(data.data()), size);
if (stream.fail())
{
JLOG(journal_.error())
<< "Deserialization: Unexpected EOF while reading data for "
<< to_string(key);
return false;
}
auto item = make_shamapitem(key, makeSlice(data));
if (hasItem(key))
return updateGiveItem(nodeType, std::move(item));
return addGiveItem(nodeType, std::move(item));
};
SHAMapNodeType lastParsed;
while (!stream.eof() && deserializeLeaf(lastParsed))
;
if (lastParsed != SHAMapNodeType::tnTERMINAL)
{
JLOG(journal_.error())
<< "Deserialization: Unexpected EOF, terminal node not found.";
return false;
}
// Flush any dirty nodes and update hashes
flushDirty(hotUNKNOWN);
return true;
}
catch (std::exception const& e)
{
JLOG(journal_.error())
<< "Exception during deserialization: " << e.what();
return false;
}
}
// explicit instantiation of templates for rpc::Catalogue
using FilteringInputStream = boost::iostreams::filtering_stream<
boost::iostreams::input,
char,
std::char_traits<char>,
std::allocator<char>,
boost::iostreams::public_>;
template bool
SHAMap::deserializeFromStream<FilteringInputStream>(FilteringInputStream&);
using FilteringOutputStream = boost::iostreams::filtering_stream<
boost::iostreams::output,
char,
std::char_traits<char>,
std::allocator<char>,
boost::iostreams::public_>;
template std::size_t
SHAMap::serializeToStream<FilteringOutputStream>(
FilteringOutputStream&,
std::optional<std::reference_wrapper<const SHAMap>> baseSHAMap) const;
} // namespace ripple