From 599376b2d3e6facbabdb3804fedf1f042e71c988 Mon Sep 17 00:00:00 2001 From: Graydon Hoare Date: Tue, 10 Mar 2026 16:08:07 -0700 Subject: [PATCH 1/6] Tighten up module cache rebuild logic --- src/ledger/LedgerManagerImpl.cpp | 100 ++++++++++++----------- src/ledger/LedgerManagerImpl.h | 21 +++-- src/ledger/SharedModuleCacheCompiler.cpp | 4 +- src/rust/src/bridge.rs | 2 +- src/rust/src/soroban_module_cache.rs | 12 +-- src/rust/src/soroban_proto_any.rs | 28 ++++--- 6 files changed, 90 insertions(+), 77 deletions(-) diff --git a/src/ledger/LedgerManagerImpl.cpp b/src/ledger/LedgerManagerImpl.cpp index ac14f19214..6f2944804d 100644 --- a/src/ledger/LedgerManagerImpl.cpp +++ b/src/ledger/LedgerManagerImpl.cpp @@ -975,7 +975,8 @@ LedgerManagerImpl::ApplyState::handleUpgradeAffectingSorobanInMemoryStateSize( void LedgerManagerImpl::ApplyState::finishPendingCompilation() { - assertWritablePhase(); + threadInvariant(); + releaseAssert(mPhase == Phase::SETTING_UP_STATE); releaseAssert(mCompiler); auto newCache = mCompiler->wait(); getMetrics().mSorobanMetrics.mModuleCacheRebuildBytes.set_count( @@ -1032,7 +1033,14 @@ void LedgerManagerImpl::ApplyState::markEndOfCommitting() { assertCommittingPhase(); - mPhase = Phase::READY_TO_APPLY; + if (isCompilationRunning()) + { + mPhase = Phase::SETTING_UP_STATE; + } + else + { + mPhase = Phase::READY_TO_APPLY; + } } void @@ -1106,51 +1114,34 @@ LedgerManagerImpl::ApplyState::maybeRebuildModuleCache( // unbounded growth. // // Unfortunately we do not know exactly how much memory is used by each byte - // of contract we compile, and the size estimates from the cost model have - // to assume a worst case which is almost a factor of _40_ larger than the - // byte-size of the contracts. So for example if we assume 100MB of - // contracts, the cost model says we ought to budget for 4GB of memory, just - // in case _all 100MB of contracts_ are "the worst case contract" that's - // just a continuous stream of function definitions. + // of contract we compile. But we do know how much wasm we fed _into_ the + // compiler, and we can assume that the network's cost model is already + // serving to roughly bound the live set of contracts in the BL. // - // So: we take this multiplier, times the size of the contracts we _last_ - // drew from the BL when doing a full recompile, times two, as a cap on the - // _current_ (post-rebuild, currently-growing) cache's budget-tracked - // memory. This should avoid rebuilding spuriously, while still treating - // events that double the size of the contract-set in the live BL as an - // event that warrants a rebuild. - - // We try to fish the current cost multiplier out of the soroban network - // config's memory cost model, but fall back to a conservative default in - // case there is no mem cost param for VmInstantiation (This should never - // happen but just in case). - uint64_t linearTerm = 5000; - - // linearTerm is in 1/128ths in the cost model, to reduce rounding error. - uint64_t scale = 128; - auto sorobanConfig = SorobanNetworkConfig::loadFromLedger(snap); - auto const& memParams = sorobanConfig.memCostParams(); - if (memParams.size() > (size_t)stellar::VmInstantiation) - { - auto const& param = memParams[(size_t)stellar::VmInstantiation]; - linearTerm = param.linearTerm; - } - auto lastBytesCompiled = + // So: we take the input size of the contracts we _last_ drew from the BL + // when doing a full recompile (which we always do on startup at least), and + // multiply it by two, and use that as a cap on the _current_ (post-rebuild, + // currently-growing) cache's wasm input bytes. This should avoid rebuilding + // spuriously, while still treating events that double the size of the + // contract-set in the live BL as an event that warrants a rebuild. + + int64_t lastCompiledWasmBytesCount = getMetrics().mSorobanMetrics.mModuleCacheRebuildBytes.count(); - uint64_t limit = 2 * lastBytesCompiled * linearTerm / scale; - + uint64_t lastCompiledWasmBytes = + lastCompiledWasmBytesCount < 0 + ? 0 + : static_cast(lastCompiledWasmBytesCount); + uint64_t currCompiledWasmBytes = 0; for (auto const& v : mModuleCacheProtocols) { - auto bytesConsumed = mModuleCache->get_mem_bytes_consumed(v); - if (bytesConsumed > limit) - { - CLOG_DEBUG(Ledger, - "Rebuilding module cache: worst-case estimate {} " - "model-bytes consumed of {} limit", - bytesConsumed, limit); - startCompilingAllContracts(snap, minLedgerVersion); - break; - } + currCompiledWasmBytes += mModuleCache->get_wasm_bytes_input(v); + } + if (currCompiledWasmBytes > (2 * lastCompiledWasmBytes)) + { + CLOG_DEBUG(Ledger, + "Rebuilding module cache after {} wasm bytes compiled", + currCompiledWasmBytes); + startCompilingAllContracts(snap, minLedgerVersion); } } @@ -1458,6 +1449,7 @@ LedgerManagerImpl::applyLedger(LedgerCloseData const& ledgerData, if (mApplyState.isCompilationRunning()) { mApplyState.finishPendingCompilation(); + mApplyState.markEndOfSetupPhase(); } #ifdef BUILD_TESTS @@ -3062,21 +3054,31 @@ LedgerManagerImpl::ApplyState::addAnyContractsToModuleCache( { if (e.data.type() == CONTRACT_CODE) { + using rslice = ::rust::Slice; + auto const& key = e.data.contractCode().hash; + auto const& wasm = e.data.contractCode().code; + rslice const keySlice{key.data(), key.size()}; + rslice const wasmSlice{wasm.data(), wasm.size()}; for (auto const& v : mModuleCacheProtocols) { if (v >= ledgerVersion) { - auto const& wasm = e.data.contractCode().code; + if (mModuleCache->contains_module(v, keySlice)) + { + CLOG_DEBUG(Ledger, + "module cache already contains wasm {} " + "for protocol {}", + binToHex(key), v); + continue; + } CLOG_DEBUG(Ledger, "compiling wasm {} for protocol {} module cache", - binToHex(sha256(wasm)), v); - auto slice = - rust::Slice(wasm.data(), wasm.size()); - getMetrics().mSorobanMetrics.mModuleCacheNumEntries.inc(); + binToHex(key), v); auto timer = getMetrics() .mSorobanMetrics.mModuleCompilationTime.TimeScope(); - mModuleCache->compile(v, slice); + mModuleCache->compile(v, wasmSlice); + getMetrics().mSorobanMetrics.mModuleCacheNumEntries.inc(); } } } diff --git a/src/ledger/LedgerManagerImpl.h b/src/ledger/LedgerManagerImpl.h index 02b74ae196..ba8b1cb331 100644 --- a/src/ledger/LedgerManagerImpl.h +++ b/src/ledger/LedgerManagerImpl.h @@ -120,8 +120,8 @@ class LedgerManagerImpl : public LedgerManager // During the ledger close process, the apply state goes through these // phases: // - SETTING_UP_STATE: LedgerManager is waiting for or setting up - // ApplyState. This occurs on startup and after BucketApply during - // catchup. + // ApplyState. This occurs on startup, after BucketApply during + // catchup, and any time the module cache has to be rebuilt. // - READY_TO_APPLY: Apply State is ready but not actively executing // transactions or committing ledger state. ApplyState is immutable. // - APPLYING: ApplyState is actively executing transactions. @@ -135,14 +135,23 @@ class LedgerManagerImpl : public LedgerManager // commits state to disk, and advances the ledger header. // // Phase transitions: - // SETTING_UP_STATE -> READY_TO_APPLY -> SETTING_UP_STATE - // | - // -> APPLYING -> COMMITTING -> READY_TO_APPLY + // + // SETTING_UP_STATE <-------(rebuild cache)----+ + // | ^ | + // | | (catchup) | + // v | | + // READY_TO_APPLY <----(no rebuild cache)---- COMMITTING + // | ^ + // v | + // APPLYING ---------------------------------+ // // SETTING_UP_STATE is the initial phase on startup. ApplyState may // also transition from READY_TO_APPLY -> SETTING_UP_STATE if a node // falls out of sync and must enter catchup, which requires re-entering - // the SETTING_UP_STATE phase to reset lcl state. + // the SETTING_UP_STATE phase to reset lcl state. After COMMITTING, + // the state returns to SETTING_UP_STATE if a module cache rebuild + // is needed, or directly to READY_TO_APPLY otherwise. In both cases + // READY_TO_APPLY is always reached before entering APPLYING. // // APPLYING is the only phase in which Soroban execution // threads are active. diff --git a/src/ledger/SharedModuleCacheCompiler.cpp b/src/ledger/SharedModuleCacheCompiler.cpp index 3158c31459..5589e8d38a 100644 --- a/src/ledger/SharedModuleCacheCompiler.cpp +++ b/src/ledger/SharedModuleCacheCompiler.cpp @@ -220,7 +220,7 @@ size_t SharedModuleCacheCompiler::getBytesCompiled() { std::unique_lock lock(mMutex); - return mBytesCompiled; + return mBytesCompiled * mLedgerVersions.size(); } std::chrono::nanoseconds @@ -234,7 +234,7 @@ size_t SharedModuleCacheCompiler::getContractsCompiled() { std::unique_lock lock(mMutex); - return mContractsCompiled; + return mContractsCompiled * mLedgerVersions.size(); } } diff --git a/src/rust/src/bridge.rs b/src/rust/src/bridge.rs index ff04db01ce..37cda3dc58 100644 --- a/src/rust/src/bridge.rs +++ b/src/rust/src/bridge.rs @@ -322,7 +322,7 @@ pub(crate) mod rust_bridge { fn evict_contract_code(self: &SorobanModuleCache, key: &[u8]) -> Result<()>; fn clear(self: &SorobanModuleCache) -> Result<()>; fn contains_module(self: &SorobanModuleCache, protocol: u32, key: &[u8]) -> Result; - fn get_mem_bytes_consumed(self: &SorobanModuleCache, protocol: u32) -> Result; + fn get_wasm_bytes_input(self: &SorobanModuleCache, protocol: u32) -> Result; // Given a quorum set configuration, checks if quorum intersection is // enjoyed among all possible quorums. Returns `Ok(status)` where diff --git a/src/rust/src/soroban_module_cache.rs b/src/rust/src/soroban_module_cache.rs index c6dd8e5dcf..e0b4fdc593 100644 --- a/src/rust/src/soroban_module_cache.rs +++ b/src/rust/src/soroban_module_cache.rs @@ -98,19 +98,19 @@ impl SorobanModuleCache { _ => Err(protocol_agnostic::make_error("unsupported protocol")), } } - pub fn get_mem_bytes_consumed( + pub fn get_wasm_bytes_input( &self, ledger_protocol: u32, ) -> Result> { #[allow(unused_mut)] let mut bytes = 0; match ledger_protocol { - 23 => bytes = bytes.max(self.p23_cache.get_mem_bytes_consumed()?), - 24 => bytes = bytes.max(self.p24_cache.get_mem_bytes_consumed()?), - 25 => bytes = bytes.max(self.p25_cache.get_mem_bytes_consumed()?), - 26 => bytes = bytes.max(self.p26_cache.get_mem_bytes_consumed()?), + 23 => bytes = bytes.max(self.p23_cache.get_wasm_bytes_input()?), + 24 => bytes = bytes.max(self.p24_cache.get_wasm_bytes_input()?), + 25 => bytes = bytes.max(self.p25_cache.get_wasm_bytes_input()?), + 26 => bytes = bytes.max(self.p26_cache.get_wasm_bytes_input()?), #[cfg(feature = "next")] - 27 => bytes = bytes.max(self.p26_cache.get_mem_bytes_consumed()?), + 27 => bytes = bytes.max(self.p26_cache.get_wasm_bytes_input()?), _ => return Err(protocol_agnostic::make_error("unsupported protocol")), } Ok(bytes) diff --git a/src/rust/src/soroban_proto_any.rs b/src/rust/src/soroban_proto_any.rs index 2dda58618a..7f92cae0bc 100644 --- a/src/rust/src/soroban_proto_any.rs +++ b/src/rust/src/soroban_proto_any.rs @@ -705,8 +705,9 @@ pub(crate) struct ProtocolSpecificModuleCache { // `CompilationContext` is _not_ threadsafe (specifically its `Budget` is // not) and so rather than reuse a single `CompilationContext` across // threads, we make a throwaway `CompilationContext` on each `compile` call, - // and _copy out_ the memory usage (which we want to publish back to core). - pub(crate) mem_bytes_consumed: std::sync::atomic::AtomicU64, + // and track only a single _input_ value (which we want to publish back to + // core). + pub(crate) wasm_bytes_input: std::sync::atomic::AtomicU64, } #[allow(dead_code)] @@ -716,7 +717,7 @@ impl ProtocolSpecificModuleCache { let module_cache = ModuleCache::new(&compilation_context)?; Ok(ProtocolSpecificModuleCache { module_cache, - mem_bytes_consumed: std::sync::atomic::AtomicU64::new(0), + wasm_bytes_input: std::sync::atomic::AtomicU64::new(0), }) } @@ -727,12 +728,8 @@ impl ProtocolSpecificModuleCache { get_max_proto(), wasm, ); - self.mem_bytes_consumed.fetch_add( - compilation_context - .unlimited_budget - .get_mem_bytes_consumed()?, - std::sync::atomic::Ordering::SeqCst, - ); + self.wasm_bytes_input + .fetch_add(wasm.len() as u64, std::sync::atomic::Ordering::SeqCst); Ok(res?) } @@ -742,7 +739,12 @@ impl ProtocolSpecificModuleCache { } pub(crate) fn clear(&self) -> Result<(), Box> { - Ok(self.module_cache.clear()?) + let res = self.module_cache.clear(); + if res.is_ok() { + self.wasm_bytes_input + .store(0, std::sync::atomic::Ordering::SeqCst); + } + Ok(res?) } pub(crate) fn contains_module( @@ -752,9 +754,9 @@ impl ProtocolSpecificModuleCache { Ok(self.module_cache.contains_module(&key.clone().into())?) } - pub(crate) fn get_mem_bytes_consumed(&self) -> Result> { + pub(crate) fn get_wasm_bytes_input(&self) -> Result> { Ok(self - .mem_bytes_consumed + .wasm_bytes_input .load(std::sync::atomic::Ordering::SeqCst)) } @@ -771,7 +773,7 @@ impl ProtocolSpecificModuleCache { let module_cache = self.module_cache.clone(); Ok(ProtocolSpecificModuleCache { module_cache, - mem_bytes_consumed: std::sync::atomic::AtomicU64::new(0), + wasm_bytes_input: std::sync::atomic::AtomicU64::new(0), }) } } From 018bceb95f5a19a51b12e2201393e5fc9ce0373d Mon Sep 17 00:00:00 2001 From: Graydon Hoare Date: Wed, 11 Mar 2026 15:50:53 -0700 Subject: [PATCH 2/6] Move module cache tests to separate file. --- .../test/InvokeHostFunctionTests.cpp | 394 ----------------- src/transactions/test/ModuleCacheTests.cpp | 416 ++++++++++++++++++ 2 files changed, 416 insertions(+), 394 deletions(-) create mode 100644 src/transactions/test/ModuleCacheTests.cpp diff --git a/src/transactions/test/InvokeHostFunctionTests.cpp b/src/transactions/test/InvokeHostFunctionTests.cpp index 737f4c1c44..d38dcd7f75 100644 --- a/src/transactions/test/InvokeHostFunctionTests.cpp +++ b/src/transactions/test/InvokeHostFunctionTests.cpp @@ -6947,61 +6947,6 @@ TEST_CASE("Soroban authorization", "[tx][soroban]") } } -TEST_CASE("Module cache", "[tx][soroban]") -{ - VirtualClock clock; - auto cfg = getTestConfig(0); - cfg.USE_CONFIG_FOR_GENESIS = false; - - auto app = createTestApplication(clock, cfg); - - auto upgrade20 = LedgerUpgrade{LEDGER_UPGRADE_VERSION}; - upgrade20.newLedgerVersion() = static_cast(SOROBAN_PROTOCOL_VERSION); - executeUpgrade(*app, upgrade20); - - // Test that repeated calls to a contract in a single transaction are - // cheaper due to caching introduced in v21. - auto sum_wasm = rust_bridge::get_test_wasm_sum_i32(); - auto add_wasm = rust_bridge::get_test_wasm_add_i32(); - SorobanTest test(app); - - auto const& sumContract = test.deployWasmContract(sum_wasm); - auto const& addContract = test.deployWasmContract(add_wasm); - - auto invocation = [&](int64_t instructions) -> bool { - auto fnName = "sum"; - auto scVec = makeVecSCVal({makeI32(1), makeI32(2), makeI32(3), - makeI32(4), makeI32(5), makeI32(6)}); - - auto invocationSpec = SorobanInvocationSpec() - .setInstructions(instructions) - .setReadBytes(2'000) - .setInclusionFee(12345); - - uint32_t const expectedRefund = 100'000; - auto spec = invocationSpec.setNonRefundableResourceFee(33'000) - .setRefundableResourceFee(expectedRefund); - - spec = spec.extendReadOnlyFootprint(addContract.getKeys()); - - auto invocation = sumContract.prepareInvocation( - fnName, {makeAddressSCVal(addContract.getAddress()), scVec}, spec, - expectedRefund); - auto tx = invocation.createTx(); - return isSuccessResult(test.invokeTx(tx)); - }; - - REQUIRE(invocation(7'000'000)); - REQUIRE(!invocation(6'000'000)); - - auto upgrade21 = LedgerUpgrade{LEDGER_UPGRADE_VERSION}; - upgrade21.newLedgerVersion() = static_cast(ProtocolVersion::V_21); - executeUpgrade(*app, upgrade21); - - // V21 Caching reduces the instructions required - REQUIRE(invocation(4'000'000)); -} - TEST_CASE("Vm instantiation tightening", "[tx][soroban]") { VirtualClock clock; @@ -7218,272 +7163,6 @@ TEST_CASE("contract constructor support", "[tx][soroban]") } } -static TransactionFrameBasePtr -makeAddTx(TestContract const& contract, int64_t instructions, - TestAccount& source) -{ - auto fnName = "add"; - auto sc7 = makeI32(7); - auto sc16 = makeI32(16); - auto spec = SorobanInvocationSpec() - .setInstructions(instructions) - .setReadBytes(2'000) - .setInclusionFee(12345) - .setNonRefundableResourceFee(33'000) - .setRefundableResourceFee(100'000); - auto invocation = contract.prepareInvocation(fnName, {sc7, sc16}, spec); - return invocation.createTx(&source); -} - -static bool -wasmsAreCached(Application& app, std::vector const& wasms) -{ - auto moduleCache = app.getLedgerManager().getModuleCacheForTesting(); - for (auto const& wasm : wasms) - { - if (!moduleCache->contains_module( - app.getLedgerManager() - .getLastClosedLedgerHeader() - .header.ledgerVersion, - ::rust::Slice{wasm.data(), wasm.size()})) - { - return false; - } - } - return true; -} - -static int64_t const INVOKE_ADD_UNCACHED_COST_PASS = 500'000; -static int64_t const INVOKE_ADD_UNCACHED_COST_FAIL = 400'000; - -static int64_t const INVOKE_ADD_CACHED_COST_PASS = 300'000; -static int64_t const INVOKE_ADD_CACHED_COST_FAIL = 200'000; - -TEST_CASE("reusable module cache", "[soroban][modulecache]") -{ - VirtualClock clock; - Config cfg = getTestConfig(0, Config::TESTDB_BUCKET_DB_PERSISTENT); - - cfg.OVERRIDE_EVICTION_PARAMS_FOR_TESTING = true; - cfg.TESTING_STARTING_EVICTION_SCAN_LEVEL = 1; - - // This test uses/tests/requires the reusable module cache. - if (!protocolVersionStartsFrom( - cfg.LEDGER_PROTOCOL_VERSION, - REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION)) - return; - - // First upload some wasms - std::vector testWasms = {rust_bridge::get_test_wasm_add_i32(), - rust_bridge::get_test_wasm_err(), - rust_bridge::get_test_wasm_complex()}; - - std::vector contractHashes; - uint32_t ttl{0}; - { - txtest::SorobanTest stest(cfg); - ttl = stest.getNetworkCfg().stateArchivalSettings().minPersistentTTL; - for (auto const& wasm : testWasms) - { - - stest.deployWasmContract(wasm); - contractHashes.push_back(sha256(wasm)); - } - // Check the module cache got populated by the uploads. - REQUIRE(wasmsAreCached(stest.getApp(), contractHashes)); - } - - // Restart the application and check module cache gets populated in the new - // app. - auto app = createTestApplication(clock, cfg, false, true); - REQUIRE(wasmsAreCached(*app, contractHashes)); - - // Crank the app forward a while until the wasms are evicted. - CLOG_INFO(Ledger, "advancing for {} ledgers to evict wasms", ttl); - for (int i = 0; i < ttl; ++i) - { - txtest::closeLedger(*app); - } - // Check the modules got evicted. - REQUIRE(!wasmsAreCached(*app, contractHashes)); -} - -TEST_CASE("Module cache across protocol versions", "[tx][soroban][modulecache]") -{ - VirtualClock clock; - auto cfg = getTestConfig(0); - // Start in p22 - cfg.TESTING_UPGRADE_LEDGER_PROTOCOL_VERSION = - static_cast(REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION) - 1; - auto app = createTestApplication(clock, cfg); - - // Deploy and invoke contract in protocol 22 - SorobanTest test(app); - - size_t baseContractCount = app->getLedgerManager() - .getSorobanMetrics() - .mModuleCacheNumEntries.count(); - auto const& addContract = - test.deployWasmContract(rust_bridge::get_test_wasm_add_i32()); - - auto invoke = [&](int64_t instructions) -> bool { - auto tx = makeAddTx(addContract, instructions, test.getRoot()); - auto res = test.invokeTx(tx); - return isSuccessResult(res); - }; - - REQUIRE(!invoke(INVOKE_ADD_UNCACHED_COST_FAIL)); - REQUIRE(invoke(INVOKE_ADD_UNCACHED_COST_PASS)); - - // The upload should have triggered a single compilation for the p23+ module - // caches, which _exist_ in this version of stellar-core, and need to be - // populated on each upload, but are just not yet active. - int moduleCacheProtocolCount = - Config::CURRENT_LEDGER_PROTOCOL_VERSION - - static_cast(REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION) + 1; - REQUIRE(app->getLedgerManager() - .getSorobanMetrics() - .mModuleCacheNumEntries.count() == - baseContractCount + moduleCacheProtocolCount); - - // Upgrade to protocol 23 (with the reusable module cache) - auto upgradeTo23 = LedgerUpgrade{LEDGER_UPGRADE_VERSION}; - upgradeTo23.newLedgerVersion() = - static_cast(REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION); - executeUpgrade(*app, upgradeTo23); - - // We can now run the same contract with fewer instructions - REQUIRE(!invoke(INVOKE_ADD_CACHED_COST_FAIL)); - REQUIRE(invoke(INVOKE_ADD_CACHED_COST_PASS)); -} - -TEST_CASE("Module cache miss on immediate execution", - "[tx][soroban][modulecache]") -{ - VirtualClock clock; - auto cfg = getTestConfig(0); - - // This test uses/tests/requires the reusable module cache. - if (!protocolVersionStartsFrom( - cfg.LEDGER_PROTOCOL_VERSION, - REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION)) - return; - - auto app = createTestApplication(clock, cfg); - - SorobanTest test(app); - - size_t baseContractCount = app->getLedgerManager() - .getSorobanMetrics() - .mModuleCacheNumEntries.count(); - - auto wasm = rust_bridge::get_test_wasm_add_i32(); - - SECTION("separate ledger upload and execution") - { - // First upload the contract - auto const& contract = test.deployWasmContract(wasm); - - // Confirm upload succeeded and triggered compilation - REQUIRE(app->getLedgerManager() - .getSorobanMetrics() - .mModuleCacheNumEntries.count() == baseContractCount + 1); - - // Try to execute with low instructions since we can use cached module. - auto txFail = - makeAddTx(contract, INVOKE_ADD_CACHED_COST_FAIL, test.getRoot()); - REQUIRE(!isSuccessResult(test.invokeTx(txFail))); - - auto txPass = - makeAddTx(contract, INVOKE_ADD_CACHED_COST_PASS, test.getRoot()); - REQUIRE(isSuccessResult(test.invokeTx(txPass))); - } - - SECTION("same ledger upload and execution") - { - - // Here we're going to create 4 txs in the same ledger (so they have to - // come from 4 separate accounts). The 1st uploads a contract wasm, the - // 2nd creates a contract, and the 3rd and 4th run it. - // - // Because all 4 happen in the same ledger, there is no opportunity for - // the module cache to be populated between the upload and the - // execution. This should result in a cache miss and higher cost: the - // 3rd (invoking) tx fails and the 4th passes, but at the higher cost. - // - // Finally to confirm that the cache is populated, we run the same - // invocations in the next ledger and it should succeed at a lower cost. - - auto minbal = test.getApp().getLedgerManager().getLastMinBalance(1); - TestAccount A(test.getRoot().create("A", minbal * 1000)); - TestAccount B(test.getRoot().create("B", minbal * 1000)); - TestAccount C(test.getRoot().create("C", minbal * 1000)); - TestAccount D(test.getRoot().create("D", minbal * 1000)); - - // Transaction 1: the upload - auto uploadResources = defaultUploadWasmResourcesWithoutFootprint( - wasm, getLclProtocolVersion(test.getApp())); - auto uploadTx = makeSorobanWasmUploadTx(test.getApp(), A, wasm, - uploadResources, 1000); - - // Transaction 2: create contract - Hash contractHash = sha256(wasm); - ContractExecutable executable = makeWasmExecutable(contractHash); - Hash salt = sha256("salt"); - ContractIDPreimage contractPreimage = makeContractIDPreimage(B, salt); - HashIDPreimage hashPreimage = makeFullContractIdPreimage( - test.getApp().getNetworkID(), contractPreimage); - SCAddress contractId = makeContractAddress(xdrSha256(hashPreimage)); - auto createResources = SorobanResources(); - createResources.instructions = 5'000'000; - createResources.diskReadBytes = - static_cast(wasm.data.size() + 1000); - createResources.writeBytes = 1000; - auto createContractTx = - makeSorobanCreateContractTx(test.getApp(), B, contractPreimage, - executable, createResources, 1000); - - // Transaction 3: invocation (with inadequate instructions to succeed) - TestContract contract(test, contractId, - {contractCodeKey(contractHash), - makeContractInstanceKey(contractId)}); - auto invokeFailTx = - makeAddTx(contract, INVOKE_ADD_UNCACHED_COST_FAIL, C); - - // Transaction 4: invocation (with inadequate instructions to succeed) - auto invokePassTx = - makeAddTx(contract, INVOKE_ADD_UNCACHED_COST_PASS, C); - - // Run single ledger with all 4 txs. First 2 should pass, 3rd should - // fail, 4th should pass. - auto txResults = closeLedger( - *app, {uploadTx, createContractTx, invokeFailTx, invokePassTx}, - /*strictOrder=*/true); - - REQUIRE(txResults.results.size() == 4); - REQUIRE( - isSuccessResult(txResults.results[0].result)); // Upload succeeds - REQUIRE( - isSuccessResult(txResults.results[1].result)); // Create succeeds - REQUIRE(!isSuccessResult( - txResults.results[2].result)); // Invoke fails at 400k - REQUIRE(isSuccessResult( - txResults.results[3].result)); // Invoke passes at 500k - - // But if we try again in next ledger, the cost threshold should be - // lower. - auto invokeTxFail2 = - makeAddTx(contract, INVOKE_ADD_CACHED_COST_FAIL, C); - auto invokeTxPass2 = - makeAddTx(contract, INVOKE_ADD_CACHED_COST_PASS, D); - txResults = closeLedger(*app, {invokeTxFail2, invokeTxPass2}, - /*strictOrder=*/true); - REQUIRE(txResults.results.size() == 2); - REQUIRE(!isSuccessResult(txResults.results[0].result)); - REQUIRE(isSuccessResult(txResults.results[1].result)); - } -} - TEST_CASE("multiple version of same key in a single eviction scan", "[archival][soroban]") { @@ -7787,79 +7466,6 @@ TEST_CASE("disable eviction scan", "[archival][soroban]") assertTemp(false); } -TEST_CASE("Module cache cost with restore gaps", "[tx][soroban][modulecache]") -{ - VirtualClock clock; - auto cfg = getTestConfig(0); - - cfg.OVERRIDE_EVICTION_PARAMS_FOR_TESTING = true; - cfg.TESTING_STARTING_EVICTION_SCAN_LEVEL = 1; - - auto app = createTestApplication(clock, cfg); - auto& lm = app->getLedgerManager(); - SorobanTest test(app); - auto wasm = rust_bridge::get_test_wasm_add_i32(); - - auto minbal = lm.getLastMinBalance(1); - TestAccount A(test.getRoot().create("A", minbal * 1000)); - TestAccount B(test.getRoot().create("B", minbal * 1000)); - - auto contract = test.deployWasmContract(wasm); - auto contractKeys = contract.getKeys(); - - // Let contract expire - auto ttl = test.getNetworkCfg().stateArchivalSettings().minPersistentTTL; - auto proto = lm.getLastClosedLedgerHeader().header.ledgerVersion; - for (auto i = 0; i < ttl; ++i) - { - closeLedger(test.getApp()); - } - auto moduleCache = lm.getModuleCacheForTesting(); - auto const wasmHash = sha256(wasm); - REQUIRE(!moduleCache->contains_module( - proto, ::rust::Slice{wasmHash.data(), wasmHash.size()})); - - SECTION("scenario A: restore in one ledger, invoke in next") - { - // Restore contract in ledger N+1 - test.invokeRestoreOp(contractKeys, 40'493); - - // Invoke in ledger N+2 - // Because we have a gap between restore and invoke, the module cache - // will be populated and we need fewer instructions - auto tx1 = makeAddTx(contract, INVOKE_ADD_CACHED_COST_FAIL, A); - auto tx2 = makeAddTx(contract, INVOKE_ADD_CACHED_COST_PASS, B); - auto txResults = closeLedger(*app, {tx1, tx2}, /*strictOrder=*/true); - REQUIRE(txResults.results.size() == 2); - REQUIRE(!isSuccessResult(txResults.results[0].result)); - REQUIRE(isSuccessResult(txResults.results[1].result)); - } - - SECTION("scenario B: restore and invoke in same ledger") - { - // Combine restore and invoke in ledger N+1 - // First restore - SorobanResources resources; - resources.footprint.readWrite = contractKeys; - resources.instructions = 0; - resources.diskReadBytes = 10'000; - resources.writeBytes = 10'000; - auto resourceFee = 300'000 + 40'000 * contractKeys.size(); - auto tx1 = test.createRestoreTx(resources, 1'000, resourceFee); - - // Then try to invoke immediately - // Because there is no gap between restore and invoke, the module cache - // won't be populated and we need more instructions. - auto tx2 = makeAddTx(contract, INVOKE_ADD_UNCACHED_COST_FAIL, A); - auto tx3 = makeAddTx(contract, INVOKE_ADD_UNCACHED_COST_PASS, B); - auto txResults = - closeLedger(*app, {tx1, tx2, tx3}, /*strictOrder=*/true); - REQUIRE(txResults.results.size() == 3); - REQUIRE(isSuccessResult(txResults.results[0].result)); - REQUIRE(!isSuccessResult(txResults.results[1].result)); - REQUIRE(isSuccessResult(txResults.results[2].result)); - } -} static UnorderedMap readParallelMeta(std::string const& metaPath) { diff --git a/src/transactions/test/ModuleCacheTests.cpp b/src/transactions/test/ModuleCacheTests.cpp new file mode 100644 index 0000000000..5d66e712e8 --- /dev/null +++ b/src/transactions/test/ModuleCacheTests.cpp @@ -0,0 +1,416 @@ +// Copyright 2026 Stellar Development Foundation and contributors. Licensed +// under the Apache License, Version 2.0. See the COPYING file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +#include "ledger/LedgerTxn.h" +#include "ledger/LedgerTypeUtils.h" +#include "main/SettingsUpgradeUtils.h" +#include "rust/RustBridge.h" +#include "test/Catch2.h" +#include "test/TxTests.h" +#include "test/test.h" +#include "transactions/test/SorobanTxTestUtils.h" +#include "xdr/Stellar-ledger-entries.h" + +using namespace stellar; +using namespace stellar::txtest; + +namespace +{ + +TEST_CASE("Module cache", "[tx][soroban]") +{ + VirtualClock clock; + auto cfg = getTestConfig(0); + cfg.USE_CONFIG_FOR_GENESIS = false; + + auto app = createTestApplication(clock, cfg); + + auto upgrade20 = LedgerUpgrade{LEDGER_UPGRADE_VERSION}; + upgrade20.newLedgerVersion() = static_cast(SOROBAN_PROTOCOL_VERSION); + executeUpgrade(*app, upgrade20); + + // Test that repeated calls to a contract in a single transaction are + // cheaper due to caching introduced in v21. + auto sum_wasm = rust_bridge::get_test_wasm_sum_i32(); + auto add_wasm = rust_bridge::get_test_wasm_add_i32(); + SorobanTest test(app); + + auto const& sumContract = test.deployWasmContract(sum_wasm); + auto const& addContract = test.deployWasmContract(add_wasm); + + auto invocation = [&](int64_t instructions) -> bool { + auto fnName = "sum"; + auto scVec = makeVecSCVal({makeI32(1), makeI32(2), makeI32(3), + makeI32(4), makeI32(5), makeI32(6)}); + + auto invocationSpec = SorobanInvocationSpec() + .setInstructions(instructions) + .setReadBytes(2'000) + .setInclusionFee(12345); + + uint32_t const expectedRefund = 100'000; + auto spec = invocationSpec.setNonRefundableResourceFee(33'000) + .setRefundableResourceFee(expectedRefund); + + spec = spec.extendReadOnlyFootprint(addContract.getKeys()); + + auto invocation = sumContract.prepareInvocation( + fnName, {makeAddressSCVal(addContract.getAddress()), scVec}, spec, + expectedRefund); + auto tx = invocation.createTx(); + return isSuccessResult(test.invokeTx(tx)); + }; + + REQUIRE(invocation(7'000'000)); + REQUIRE(!invocation(6'000'000)); + + auto upgrade21 = LedgerUpgrade{LEDGER_UPGRADE_VERSION}; + upgrade21.newLedgerVersion() = static_cast(ProtocolVersion::V_21); + executeUpgrade(*app, upgrade21); + + // V21 Caching reduces the instructions required + REQUIRE(invocation(4'000'000)); +} + +static TransactionFrameBasePtr +makeAddTx(TestContract const& contract, int64_t instructions, + TestAccount& source) +{ + auto fnName = "add"; + auto sc7 = makeI32(7); + auto sc16 = makeI32(16); + auto spec = SorobanInvocationSpec() + .setInstructions(instructions) + .setReadBytes(2'000) + .setInclusionFee(12345) + .setNonRefundableResourceFee(33'000) + .setRefundableResourceFee(100'000); + auto invocation = contract.prepareInvocation(fnName, {sc7, sc16}, spec); + return invocation.createTx(&source); +} + +static bool +wasmsAreCached(Application& app, std::vector const& wasms) +{ + auto moduleCache = app.getLedgerManager().getModuleCacheForTesting(); + for (auto const& wasm : wasms) + { + if (!moduleCache->contains_module( + app.getLedgerManager() + .getLastClosedLedgerHeader() + .header.ledgerVersion, + ::rust::Slice{wasm.data(), wasm.size()})) + { + return false; + } + } + return true; +} + +static int64_t const INVOKE_ADD_UNCACHED_COST_PASS = 500'000; +static int64_t const INVOKE_ADD_UNCACHED_COST_FAIL = 400'000; + +static int64_t const INVOKE_ADD_CACHED_COST_PASS = 300'000; +static int64_t const INVOKE_ADD_CACHED_COST_FAIL = 200'000; + +TEST_CASE("reusable module cache", "[soroban][modulecache]") +{ + VirtualClock clock; + Config cfg = getTestConfig(0, Config::TESTDB_BUCKET_DB_PERSISTENT); + + cfg.OVERRIDE_EVICTION_PARAMS_FOR_TESTING = true; + cfg.TESTING_STARTING_EVICTION_SCAN_LEVEL = 1; + + // This test uses/tests/requires the reusable module cache. + if (!protocolVersionStartsFrom( + cfg.LEDGER_PROTOCOL_VERSION, + REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION)) + return; + + // First upload some wasms + std::vector testWasms = {rust_bridge::get_test_wasm_add_i32(), + rust_bridge::get_test_wasm_err(), + rust_bridge::get_test_wasm_complex()}; + + std::vector contractHashes; + uint32_t ttl{0}; + { + txtest::SorobanTest stest(cfg); + ttl = stest.getNetworkCfg().stateArchivalSettings().minPersistentTTL; + for (auto const& wasm : testWasms) + { + + stest.deployWasmContract(wasm); + contractHashes.push_back(sha256(wasm)); + } + // Check the module cache got populated by the uploads. + REQUIRE(wasmsAreCached(stest.getApp(), contractHashes)); + } + + // Restart the application and check module cache gets populated in the new + // app. + auto app = createTestApplication(clock, cfg, false, true); + REQUIRE(wasmsAreCached(*app, contractHashes)); + + // Crank the app forward a while until the wasms are evicted. + CLOG_INFO(Ledger, "advancing for {} ledgers to evict wasms", ttl); + for (int i = 0; i < ttl; ++i) + { + txtest::closeLedger(*app); + } + // Check the modules got evicted. + REQUIRE(!wasmsAreCached(*app, contractHashes)); +} + +TEST_CASE("Module cache across protocol versions", "[tx][soroban][modulecache]") +{ + VirtualClock clock; + auto cfg = getTestConfig(0); + // Start in p22 + cfg.TESTING_UPGRADE_LEDGER_PROTOCOL_VERSION = + static_cast(REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION) - 1; + auto app = createTestApplication(clock, cfg); + + // Deploy and invoke contract in protocol 22 + SorobanTest test(app); + + size_t baseContractCount = app->getLedgerManager() + .getSorobanMetrics() + .mModuleCacheNumEntries.count(); + auto const& addContract = + test.deployWasmContract(rust_bridge::get_test_wasm_add_i32()); + + auto invoke = [&](int64_t instructions) -> bool { + auto tx = makeAddTx(addContract, instructions, test.getRoot()); + auto res = test.invokeTx(tx); + return isSuccessResult(res); + }; + + REQUIRE(!invoke(INVOKE_ADD_UNCACHED_COST_FAIL)); + REQUIRE(invoke(INVOKE_ADD_UNCACHED_COST_PASS)); + + // The upload should have triggered a single compilation for the p23+ module + // caches, which _exist_ in this version of stellar-core, and need to be + // populated on each upload, but are just not yet active. + int moduleCacheProtocolCount = + Config::CURRENT_LEDGER_PROTOCOL_VERSION - + static_cast(REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION) + 1; + REQUIRE(app->getLedgerManager() + .getSorobanMetrics() + .mModuleCacheNumEntries.count() == + baseContractCount + moduleCacheProtocolCount); + + // Upgrade to protocol 23 (with the reusable module cache) + auto upgradeTo23 = LedgerUpgrade{LEDGER_UPGRADE_VERSION}; + upgradeTo23.newLedgerVersion() = + static_cast(REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION); + executeUpgrade(*app, upgradeTo23); + + // We can now run the same contract with fewer instructions + REQUIRE(!invoke(INVOKE_ADD_CACHED_COST_FAIL)); + REQUIRE(invoke(INVOKE_ADD_CACHED_COST_PASS)); +} + +TEST_CASE("Module cache miss on immediate execution", + "[tx][soroban][modulecache]") +{ + VirtualClock clock; + auto cfg = getTestConfig(0); + + // This test uses/tests/requires the reusable module cache. + if (!protocolVersionStartsFrom( + cfg.LEDGER_PROTOCOL_VERSION, + REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION)) + return; + + auto app = createTestApplication(clock, cfg); + + SorobanTest test(app); + + size_t baseContractCount = app->getLedgerManager() + .getSorobanMetrics() + .mModuleCacheNumEntries.count(); + + auto wasm = rust_bridge::get_test_wasm_add_i32(); + + SECTION("separate ledger upload and execution") + { + // First upload the contract + auto const& contract = test.deployWasmContract(wasm); + + // Confirm upload succeeded and triggered compilation + REQUIRE(app->getLedgerManager() + .getSorobanMetrics() + .mModuleCacheNumEntries.count() == baseContractCount + 1); + + // Try to execute with low instructions since we can use cached module. + auto txFail = + makeAddTx(contract, INVOKE_ADD_CACHED_COST_FAIL, test.getRoot()); + REQUIRE(!isSuccessResult(test.invokeTx(txFail))); + + auto txPass = + makeAddTx(contract, INVOKE_ADD_CACHED_COST_PASS, test.getRoot()); + REQUIRE(isSuccessResult(test.invokeTx(txPass))); + } + + SECTION("same ledger upload and execution") + { + + // Here we're going to create 4 txs in the same ledger (so they have to + // come from 4 separate accounts). The 1st uploads a contract wasm, the + // 2nd creates a contract, and the 3rd and 4th run it. + // + // Because all 4 happen in the same ledger, there is no opportunity for + // the module cache to be populated between the upload and the + // execution. This should result in a cache miss and higher cost: the + // 3rd (invoking) tx fails and the 4th passes, but at the higher cost. + // + // Finally to confirm that the cache is populated, we run the same + // invocations in the next ledger and it should succeed at a lower cost. + + auto minbal = test.getApp().getLedgerManager().getLastMinBalance(1); + TestAccount A(test.getRoot().create("A", minbal * 1000)); + TestAccount B(test.getRoot().create("B", minbal * 1000)); + TestAccount C(test.getRoot().create("C", minbal * 1000)); + TestAccount D(test.getRoot().create("D", minbal * 1000)); + + // Transaction 1: the upload + auto uploadResources = defaultUploadWasmResourcesWithoutFootprint( + wasm, getLclProtocolVersion(test.getApp())); + auto uploadTx = makeSorobanWasmUploadTx(test.getApp(), A, wasm, + uploadResources, 1000); + + // Transaction 2: create contract + Hash contractHash = sha256(wasm); + ContractExecutable executable = makeWasmExecutable(contractHash); + Hash salt = sha256("salt"); + ContractIDPreimage contractPreimage = makeContractIDPreimage(B, salt); + HashIDPreimage hashPreimage = makeFullContractIdPreimage( + test.getApp().getNetworkID(), contractPreimage); + SCAddress contractId = makeContractAddress(xdrSha256(hashPreimage)); + auto createResources = SorobanResources(); + createResources.instructions = 5'000'000; + createResources.diskReadBytes = + static_cast(wasm.data.size() + 1000); + createResources.writeBytes = 1000; + auto createContractTx = + makeSorobanCreateContractTx(test.getApp(), B, contractPreimage, + executable, createResources, 1000); + + // Transaction 3: invocation (with inadequate instructions to succeed) + TestContract contract(test, contractId, + {contractCodeKey(contractHash), + makeContractInstanceKey(contractId)}); + auto invokeFailTx = + makeAddTx(contract, INVOKE_ADD_UNCACHED_COST_FAIL, C); + + // Transaction 4: invocation (with inadequate instructions to succeed) + auto invokePassTx = + makeAddTx(contract, INVOKE_ADD_UNCACHED_COST_PASS, C); + + // Run single ledger with all 4 txs. First 2 should pass, 3rd should + // fail, 4th should pass. + auto txResults = closeLedger( + *app, {uploadTx, createContractTx, invokeFailTx, invokePassTx}, + /*strictOrder=*/true); + + REQUIRE(txResults.results.size() == 4); + REQUIRE( + isSuccessResult(txResults.results[0].result)); // Upload succeeds + REQUIRE( + isSuccessResult(txResults.results[1].result)); // Create succeeds + REQUIRE(!isSuccessResult( + txResults.results[2].result)); // Invoke fails at 400k + REQUIRE(isSuccessResult( + txResults.results[3].result)); // Invoke passes at 500k + + // But if we try again in next ledger, the cost threshold should be + // lower. + auto invokeTxFail2 = + makeAddTx(contract, INVOKE_ADD_CACHED_COST_FAIL, C); + auto invokeTxPass2 = + makeAddTx(contract, INVOKE_ADD_CACHED_COST_PASS, D); + txResults = closeLedger(*app, {invokeTxFail2, invokeTxPass2}, + /*strictOrder=*/true); + REQUIRE(txResults.results.size() == 2); + REQUIRE(!isSuccessResult(txResults.results[0].result)); + REQUIRE(isSuccessResult(txResults.results[1].result)); + } +} + +TEST_CASE("Module cache cost with restore gaps", "[tx][soroban][modulecache]") +{ + VirtualClock clock; + auto cfg = getTestConfig(0); + + cfg.OVERRIDE_EVICTION_PARAMS_FOR_TESTING = true; + cfg.TESTING_STARTING_EVICTION_SCAN_LEVEL = 1; + + auto app = createTestApplication(clock, cfg); + auto& lm = app->getLedgerManager(); + SorobanTest test(app); + auto wasm = rust_bridge::get_test_wasm_add_i32(); + + auto minbal = lm.getLastMinBalance(1); + TestAccount A(test.getRoot().create("A", minbal * 1000)); + TestAccount B(test.getRoot().create("B", minbal * 1000)); + + auto contract = test.deployWasmContract(wasm); + auto contractKeys = contract.getKeys(); + + // Let contract expire + auto ttl = test.getNetworkCfg().stateArchivalSettings().minPersistentTTL; + auto proto = lm.getLastClosedLedgerHeader().header.ledgerVersion; + for (auto i = 0; i < ttl; ++i) + { + closeLedger(test.getApp()); + } + auto moduleCache = lm.getModuleCacheForTesting(); + auto const wasmHash = sha256(wasm); + REQUIRE(!moduleCache->contains_module( + proto, ::rust::Slice{wasmHash.data(), wasmHash.size()})); + + SECTION("scenario A: restore in one ledger, invoke in next") + { + // Restore contract in ledger N+1 + test.invokeRestoreOp(contractKeys, 40'493); + + // Invoke in ledger N+2 + // Because we have a gap between restore and invoke, the module cache + // will be populated and we need fewer instructions + auto tx1 = makeAddTx(contract, INVOKE_ADD_CACHED_COST_FAIL, A); + auto tx2 = makeAddTx(contract, INVOKE_ADD_CACHED_COST_PASS, B); + auto txResults = closeLedger(*app, {tx1, tx2}, /*strictOrder=*/true); + REQUIRE(txResults.results.size() == 2); + REQUIRE(!isSuccessResult(txResults.results[0].result)); + REQUIRE(isSuccessResult(txResults.results[1].result)); + } + + SECTION("scenario B: restore and invoke in same ledger") + { + // Combine restore and invoke in ledger N+1 + // First restore + SorobanResources resources; + resources.footprint.readWrite = contractKeys; + resources.instructions = 0; + resources.diskReadBytes = 10'000; + resources.writeBytes = 10'000; + auto resourceFee = 300'000 + 40'000 * contractKeys.size(); + auto tx1 = test.createRestoreTx(resources, 1'000, resourceFee); + + // Then try to invoke immediately + // Because there is no gap between restore and invoke, the module cache + // won't be populated and we need more instructions. + auto tx2 = makeAddTx(contract, INVOKE_ADD_UNCACHED_COST_FAIL, A); + auto tx3 = makeAddTx(contract, INVOKE_ADD_UNCACHED_COST_PASS, B); + auto txResults = + closeLedger(*app, {tx1, tx2, tx3}, /*strictOrder=*/true); + REQUIRE(txResults.results.size() == 3); + REQUIRE(isSuccessResult(txResults.results[0].result)); + REQUIRE(!isSuccessResult(txResults.results[1].result)); + REQUIRE(isSuccessResult(txResults.results[2].result)); + } +} + +} // namespace From 59ec92748a08a63a5e3de1a3972496cae9485cd8 Mon Sep 17 00:00:00 2001 From: Graydon Hoare Date: Wed, 11 Mar 2026 18:32:49 -0700 Subject: [PATCH 3/6] Add test of module cache rebuilds. --- src/transactions/test/ModuleCacheTests.cpp | 90 ++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/transactions/test/ModuleCacheTests.cpp b/src/transactions/test/ModuleCacheTests.cpp index 5d66e712e8..0fb8ca24a6 100644 --- a/src/transactions/test/ModuleCacheTests.cpp +++ b/src/transactions/test/ModuleCacheTests.cpp @@ -163,6 +163,96 @@ TEST_CASE("reusable module cache", "[soroban][modulecache]") REQUIRE(!wasmsAreCached(*app, contractHashes)); } +TEST_CASE("module cache rebuild on incremental wasm uploads", + "[soroban][modulecache]") +{ + VirtualClock clock; + Config cfg = getTestConfig(0, Config::TESTDB_BUCKET_DB_PERSISTENT); + + // This test uses/tests/requires the reusable module cache. + if (!protocolVersionStartsFrom( + cfg.LEDGER_PROTOCOL_VERSION, + REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION)) + return; + + std::vector initialWasms = {rust_bridge::get_test_wasm_add_i32(), + rust_bridge::get_test_wasm_sum_i32()}; + std::vector additionalWasms = { + rust_bridge::get_test_wasm_err(), + rust_bridge::get_test_wasm_contract_data(), + rust_bridge::get_test_wasm_complex(), + rust_bridge::get_test_wasm_loadgen()}; + + std::vector initialHashes; + for (auto const& wasm : initialWasms) + { + initialHashes.push_back(sha256(wasm)); + } + + // Populate persistent DB with an initial set of wasm entries. + { + SorobanTest stest(cfg); + for (auto const& wasm : initialWasms) + { + stest.deployWasmContract(wasm); + } + REQUIRE(wasmsAreCached(stest.getApp(), initialHashes)); + } + + // Restart and verify startup compilation rebuilt cache from the DB state. + auto app = createTestApplication(clock, cfg, false, true); + REQUIRE(wasmsAreCached(*app, initialHashes)); + + auto& metrics = app->getLedgerManager().getSorobanMetrics(); + auto rebuildBytesAtStartup = metrics.mModuleCacheRebuildBytes.count(); + REQUIRE(rebuildBytesAtStartup > 0); + + auto uploader = app->getRoot(); + auto uploadWasm = [&](RustBuf const& wasm) { + auto uploadResources = defaultUploadWasmResourcesWithoutFootprint( + wasm, getLclProtocolVersion(*app)); + auto uploadTx = makeSorobanWasmUploadTx(*app, *uploader, wasm, + uploadResources, 1000); + auto txResults = closeLedger(*app, {uploadTx}); + REQUIRE(txResults.results.size() == 1); + REQUIRE(isSuccessResult(txResults.results[0].result)); + }; + + uint64_t uploadedRawBeforeTrigger = 0; + uint64_t uploadedRawAtTrigger = 0; + uint64_t uploadedRawBytes = 0; + bool rebuilt = false; + + for (auto const& wasm : additionalWasms) + { + uploadedRawBeforeTrigger = uploadedRawBytes; + uploadWasm(wasm); + uploadedRawBytes += wasm.data.size(); + + // If a rebuild was started by this upload, it completes on next + // ledger close at apply start. + closeLedger(*app); + + if (metrics.mModuleCacheRebuildBytes.count() != rebuildBytesAtStartup) + { + rebuilt = true; + uploadedRawAtTrigger = uploadedRawBytes; + break; + } + } + + REQUIRE(rebuilt); + + // maybeRebuildModuleCache rebuilds when current cache bytes exceed + // 2 * bytes from the previous full rebuild. Since this test starts from a + // freshly rebuilt cache, that means a rebuild should start after crossing + // roughly one rebuild's worth of additional wasm bytes. + REQUIRE(uploadedRawBeforeTrigger <= + static_cast(rebuildBytesAtStartup)); + REQUIRE(uploadedRawAtTrigger > + static_cast(rebuildBytesAtStartup)); +} + TEST_CASE("Module cache across protocol versions", "[tx][soroban][modulecache]") { VirtualClock clock; From 7ded6b989bbfcf8ef680a5067f77c71ec4e17bef Mon Sep 17 00:00:00 2001 From: Graydon Hoare Date: Tue, 17 Mar 2026 23:20:58 -0700 Subject: [PATCH 4/6] Update current baseline to account for moved tests --- .../InvokeHostFunctionTests.json | 13 ----- .../ModuleCacheTests.json | 49 +++++++++++++++++++ 2 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 test-tx-meta-baseline-current/ModuleCacheTests.json diff --git a/test-tx-meta-baseline-current/InvokeHostFunctionTests.json b/test-tx-meta-baseline-current/InvokeHostFunctionTests.json index 878c33b4b1..f6c97a65aa 100644 --- a/test-tx-meta-baseline-current/InvokeHostFunctionTests.json +++ b/test-tx-meta-baseline-current/InvokeHostFunctionTests.json @@ -34,19 +34,6 @@ 26 ], "Failed write still causes ttl observation" : [ "+cR3oq2qY0I=", "qJ8KIK1AUN8=", "mvtbiiIU4+E=", "tLtF1ukXymY=" ], - "Module cache" : [ "MR6BJ3xmn2c=" ], - "Module cache across protocol versions" : [ "bKDF6V5IzTo=" ], - "Module cache cost with restore gaps" : - [ - "+cR3oq2qY0I=", - "aNLDpx+d0IQ=", - "xUGbgZ/E+CU=", - "+cR3oq2qY0I=", - "aNLDpx+d0IQ=", - "xUGbgZ/E+CU=" - ], - "Module cache miss on immediate execution" : [ "+cR3oq2qY0I=", "+cR3oq2qY0I=" ], - "Module cache miss on immediate execution|same ledger upload and execution" : [ "aNLDpx+d0IQ=", "xUGbgZ/E+CU=", "c5cI6O8UE2k=", "U18PA9C2bOU=" ], "Native stellar asset contract" : [ "+cR3oq2qY0I=", diff --git a/test-tx-meta-baseline-current/ModuleCacheTests.json b/test-tx-meta-baseline-current/ModuleCacheTests.json new file mode 100644 index 0000000000..235eaea5b9 --- /dev/null +++ b/test-tx-meta-baseline-current/ModuleCacheTests.json @@ -0,0 +1,49 @@ + +{ + "!cfg protocol version" : 26, + "!rng seed" : 12345, + "!test all versions" : true, + "!versions to test" : + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "Module cache" : [ "MR6BJ3xmn2c=" ], + "Module cache across protocol versions" : [ "bKDF6V5IzTo=" ], + "Module cache cost with restore gaps" : + [ + "+cR3oq2qY0I=", + "aNLDpx+d0IQ=", + "xUGbgZ/E+CU=", + "+cR3oq2qY0I=", + "aNLDpx+d0IQ=", + "xUGbgZ/E+CU=" + ], + "Module cache miss on immediate execution" : [ "+cR3oq2qY0I=", "+cR3oq2qY0I=" ], + "Module cache miss on immediate execution|same ledger upload and execution" : [ "aNLDpx+d0IQ=", "xUGbgZ/E+CU=", "c5cI6O8UE2k=", "U18PA9C2bOU=" ] +} From 08651737aa475636aee78efe1895e342f1c3fe25 Mon Sep 17 00:00:00 2001 From: Graydon Hoare Date: Thu, 19 Mar 2026 21:17:29 -0700 Subject: [PATCH 5/6] Fix vnext module cache test now that compilation skips existing entries --- src/transactions/test/ModuleCacheTests.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/transactions/test/ModuleCacheTests.cpp b/src/transactions/test/ModuleCacheTests.cpp index 0fb8ca24a6..72bc3cdf03 100644 --- a/src/transactions/test/ModuleCacheTests.cpp +++ b/src/transactions/test/ModuleCacheTests.cpp @@ -286,6 +286,12 @@ TEST_CASE("Module cache across protocol versions", "[tx][soroban][modulecache]") int moduleCacheProtocolCount = Config::CURRENT_LEDGER_PROTOCOL_VERSION - static_cast(REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION) + 1; +#ifdef ENABLE_NEXT_PROTOCOL_VERSION_UNSAFE_FOR_PRODUCTION + // When we build with vnext, we direct (over in the rust bridge) the module + // cache code to use the same module cache for p26 and p27, which means + // there's one less copy of each module. + moduleCacheProtocolCount -= 1; +#endif REQUIRE(app->getLedgerManager() .getSorobanMetrics() .mModuleCacheNumEntries.count() == From 34533d08821264ecb9f045499bb4b8894f112fb4 Mon Sep 17 00:00:00 2001 From: Graydon Hoare Date: Thu, 19 Mar 2026 21:57:56 -0700 Subject: [PATCH 6/6] Update vnext baseline to account for moved tests --- .../InvokeHostFunctionTests.json | 13 ----- .../ModuleCacheTests.json | 50 +++++++++++++++++++ 2 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 test-tx-meta-baseline-next/ModuleCacheTests.json diff --git a/test-tx-meta-baseline-next/InvokeHostFunctionTests.json b/test-tx-meta-baseline-next/InvokeHostFunctionTests.json index a842318790..ec27886c57 100644 --- a/test-tx-meta-baseline-next/InvokeHostFunctionTests.json +++ b/test-tx-meta-baseline-next/InvokeHostFunctionTests.json @@ -35,19 +35,6 @@ 27 ], "Failed write still causes ttl observation" : [ "+cR3oq2qY0I=", "qJ8KIK1AUN8=", "mvtbiiIU4+E=", "tLtF1ukXymY=" ], - "Module cache" : [ "MR6BJ3xmn2c=" ], - "Module cache across protocol versions" : [ "bKDF6V5IzTo=" ], - "Module cache cost with restore gaps" : - [ - "+cR3oq2qY0I=", - "aNLDpx+d0IQ=", - "xUGbgZ/E+CU=", - "+cR3oq2qY0I=", - "aNLDpx+d0IQ=", - "xUGbgZ/E+CU=" - ], - "Module cache miss on immediate execution" : [ "+cR3oq2qY0I=", "+cR3oq2qY0I=" ], - "Module cache miss on immediate execution|same ledger upload and execution" : [ "aNLDpx+d0IQ=", "xUGbgZ/E+CU=", "c5cI6O8UE2k=", "U18PA9C2bOU=" ], "Native stellar asset contract" : [ "+cR3oq2qY0I=", diff --git a/test-tx-meta-baseline-next/ModuleCacheTests.json b/test-tx-meta-baseline-next/ModuleCacheTests.json new file mode 100644 index 0000000000..e853452f22 --- /dev/null +++ b/test-tx-meta-baseline-next/ModuleCacheTests.json @@ -0,0 +1,50 @@ + +{ + "!cfg protocol version" : 27, + "!rng seed" : 12345, + "!test all versions" : true, + "!versions to test" : + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "Module cache" : [ "MR6BJ3xmn2c=" ], + "Module cache across protocol versions" : [ "bKDF6V5IzTo=" ], + "Module cache cost with restore gaps" : + [ + "+cR3oq2qY0I=", + "aNLDpx+d0IQ=", + "xUGbgZ/E+CU=", + "+cR3oq2qY0I=", + "aNLDpx+d0IQ=", + "xUGbgZ/E+CU=" + ], + "Module cache miss on immediate execution" : [ "+cR3oq2qY0I=", "+cR3oq2qY0I=" ], + "Module cache miss on immediate execution|same ledger upload and execution" : [ "aNLDpx+d0IQ=", "xUGbgZ/E+CU=", "c5cI6O8UE2k=", "U18PA9C2bOU=" ] +}