From 7fe180bd28973c65a58509a3c81fd86336f2ecda Mon Sep 17 00:00:00 2001 From: Ming Date: Fri, 5 Jun 2026 17:20:04 +0700 Subject: [PATCH] fix(hermes): share VAA bytes across a slot's proofs via Arc `construct_message_states_proofs` cloned the full VAA (~1-2KB) into the `WormholeMerkleMessageProof` of every message in a slot. Since a slot contains all price feeds (~6000), the same VAA was duplicated ~6000x per slot and then retained `cache-size-slots` deep in the message cache. With the default `cache-size-slots = 1600` this dominated memory: a Hermes instance subscribed to all Pythnet feeds grew to ~20GB of resident memory (and kept climbing). The VAA bytes accounted for the bulk of it. Store the VAA behind an `Arc` so every proof in a slot shares a single allocation (refcount bump instead of a deep copy). The bytes are cloned once when building update data for an API response, which is a read-time/per-request cost rather than a stored, per-feed cost. Measured on a node subscribed to all mainnet feeds (cache-size-slots=1600): resident memory plateaued at ~6.5GB instead of climbing past 20GB. --- .../src/state/aggregate/wormhole_merkle.rs | 16 ++++++++++++---- apps/hermes/server/src/state/cache.rs | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/hermes/server/src/state/aggregate/wormhole_merkle.rs b/apps/hermes/server/src/state/aggregate/wormhole_merkle.rs index 5355b270fb..c1a983666e 100644 --- a/apps/hermes/server/src/state/aggregate/wormhole_merkle.rs +++ b/apps/hermes/server/src/state/aggregate/wormhole_merkle.rs @@ -16,6 +16,7 @@ use { v1::{AccumulatorUpdateData, MerklePriceUpdate, Proof, WormholeMerkleRoot}, }, }, + std::sync::Arc, }; // The number of messages in a single update data is defined as a @@ -31,7 +32,10 @@ pub struct WormholeMerkleState { #[derive(Clone, PartialEq, Debug)] pub struct WormholeMerkleMessageProof { pub proof: MerklePath, - pub vaa: VaaBytes, + /// Shared VAA for the message's slot. All messages in a slot share the same + /// VAA, so we store it behind an `Arc` to avoid cloning the (~1-2KB) bytes + /// once per feed (which, across ~6000 feeds × cache depth, dominated memory). + pub vaa: Arc, } #[derive(Clone, PartialEq, Debug)] @@ -83,12 +87,16 @@ pub fn construct_message_states_proofs( return Err(anyhow!("Invalid merkle root")); } + // Clone the VAA bytes once for the whole slot; every message proof shares it + // via the `Arc` (refcount bump only), instead of duplicating the bytes per feed. + let shared_vaa = Arc::new(wormhole_merkle_state.vaa.clone()); + accumulator_messages .raw_messages .iter() .map(|m| { Ok(WormholeMerkleMessageProof { - vaa: wormhole_merkle_state.vaa.clone(), + vaa: shared_vaa.clone(), proof: merkle_acc .prove(m.as_ref()) .ok_or(anyhow!("Failed to prove message"))?, @@ -107,7 +115,7 @@ pub fn construct_update_data(mut messages: Vec) -> Re while let Some(message) = iter.next() { let slot = message.slot; - let vaa = message.proof.vaa; + let vaa: VaaBytes = (*message.proof.vaa).clone(); let mut updates = vec![MerklePriceUpdate { message: message.raw_message.into(), proof: message.proof.proof, @@ -174,7 +182,7 @@ mod test { RawMessageWithMerkleProof { slot: slot_and_pubtime, proof: WormholeMerkleMessageProof { - vaa: vec![], + vaa: std::sync::Arc::new(vec![]), proof: MerklePath::default(), }, raw_message: to_vec::<_, byteorder::BE>(&price_feed_message).unwrap(), diff --git a/apps/hermes/server/src/state/cache.rs b/apps/hermes/server/src/state/cache.rs index 0574a2e1e3..a9d3fa485a 100644 --- a/apps/hermes/server/src/state/cache.rs +++ b/apps/hermes/server/src/state/cache.rs @@ -352,7 +352,7 @@ mod test { received_at: publish_time, proof_set: ProofSet { wormhole_merkle_proof: WormholeMerkleMessageProof { - vaa: vec![], + vaa: std::sync::Arc::new(vec![]), proof: MerklePath::::new(vec![]), }, },