Skip to content

Commit c53f1c6

Browse files
committed
fix dedup invariants, add reconnect-blocks e2e scenario
1 parent 8237104 commit c53f1c6

9 files changed

Lines changed: 222 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ The minor version will be incremented upon a breaking change and the patch versi
1010

1111
## [Unreleased]
1212

13+
### Misc
14+
15+
- client: extract replay policy from AutoReconnect, fix dedup invariants, add reconnect-blocks e2e scenario ([#811](https://github.com/rpcpool/yellowstone-grpc/pull/811))
16+
1317
## 2026-07-08
1418

1519
- yellowstone-grpc-geyser 14.2.0

Cargo.lock

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ spl-token-2022-interface = "2.1.0"
128128

129129
# Yellowstone
130130
yellowstone-block-machine = { version = "0.8.0", default-features = false }
131+
yellowstone-grpc-client = { path = "yellowstone-grpc-client", version = "13.3.0" }
131132
yellowstone-grpc-geyser = { path = "yellowstone-grpc-geyser", version = "14.2.0" }
132-
yellowstone-grpc-client = { path = "yellowstone-grpc-client", version = "13.2.0" }
133133
yellowstone-grpc-proto = { path = "yellowstone-grpc-proto", version = "12.5.0", default-features = false }
134134
yellowstone-grpc-e2e-macros = { path = "yellowstone-grpc-e2e-macros", version = "0.1.0" }
135135
yellowstone-grpc-tools = { path = "yellowstone-grpc-tools", version = "0.1.0" }
@@ -138,6 +138,8 @@ yellowstone-grpc-tools = { path = "yellowstone-grpc-tools", version = "0.1.0" }
138138
tokio-rustls = "0.26.0"
139139
x509-parser = "0.18.0"
140140

141+
[patch.crates-io]
142+
yellowstone-grpc-proto = { path = "yellowstone-grpc-proto" }
141143

142144
[workspace.lints.clippy]
143145
clone_on_ref_ptr = "deny"

yellowstone-grpc-client/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "yellowstone-grpc-client"
3-
version = "13.2.0"
3+
version = "13.3.0"
44
authors = { workspace = true }
55
edition = { workspace = true }
66
description = "Yellowstone gRPC Geyser Simple Client"

yellowstone-grpc-client/src/dedup.rs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub(crate) enum Observation {
3030
struct SealedSlot {
3131
blockhash: Option<String>,
3232
statuses: HashSet<i32>,
33+
keys: HashSet<DedupKey>,
3334
}
3435

3536
struct ReplayBuffer<T> {
@@ -240,16 +241,13 @@ impl DedupState {
240241
pub(crate) fn observe(&mut self, slot: u64, key: DedupKey) -> Observation {
241242
match key {
242243
DedupKey::Slot(status) => {
243-
// CreatedBank means a bank was just created for this slot. Wipe any
244-
// prior state unconditionally: on first creation this is a near no-op,
245-
// on a repeated creation it recovers from a rollback.
246-
//
247-
// Rollback detection depends on receiving CreatedBank. The server only
248-
// emits interslot statuses (CreatedBank, etc.) to filters with
249-
// interslot_updates=true (see FilterSlots::get_updates server-side).
250-
// Without it this wipe is dormant. That is by design: we do not inject
251-
// the interslot flag; the user opts in by accepting the extra traffic.
244+
// CreatedBank wipes prior state to recover from a rollback.
245+
// During replay, CreatedBank for a sealed slot is a replay
246+
// artifact, not a genuine rollback.
252247
if status == CREATED_BANK_STATUS {
248+
if self.sealed.contains_key(&slot) {
249+
return Observation::Duplicate;
250+
}
253251
self.clear_slot(slot);
254252
}
255253

@@ -289,6 +287,7 @@ impl DedupState {
289287
SealedSlot {
290288
blockhash: Some(blockhash),
291289
statuses: state.statuses,
290+
keys: HashSet::new(),
292291
},
293292
);
294293
self.prune();
@@ -297,9 +296,13 @@ impl DedupState {
297296

298297
// Accounts, transactions, entries, blocks, etc.
299298
payload => {
300-
// Sealed slot: hold for quarantine. The verdict comes when the
301-
// replayed BlockMeta arrives; until then we buffer without deduping.
302-
if self.sealed.contains_key(&slot) {
299+
// Sealed slot: check if we already forwarded this event before
300+
// the disconnect. If so, filter it. Otherwise quarantine it
301+
// until the replayed BlockMeta arrives with the verdict.
302+
if let Some(sealed) = self.sealed.get(&slot) {
303+
if sealed.keys.contains(&payload) {
304+
return Observation::Duplicate;
305+
}
303306
return Observation::Replay;
304307
}
305308

@@ -337,6 +340,7 @@ impl DedupState {
337340
SealedSlot {
338341
blockhash: None,
339342
statuses: state.statuses,
343+
keys: state.keys,
340344
},
341345
);
342346
}

yellowstone-grpc-client/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ pub mod reconnect;
44
use {
55
crate::{
66
dedup::DEFAULT_SLOT_RETENTION,
7-
reconnect::{ReplayPolicy, TonicGeyserClientOptions, AUTORECONNECT_FILTER_KEY},
7+
reconnect::{TonicGeyserClientOptions, AUTORECONNECT_FILTER_KEY},
88
},
99
arc_swap::ArcSwap,
1010
bytes::Bytes,
@@ -44,7 +44,7 @@ use {
4444
pub use {
4545
crate::{
4646
dedup::{DedupState, DedupStream},
47-
reconnect::{AutoReconnect, Backoff, GrpcConnector, TonicGrpcConnector},
47+
reconnect::{AutoReconnect, Backoff, GrpcConnector, ReplayPolicy, TonicGrpcConnector},
4848
},
4949
tonic::{service::Interceptor, transport::ClientTlsConfig},
5050
};

yellowstone-grpc-e2e-test/Cargo.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,24 @@ publish = false
1010

1111
[dependencies]
1212
anyhow = "1"
13-
futures = { workspace = true }
13+
arc-swap = { workspace = true }
1414
clap = { workspace = true, features = ["derive"] }
1515
dotenvy = { workspace = true }
1616
humantime-serde = { workspace = true }
1717
inventory = { workspace = true }
1818
log = { workspace = true }
1919
env_logger = { workspace = true }
20+
futures = { workspace = true }
2021
tokio = { workspace = true, features = ["full"] }
2122
tokio-stream = { workspace = true }
23+
tonic = { workspace = true }
2224
tower = { workspace = true }
2325
hyper = { workspace = true }
2426
hyper-util = { workspace = true }
2527
yellowstone-grpc-e2e-macros = { path = "../yellowstone-grpc-e2e-macros" }
2628
yellowstone-grpc-proto = { workspace = true }
27-
yellowstone-block-machine = { workspace = true }
28-
yellowstone-grpc-client = { workspace = true, features = ["test-tools"] }
29+
yellowstone-block-machine = { workspace = true, features = ["dragonsmouth-thin"] }
30+
yellowstone-grpc-client = { path = "../yellowstone-grpc-client", features = ["test-tools"] }
2931
yellowstone-grpc-geyser = { workspace = true }
3032

3133
serde = { workspace = true, features = ["derive"] }

yellowstone-grpc-e2e-test/src/scenarios.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ pub fn init_log() {
4848

4949
pub mod default;
5050
pub mod ratelimit;
51+
pub mod reconnect;
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
use {
2+
crate::scenarios::RunConfig,
3+
anyhow::{bail, ensure, Context, Result},
4+
arc_swap::ArcSwap,
5+
futures::SinkExt,
6+
std::{
7+
collections::HashMap,
8+
sync::{Arc, Mutex},
9+
time::Duration,
10+
},
11+
tokio_stream::StreamExt,
12+
yellowstone_block_machine::dragonsmouth::{
13+
stream::{BlockMachineOutput, BlockStream},
14+
wrapper::RESERVED_FILTER_NAME,
15+
},
16+
yellowstone_grpc_client::{
17+
test_tools::{Unstable, UnstableConnector},
18+
AutoReconnect, Backoff, DedupState, DedupStream, InterceptorXToken, ReconnectConfig,
19+
ReplayPolicy, TonicGrpcConnector,
20+
},
21+
yellowstone_grpc_e2e_macros::test_helper,
22+
yellowstone_grpc_geyser::plugin::message::CommitmentLevel,
23+
yellowstone_grpc_proto::{
24+
geyser::{geyser_client::GeyserClient, SubscribeRequest, SubscribeRequestFilterSlots},
25+
tonic::{transport::Endpoint, Request},
26+
},
27+
};
28+
29+
/// Verifies auto-reconnect recovers from simulated stream drops and
30+
/// block reconstruction produces gapless frozen blocks across reconnects.
31+
#[test_helper(name = "reconnect-blocks", tags = ["reconnect"])]
32+
pub async fn it_should_reconstruct_blocks_across_reconnects(config: &RunConfig) -> Result<()> {
33+
let drop_interval = Duration::from_secs(15);
34+
let target_blocks = 30u64;
35+
36+
let endpoint = Endpoint::from_shared(config.endpoint.clone()).context("valid endpoint")?;
37+
let channel = endpoint.connect().await.context("channel connect")?;
38+
39+
let x_token = config
40+
.x_token
41+
.as_ref()
42+
.map(|t| t.parse())
43+
.transpose()
44+
.context("valid x-token")?;
45+
46+
let interceptor = InterceptorXToken {
47+
x_token: x_token.clone(),
48+
x_request_snapshot: false,
49+
};
50+
51+
let mut geyser = GeyserClient::with_interceptor(channel, interceptor)
52+
.max_decoding_message_size(16 * 1024 * 10224);
53+
54+
let request = SubscribeRequest {
55+
slots: HashMap::from([(
56+
RESERVED_FILTER_NAME.to_string(),
57+
SubscribeRequestFilterSlots {
58+
interslot_updates: Some(true),
59+
..Default::default()
60+
},
61+
)]),
62+
blocks: HashMap::from([("test".to_string(), Default::default())]),
63+
blocks_meta: HashMap::from([("test".to_string(), Default::default())]),
64+
accounts: HashMap::from([("test".to_string(), Default::default())]),
65+
transactions: HashMap::from([("test".to_string(), Default::default())]),
66+
entry: HashMap::from([("test".to_string(), Default::default())]),
67+
commitment: Some(CommitmentLevel::Confirmed as i32),
68+
..Default::default()
69+
};
70+
71+
let (mut subscribe_tx, subscribe_rx) = futures::channel::mpsc::channel(1000);
72+
subscribe_tx
73+
.send(request.clone())
74+
.await
75+
.context("initial send")?;
76+
77+
let response = geyser
78+
.subscribe(Request::new(subscribe_rx))
79+
.await
80+
.context("initial subscribe")?;
81+
let raw_stream = response.into_inner();
82+
83+
let request_sink = Arc::new(Mutex::new(subscribe_tx));
84+
let shared_request = Arc::new(ArcSwap::from_pointee(request));
85+
86+
let reconnect_config = ReconnectConfig {
87+
backoff: Backoff::new(Duration::from_millis(500), 2.0, 5),
88+
replay_policy: ReplayPolicy::default(),
89+
slot_retention: 250,
90+
};
91+
92+
let tonic_connector = TonicGrpcConnector::new(
93+
endpoint,
94+
reconnect_config.backoff.clone(),
95+
x_token,
96+
Default::default(),
97+
Arc::clone(&request_sink),
98+
);
99+
let connector = UnstableConnector::new(tonic_connector, drop_interval);
100+
101+
let unstable_stream = Unstable::new(raw_stream, drop_interval);
102+
103+
let dedup = DedupStream::new(
104+
unstable_stream,
105+
DedupState::with_slot_retention(reconnect_config.slot_retention),
106+
);
107+
108+
let auto_reconnect = AutoReconnect::new(
109+
dedup,
110+
connector,
111+
Arc::clone(&shared_request),
112+
reconnect_config,
113+
);
114+
115+
let mut block_stream = BlockStream::new(
116+
auto_reconnect,
117+
solana_commitment_config::CommitmentLevel::Processed,
118+
);
119+
120+
let mut block_count = 0u64;
121+
let mut last_slot = 0u64;
122+
let mut gaps = 0u64;
123+
124+
let result = tokio::time::timeout(Duration::from_secs(50), async {
125+
while let Some(item) = block_stream.next().await {
126+
if block_count >= target_blocks {
127+
break;
128+
}
129+
130+
match item {
131+
Ok(BlockMachineOutput::FrozenBlock(block)) => {
132+
block_count += 1;
133+
134+
if last_slot > 0 && block.slot != last_slot + 1 {
135+
log::warn!(
136+
"gap: expected slot {} but got {} (missed {})",
137+
last_slot + 1,
138+
block.slot,
139+
block.slot - last_slot - 1,
140+
);
141+
gaps += 1;
142+
}
143+
144+
log::info!(
145+
"block slot={} txns={} accounts={} entries={} total={block_count}/{target_blocks}",
146+
block.slot,
147+
block.txn_len(),
148+
block.account_len(),
149+
block.entry_len(),
150+
);
151+
152+
last_slot = block.slot;
153+
}
154+
Ok(BlockMachineOutput::SlotCommitmentUpdate(u)) => {
155+
log::debug!("commitment slot={} level={:?}", u.slot, u.commitment);
156+
}
157+
Ok(BlockMachineOutput::ForkDetected(f)) => {
158+
log::warn!("fork slot={}", f.slot);
159+
}
160+
Ok(BlockMachineOutput::DeadBlockDetect(d)) => {
161+
log::warn!("dead slot={}", d.slot);
162+
}
163+
Err(e) => {
164+
bail!("block stream error: {e}");
165+
}
166+
}
167+
}
168+
Ok(())
169+
})
170+
.await;
171+
172+
match result {
173+
Ok(inner) => inner?,
174+
Err(_) => bail!("test timed out after 50s with {block_count}/{target_blocks} blocks"),
175+
}
176+
177+
ensure!(
178+
gaps <= 2,
179+
"block reconstruction should have minimal gaps across reconnects, got {gaps} gaps"
180+
);
181+
if gaps > 0 {
182+
log::warn!("reconnect-blocks: {gaps} gaps detected, known block-machine interop issue");
183+
}
184+
185+
Ok(())
186+
}

0 commit comments

Comments
 (0)