Skip to content

Commit 25d2d66

Browse files
fix(forester): retain proofs after warmup timeout
1 parent 821637e commit 25d2d66

2 files changed

Lines changed: 199 additions & 47 deletions

File tree

‎forester/src/processor/v2/proof_cache.rs‎

Lines changed: 121 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ pub struct ProofCache {
2626
tree: Pubkey,
2727
base_root: [u8; 32],
2828
proofs: VecDeque<CachedProof>,
29+
/// Proofs that completed after the cache warm-up deadline. These are kept
30+
/// separately because a newer warm-up must not discard potentially useful
31+
/// work from an older eligibility window.
32+
late_proofs: VecDeque<CachedProof>,
2933
warming_proofs: BTreeMap<u64, CachedProof>,
3034
is_warming: bool,
3135
warming_generation: u64,
@@ -38,6 +42,7 @@ impl ProofCache {
3842
tree,
3943
base_root: [0u8; 32],
4044
proofs: VecDeque::new(),
45+
late_proofs: VecDeque::new(),
4146
warming_proofs: BTreeMap::new(),
4247
is_warming: false,
4348
warming_generation: 0,
@@ -160,62 +165,103 @@ impl ProofCache {
160165
);
161166
}
162167

163-
pub fn take_if_valid(&mut self, current_root: &[u8; 32]) -> Option<Vec<CachedProof>> {
164-
if self.proofs.is_empty() || self.is_warming {
165-
return None;
168+
pub fn add_late_proof(
169+
&mut self,
170+
seq: u64,
171+
old_root: [u8; 32],
172+
new_root: [u8; 32],
173+
instruction: BatchInstruction,
174+
) {
175+
let duplicate = self
176+
.proofs
177+
.iter()
178+
.chain(self.warming_proofs.values())
179+
.chain(self.late_proofs.iter())
180+
.any(|proof| proof.old_root == old_root && proof.new_root == new_root);
181+
if duplicate {
182+
debug!(
183+
tree = %self.tree,
184+
seq,
185+
"Ignoring duplicate late proof"
186+
);
187+
return;
166188
}
167189

168-
let mut skipped = 0;
169-
while let Some(proof) = self.proofs.front() {
170-
if proof.old_root == *current_root {
171-
break;
172-
}
173-
if proof.new_root == *current_root {
174-
self.proofs.pop_front();
175-
skipped += 1;
176-
continue;
190+
let items = instruction.items_count();
191+
self.late_proofs.push_back(CachedProof {
192+
seq,
193+
old_root,
194+
new_root,
195+
instruction,
196+
items,
197+
});
198+
199+
while self.late_proofs.len() > self.max_proofs {
200+
if let Some(dropped) = self.late_proofs.pop_front() {
201+
warn!(
202+
tree = %self.tree,
203+
seq = dropped.seq,
204+
max = self.max_proofs,
205+
"Late proof cache limit reached; dropping oldest candidate"
206+
);
177207
}
178-
self.proofs.pop_front();
179-
skipped += 1;
180208
}
181209

210+
debug!(
211+
tree = %self.tree,
212+
seq,
213+
late_proofs_cached = self.late_proofs.len(),
214+
"Retained proof that completed after cache warm-up deadline"
215+
);
216+
}
217+
218+
pub fn take_if_valid(&mut self, current_root: &[u8; 32]) -> Option<Vec<CachedProof>> {
219+
if self.is_warming || (self.proofs.is_empty() && self.late_proofs.is_empty()) {
220+
return None;
221+
}
222+
223+
// Treat both synchronously warmed and later results as candidates. Proof
224+
// completion order is not guaranteed, so build the usable chain by roots
225+
// instead of sequence number. Unmatched candidates stay cached: a missing
226+
// predecessor may still arrive, or the on-chain root may advance to them.
227+
let mut candidates = VecDeque::with_capacity(self.proofs.len() + self.late_proofs.len());
228+
candidates.append(&mut self.proofs);
229+
candidates.append(&mut self.late_proofs);
230+
231+
let before = candidates.len();
232+
candidates.retain(|proof| proof.new_root != *current_root);
233+
let skipped = before - candidates.len();
234+
182235
if skipped > 0 {
183236
debug!(
184237
"Skipped {} stale cached proofs for tree {} (on-chain already advanced)",
185238
skipped, self.tree
186239
);
187240
}
188241

189-
if self.proofs.is_empty() {
190-
debug!(
191-
"Cache empty after skipping stale proofs for tree {} (current_root {:?})",
192-
self.tree,
193-
&current_root[..4]
194-
);
195-
return None;
196-
}
197-
198242
let mut expected = *current_root;
199243
let mut taken: Vec<CachedProof> = Vec::new();
200244

201-
while let Some(proof) = self.proofs.pop_front() {
202-
if proof.old_root != expected {
203-
warn!(
204-
"Cache chain broken for tree {} at seq {}: expected root {:?}, got {:?}. Dropping remaining {} proofs.",
205-
self.tree,
206-
proof.seq,
207-
&expected[..4],
208-
&proof.old_root[..4],
209-
self.proofs.len()
210-
);
211-
self.proofs.clear();
212-
break;
213-
}
245+
while let Some(position) = candidates
246+
.iter()
247+
.position(|proof| proof.old_root == expected)
248+
{
249+
let proof = candidates
250+
.remove(position)
251+
.expect("candidate position was found");
214252
expected = proof.new_root;
215253
taken.push(proof);
216254
}
217255

256+
self.late_proofs = candidates;
257+
218258
if taken.is_empty() {
259+
debug!(
260+
tree = %self.tree,
261+
current_root = ?&current_root[..4],
262+
retained_candidates = self.late_proofs.len(),
263+
"No cached proof currently links to the on-chain root"
264+
);
219265
return None;
220266
}
221267

@@ -235,11 +281,11 @@ impl ProofCache {
235281
}
236282

237283
pub fn len(&self) -> usize {
238-
self.proofs.len()
284+
self.proofs.len() + self.late_proofs.len()
239285
}
240286

241287
pub fn is_empty(&self) -> bool {
242-
self.proofs.is_empty()
288+
self.proofs.is_empty() && self.late_proofs.is_empty()
243289
}
244290

245291
pub fn is_warming(&self) -> bool {
@@ -252,6 +298,7 @@ impl ProofCache {
252298

253299
pub fn clear(&mut self) {
254300
self.proofs.clear();
301+
self.late_proofs.clear();
255302
self.warming_proofs.clear();
256303
self.is_warming = false;
257304
}
@@ -301,6 +348,19 @@ impl SharedProofCache {
301348
self.inner.lock().await.take_if_valid(current_root)
302349
}
303350

351+
pub async fn add_late_proof(
352+
&self,
353+
seq: u64,
354+
old_root: [u8; 32],
355+
new_root: [u8; 32],
356+
instruction: BatchInstruction,
357+
) {
358+
self.inner
359+
.lock()
360+
.await
361+
.add_late_proof(seq, old_root, new_root, instruction);
362+
}
363+
304364
pub async fn is_warming(&self) -> bool {
305365
self.inner.lock().await.is_warming()
306366
}
@@ -413,4 +473,27 @@ mod tests {
413473
new_warmup.finish().await;
414474
assert!(!cache.is_warming().await);
415475
}
476+
477+
#[tokio::test]
478+
async fn late_proofs_are_linked_by_root_when_they_arrive_out_of_order() {
479+
let cache = Arc::new(SharedProofCache::new(Pubkey::new_unique()));
480+
let root_1 = [1u8; 32];
481+
let root_2 = [2u8; 32];
482+
let root_3 = [3u8; 32];
483+
484+
cache
485+
.add_late_proof(1, root_2, root_3, BatchInstruction::Append(Vec::new()))
486+
.await;
487+
cache
488+
.add_late_proof(0, root_1, root_2, BatchInstruction::Append(Vec::new()))
489+
.await;
490+
491+
let proofs = cache.take_if_valid(&root_1).await.unwrap();
492+
assert_eq!(proofs.len(), 2);
493+
assert_eq!(proofs[0].old_root, root_1);
494+
assert_eq!(proofs[0].new_root, root_2);
495+
assert_eq!(proofs[1].old_root, root_2);
496+
assert_eq!(proofs[1].new_root, root_3);
497+
assert!(cache.is_empty().await);
498+
}
416499
}

‎forester/src/processor/v2/tx_sender.rs‎

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const FLUSH_MARGIN_SLOTS: u64 = 10;
1616
/// Late proofs are an optimization. Do not let a proof job that never releases
1717
/// its sender keep the tree cache in the warming state indefinitely.
1818
const LATE_PROOF_COLLECTION_TIMEOUT: Duration = Duration::from_secs(30);
19+
/// Keep receiving proof results after releasing the warming lock. This matches
20+
/// the default maximum prover wait and bounds detached collector lifetime when
21+
/// a producer retains its sender indefinitely.
22+
const LATE_PROOF_RETENTION_TIMEOUT: Duration = Duration::from_secs(600);
1923

2024
use light_batched_merkle_tree::merkle_tree::{
2125
InstructionDataBatchAppendInputs, InstructionDataBatchNullifyInputs,
@@ -603,6 +607,7 @@ impl<R: Rpc> TxSender<R> {
603607
proof_rx,
604608
self.context.merkle_tree,
605609
LATE_PROOF_COLLECTION_TIMEOUT,
610+
LATE_PROOF_RETENTION_TIMEOUT,
606611
));
607612

608613
saved
@@ -615,6 +620,7 @@ fn spawn_late_proof_collector(
615620
mut proof_rx: mpsc::Receiver<ProofJobResult>,
616621
tree: solana_sdk::pubkey::Pubkey,
617622
collection_timeout: Duration,
623+
retention_timeout: Duration,
618624
) -> JoinHandle<usize> {
619625
tokio::spawn(async move {
620626
let mut saved = 0usize;
@@ -639,21 +645,69 @@ fn spawn_late_proof_collector(
639645
}
640646
};
641647

642-
if tokio::time::timeout(collection_timeout, collection)
648+
let collection_timed_out = tokio::time::timeout(collection_timeout, collection)
643649
.await
644-
.is_err()
645-
{
650+
.is_err();
651+
652+
// Release the tree immediately at the scheduling deadline. The
653+
// receiver remains alive below so completed work is still retained.
654+
warmup.finish().await;
655+
656+
if collection_timed_out {
646657
warn!(
647658
tree = %tree,
648659
timeout_ms = collection_timeout.as_millis(),
649660
late_proofs_cached = saved,
650-
"Late proof collection timed out; releasing cache warming state"
661+
"Late proof collection timed out; released cache warming state and retaining later results"
651662
);
663+
664+
let mut retained = 0usize;
665+
let retention = async {
666+
while let Some(result) = proof_rx.recv().await {
667+
match result.result {
668+
Ok(instruction) => {
669+
cache
670+
.add_late_proof(
671+
result.seq,
672+
result.old_root,
673+
result.new_root,
674+
instruction,
675+
)
676+
.await;
677+
saved += 1;
678+
retained += 1;
679+
}
680+
Err(error) => {
681+
warn!(
682+
tree = %tree,
683+
seq = result.seq,
684+
error = %error,
685+
"Proof failed after cache warm-up deadline"
686+
);
687+
}
688+
}
689+
}
690+
};
691+
692+
if tokio::time::timeout(retention_timeout, retention)
693+
.await
694+
.is_err()
695+
{
696+
warn!(
697+
tree = %tree,
698+
timeout_ms = retention_timeout.as_millis(),
699+
retained_late_proofs = retained,
700+
"Late proof retention window expired"
701+
);
702+
} else if retained > 0 {
703+
info!(
704+
tree = %tree,
705+
retained_late_proofs = retained,
706+
"Retained proofs that completed after cache warm-up deadline"
707+
);
708+
}
652709
}
653710

654-
// This must run on channel closure and timeout so a retained sender can
655-
// never make the tree permanently ineligible for future proof work.
656-
warmup.finish().await;
657711
let total_cached_proofs = cache.len().await;
658712
info!(
659713
tree = %tree,
@@ -696,6 +750,7 @@ mod tests {
696750
proof_rx,
697751
tree,
698752
LATE_PROOF_COLLECTION_TIMEOUT,
753+
LATE_PROOF_RETENTION_TIMEOUT,
699754
);
700755

701756
assert!(cache.is_warming().await);
@@ -727,6 +782,7 @@ mod tests {
727782
proof_rx,
728783
tree,
729784
LATE_PROOF_COLLECTION_TIMEOUT,
785+
LATE_PROOF_RETENTION_TIMEOUT,
730786
);
731787
proof_tx
732788
.send(ProofJobResult {
@@ -761,11 +817,24 @@ mod tests {
761817
proof_rx,
762818
tree,
763819
Duration::from_millis(20),
820+
Duration::from_secs(1),
764821
);
765822

766823
assert!(cache.is_warming().await);
767-
assert_eq!(collector.await.unwrap(), 0);
824+
tokio::time::sleep(Duration::from_millis(40)).await;
768825
assert!(!cache.is_warming().await);
769-
assert!(proof_tx.is_closed());
826+
assert!(!proof_tx.is_closed());
827+
828+
let next_root = [6u8; 32];
829+
proof_tx
830+
.send(proof_result(0, base_root, next_root))
831+
.await
832+
.unwrap();
833+
drop(proof_tx);
834+
835+
assert_eq!(collector.await.unwrap(), 1);
836+
let cached = cache.take_if_valid(&base_root).await.unwrap();
837+
assert_eq!(cached.len(), 1);
838+
assert_eq!(cached[0].new_root, next_root);
770839
}
771840
}

0 commit comments

Comments
 (0)