Skip to content

Commit 2c3470c

Browse files
committed
Address the second round of OpenCodeReview findings
Accepted: * scanner-daemon: replace the implicit 'outage exists iff client is None' bookkeeping with a ConnectionState enum (Connected/Reconnecting) so the invariant cannot be violated; give the storage-version panic a contextual message; log the local-tip read failure during the recovery log instead of silently resuming 'from height 0'; validate that the backoff maximum is not smaller than its initial delay. * web-server: the outage starts at the first failed round (the failure logs now show the real elapsed time instead of zero), and a stalled event stream no longer forces a full reconnection: the bridge first retries the subscription on the existing connection and only drops the client when that fails with a connection-level error. The lag advisory is broadcast whenever an outage is ongoing, including stall and decode failure rounds. * tests: the test proxy reaps dead connection pairs (either direction finished), counts refused connections (refused_connections()), keeps its task handle observable via assert_alive(), and the scanner reconnect test builds its chain in spawn_blocking (no runtime blocking), derives deterministic child seeds from one master RNG, and replaces the 500ms pre-abort sleep with a proper abort+join. scanner_tip logging: polled storage errors are no longer fully indistinguishable from the uninitialized state (last error is visible in the timeout message). Rejected: * 'send_event stale sink race': after wait_for_subscription, the last sink is by construction the freshly opened one (the backend pushes exactly once per accepted subscription); the is_closed assert is the precise guard against ordering regressions, a retry loop would mask them.
1 parent 8b8f411 commit 2c3470c

5 files changed

Lines changed: 182 additions & 103 deletions

File tree

‎api-server/scanner-daemon/src/backoff.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ impl ReconnectBackoff {
4949
Duration::ZERO,
5050
"The maximum delay must not be zero"
5151
);
52+
assert!(
53+
max_delay >= initial_delay,
54+
"The maximum delay ({max_delay:?}) must not be smaller than the initial delay \
55+
({initial_delay:?})"
56+
);
5257

5358
Self {
5459
initial_delay,

‎api-server/scanner-daemon/src/lib.rs‎

Lines changed: 94 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,10 @@ pub async fn run<S: ApiServerStorage>(
123123
.get_storage_version()
124124
.await
125125
.unwrap_or_else(|e| panic!("Storage version read failed {}", e))
126-
.expect("cannot be empty");
126+
.expect(
127+
"storage is initialized but has no storage version row (the row \
128+
is missing or the database is corrupt)",
129+
);
127130
if storage_version != CURRENT_STORAGE_VERSION {
128131
true
129132
} else {
@@ -184,6 +187,13 @@ async fn reinitialize_and_rescan<S: ApiServerStorage>(
184187
local_block
185188
}
186189

190+
/// The connection state of the supervision loop; the `Connected`/`Reconnecting` variants make
191+
/// the invariant "an outage tracker exists if and only if there is no client" explicit.
192+
enum ConnectionState {
193+
Connected(NodeRpcClient),
194+
Reconnecting { outage: Outage },
195+
}
196+
187197
/// The supervision loop of the scanner: keeps the local state in sync with the node, recovering
188198
/// in place from connection-level failures by re-creating the RPC client with an exponential
189199
/// backoff and resuming the indexing from the tip stored in the local state.
@@ -206,80 +216,91 @@ async fn supervise_sync<S: ApiServerStorage>(
206216
rng: &mut impl Rng,
207217
) -> Result<(), ApiServerScannerError> {
208218
// Note: the client is created lazily (and re-created after every connection-level failure),
209-
// so that a node that is down at startup does not abort the daemon; `None` means that the
210-
// connection to the node is currently broken and must be re-established.
211-
let mut rpc_client: Option<NodeRpcClient> = None;
212-
// Note: `outage` is `Some` if and only if `rpc_client` is `None`.
213-
let mut outage: Option<Outage> = None;
219+
// so that a node that is down at startup does not abort the daemon.
220+
let mut state = ConnectionState::Reconnecting {
221+
outage: Outage::new(),
222+
};
214223

215224
loop {
216-
if rpc_client.is_none() {
217-
rpc_client = Some(loop {
218-
// Note: the connection attempt is bounded by a timeout so that a stalled
219-
// handshake (the node accepting the TCP connection but never completing the
220-
// WebSocket handshake) cannot wedge a round forever without any log output;
221-
// expiry is treated like any other connection failure.
222-
let connection = tokio::time::timeout(
223-
CONNECT_TIMEOUT,
224-
make_rpc_client(
225-
Arc::clone(chain_config),
226-
node_rpc_address.clone(),
227-
node_rpc_auth.clone(),
228-
),
229-
)
230-
.await;
231-
match connection {
232-
Ok(Ok(client)) => break client,
233-
Ok(Err(err)) if NodeRpcError::is_connection_error(&err) => {
234-
let outage = outage.get_or_insert_with(Outage::new);
235-
outage.attempts += 1;
236-
let delay = backoff.next_delay(rng);
237-
logging::log::warn!(
238-
"Failed to connect to the node (attempt {}, elapsed {:?}): {err}; \
239-
retrying in {delay:?}",
240-
outage.attempts,
241-
outage.started.elapsed(),
242-
);
243-
tokio::time::sleep(delay).await;
244-
}
245-
// A connection cannot be established, but the reason is not a temporary
246-
// connectivity problem (e.g. an invalid address); retrying is pointless.
247-
Ok(Err(err)) => return Err(ApiServerScannerError::RpcError(err)),
248-
Err(_timed_out) => {
249-
let outage = outage.get_or_insert_with(Outage::new);
250-
outage.attempts += 1;
251-
let delay = backoff.next_delay(rng);
252-
logging::log::warn!(
253-
"Timed out connecting to the node (attempt {}, elapsed {:?}); \
254-
retrying in {delay:?}",
255-
outage.attempts,
256-
outage.started.elapsed(),
257-
);
258-
tokio::time::sleep(delay).await;
225+
let client = loop {
226+
match &mut state {
227+
ConnectionState::Connected(client) => break client.clone(),
228+
ConnectionState::Reconnecting { outage } => {
229+
// Note: the connection attempt is bounded by a timeout so that a stalled
230+
// handshake (the node accepting the TCP connection but never completing
231+
// the WebSocket handshake) cannot wedge a round forever without any log
232+
// output; expiry is treated like any other connection failure.
233+
let connection = tokio::time::timeout(
234+
CONNECT_TIMEOUT,
235+
make_rpc_client(
236+
Arc::clone(chain_config),
237+
node_rpc_address.clone(),
238+
node_rpc_auth.clone(),
239+
),
240+
)
241+
.await;
242+
match connection {
243+
// Note: the recovery is only logged when the outage actually produced
244+
// failed attempts; the very first connection is not a "recovery".
245+
Ok(Ok(new_client)) if outage.attempts > 0 => {
246+
let outage = std::mem::replace(outage, Outage::new());
247+
let local_height = match local_block.best_block().await {
248+
Ok((height, _)) => height,
249+
Err(err) => {
250+
logging::log::warn!(
251+
"Failed to read the local tip for the recovery log: {err}"
252+
);
253+
common::primitives::BlockHeight::zero()
254+
}
255+
};
256+
logging::log::info!(
257+
"Scanner reconnected to the node after {} attempt(s) ({:?} \
258+
elapsed); resuming from height {}",
259+
outage.attempts,
260+
outage.started.elapsed(),
261+
local_height,
262+
);
263+
break new_client;
264+
}
265+
Ok(Ok(new_client)) => break new_client,
266+
Ok(Err(err)) if NodeRpcError::is_connection_error(&err) => {
267+
outage.attempts += 1;
268+
let delay = backoff.next_delay(rng);
269+
logging::log::warn!(
270+
"Failed to connect to the node (attempt {}, elapsed {:?}): {err}; \
271+
retrying in {delay:?}",
272+
outage.attempts,
273+
outage.started.elapsed(),
274+
);
275+
tokio::time::sleep(delay).await;
276+
}
277+
// A connection cannot be established, but the reason is not a temporary
278+
// connectivity problem (e.g. an invalid address); retrying is pointless.
279+
Ok(Err(err)) => return Err(ApiServerScannerError::RpcError(err)),
280+
Err(_timed_out) => {
281+
outage.attempts += 1;
282+
let delay = backoff.next_delay(rng);
283+
logging::log::warn!(
284+
"Timed out connecting to the node (attempt {}, elapsed {:?}); \
285+
retrying in {delay:?}",
286+
outage.attempts,
287+
outage.started.elapsed(),
288+
);
289+
tokio::time::sleep(delay).await;
290+
}
259291
}
260292
}
261-
});
262-
263-
if let Some(outage) = outage.take() {
264-
let local_height = local_block
265-
.best_block()
266-
.await
267-
.map_or(common::primitives::BlockHeight::zero(), |(height, _)| {
268-
height
269-
});
270-
logging::log::info!(
271-
"Scanner reconnected to the node after {} attempt(s) ({:?} elapsed); \
272-
resuming from height {}",
273-
outage.attempts,
274-
outage.started.elapsed(),
275-
local_height,
276-
);
277293
}
278-
}
294+
};
279295

280-
let client = rpc_client.as_ref().expect("The RPC client must have been established above");
296+
// Note: the loop above leaves the state in `Reconnecting` when it connected (the
297+
// connected client is the loop's result); record the new state.
298+
state = ConnectionState::Connected(client.clone());
281299

282-
match api_blockchain_scanner_lib::sync::sync_once(chain_config, client, local_block).await {
300+
// Note: the backoff state at this point belongs to the successful connection; it is
301+
// only reset after a successful sync, see below.
302+
match api_blockchain_scanner_lib::sync::sync_once(chain_config, &client, local_block).await
303+
{
283304
Ok(()) => {
284305
// Note: the backoff is only reset here (after a successful sync), not after a
285306
// successful connect: a flapping node must not pin the retry rate at the
@@ -290,15 +311,13 @@ async fn supervise_sync<S: ApiServerStorage>(
290311
Err(err) if err.is_connection_error() => {
291312
// The client is permanently broken (e.g. the node has closed the WebSocket
292313
// connection); drop it and re-connect on the next iteration.
293-
rpc_client = None;
294-
let outage = outage.get_or_insert_with(Outage::new);
295-
outage.attempts += 1;
314+
state = ConnectionState::Reconnecting {
315+
outage: Outage::new(),
316+
};
296317
let delay = backoff.next_delay(rng);
297318
logging::log::warn!(
298-
"Lost the connection to the node (attempt {}, elapsed {:?}): {err}; \
299-
re-connecting in {delay:?}",
300-
outage.attempts,
301-
outage.started.elapsed(),
319+
"Lost the connection to the node (attempt 1, elapsed 0ns): {err}; \
320+
re-connecting in {delay:?}"
302321
);
303322
tokio::time::sleep(delay).await;
304323
}

‎api-server/stack-test-suite/tests/common/proxy.rs‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ pub struct ProxyHandle {
3030
forwarding: tokio::sync::watch::Sender<bool>,
3131
connections: Arc<tokio::sync::Mutex<Vec<[tokio::task::JoinHandle<()>; 2]>>>,
3232
task: tokio::task::JoinHandle<()>,
33+
/// The number of accepted connections dropped because forwarding was disabled or the
34+
/// backend was unreachable; exposed for diagnostics in test failures.
35+
refused: Arc<std::sync::atomic::AtomicUsize>,
3336
}
3437

3538
impl ProxyHandle {
@@ -44,18 +47,21 @@ impl ProxyHandle {
4447
let addr = listener.local_addr().unwrap();
4548
let forwarding_rx = forwarding.subscribe();
4649
let connections = Arc::new(tokio::sync::Mutex::new(Vec::new()));
50+
let refused = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4751
let task = tokio::spawn(run_proxy(
4852
listener,
4953
backend_addr,
5054
forwarding_rx,
5155
Arc::clone(&connections),
56+
Arc::clone(&refused),
5257
));
5358

5459
Self {
5560
addr,
5661
forwarding,
5762
connections,
5863
task,
64+
refused,
5965
}
6066
}
6167

@@ -86,14 +92,23 @@ impl ProxyHandle {
8692
pub fn assert_alive(&self) {
8793
assert!(!self.task.is_finished(), "The proxy task has terminated");
8894
}
95+
96+
/// The number of connections the proxy has dropped without forwarding them (forwarding
97+
/// disabled, or the backend unreachable); useful for diagnosing test failures.
98+
pub fn refused_connections(&self) -> usize {
99+
self.refused.load(std::sync::atomic::Ordering::Relaxed)
100+
}
89101
}
90102

91103
async fn run_proxy(
92104
listener: tokio::net::TcpListener,
93105
backend_addr: SocketAddr,
94106
mut forwarding: tokio::sync::watch::Receiver<bool>,
95107
connections: Arc<tokio::sync::Mutex<Vec<[tokio::task::JoinHandle<()>; 2]>>>,
108+
refused: Arc<std::sync::atomic::AtomicUsize>,
96109
) {
110+
use std::sync::atomic::Ordering;
111+
97112
loop {
98113
let (client_socket, _) = tokio::select! {
99114
accepted = listener.accept() => accepted.expect("The proxy listener has failed"),
@@ -106,11 +121,13 @@ async fn run_proxy(
106121

107122
if !*forwarding.borrow() {
108123
// The "node is down": drop the accepted connection right away.
124+
refused.fetch_add(1, Ordering::Relaxed);
109125
continue;
110126
}
111127

112128
let Ok(backend_socket) = tokio::net::TcpStream::connect(backend_addr).await else {
113129
// The backend is unreachable; behave like a closed connection.
130+
refused.fetch_add(1, Ordering::Relaxed);
114131
continue;
115132
};
116133

@@ -124,8 +141,9 @@ async fn run_proxy(
124141
});
125142

126143
let mut connections = connections.lock().await;
127-
// Reap the forwarding tasks that finished on their own, so that the list stays bounded.
128-
connections.retain(|pair| !pair[0].is_finished() && !pair[1].is_finished());
144+
// Reap the forwarding tasks of dead connections: once either direction has finished,
145+
// the other is dead by construction (its peer socket has been dropped with the task).
146+
connections.retain(|pair| !pair[0].is_finished() || !pair[1].is_finished());
129147
connections.push([client_to_backend, backend_to_client]);
130148
}
131149
}

‎api-server/stack-test-suite/tests/scanner_reconnect.rs‎

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ use jsonrpsee::{
4848
core::RpcResult,
4949
types::{ErrorObjectOwned, error::INTERNAL_ERROR_CODE},
5050
};
51+
use randomness::RngExt as _;
5152
use rpc::RpcAuthData;
5253
use serialization::hex_encoded::HexEncoded;
5354
use test_common::proxy::ProxyHandle;
@@ -256,14 +257,25 @@ async fn scanner_reconnects_after_node_disconnection() {
256257
});
257258

258259
// Index a few blocks while everything is healthy.
259-
// Note: the framework mutex is shared between this (chain building, on the runtime) and
260-
// the spawn_blocking-backed RPC serving; the two serialize for the duration of the chain
261-
// building, which is fine at this scale (the scanner's calls resume right after).
262-
let tip_3 = {
263-
let mut framework = framework.lock().await;
264-
let genesis_id = framework.chain_config().genesis_block_id();
265-
framework.create_chain_with_empty_blocks(&genesis_id, 3, &mut rng).unwrap()
266-
};
260+
// Note: the framework mutex is shared between this test (chain building) and the
261+
// spawn_blocking-backed RPC serving; chain building is itself moved off the runtime thread
262+
// (it is synchronous, CPU-heavy block processing), and the two serialize for its duration —
263+
// fine at this scale (the scanner's in-flight calls resume right after).
264+
// Note: deterministic child seeds are derived from the master RNG, so that a CI failure is
265+
// locally reproducible with a fixed seed.
266+
let phase_1_seed = Seed(rng.random_range(0..u64::MAX));
267+
let phase_2_seed = Seed(rng.random_range(0..u64::MAX));
268+
let tip_3 = tokio::task::spawn_blocking({
269+
let framework = Arc::clone(&framework);
270+
let mut rng = make_seedable_rng(phase_1_seed);
271+
move || {
272+
let mut framework = framework.blocking_lock();
273+
let genesis_id = framework.chain_config().genesis_block_id();
274+
framework.create_chain_with_empty_blocks(&genesis_id, 3, &mut rng).unwrap()
275+
}
276+
})
277+
.await
278+
.unwrap();
267279
proxy.assert_alive();
268280
wait_for_scanner_tip(&test_storage, tip_3).await;
269281
assert_eq!(
@@ -279,10 +291,16 @@ async fn scanner_reconnects_after_node_disconnection() {
279291
proxy.set_forwarding(false);
280292
tokio::time::sleep(OUTAGE_DURATION).await;
281293

282-
let tip_5 = {
283-
let mut framework = framework.lock().await;
284-
framework.create_chain_with_empty_blocks(&tip_3, 2, &mut rng).unwrap()
285-
};
294+
let tip_5 = tokio::task::spawn_blocking({
295+
let framework = Arc::clone(&framework);
296+
let mut rng = make_seedable_rng(phase_2_seed);
297+
move || {
298+
let mut framework = framework.blocking_lock();
299+
framework.create_chain_with_empty_blocks(&tip_3, 2, &mut rng).unwrap()
300+
}
301+
})
302+
.await
303+
.unwrap();
286304
assert_ne!(tip_3, tip_5);
287305

288306
// The scanner must not have given up (or crashed) while the node was unreachable.
@@ -303,11 +321,10 @@ async fn scanner_reconnects_after_node_disconnection() {
303321
// The supervision loop is still running (the recovery happened in place).
304322
assert!(!scanner_task.is_finished());
305323

306-
// Note: the scanner task is intentionally not allowed to run to completion; the destructors
307-
// of the storage handles and of the proxy close the remaining resources. Give the scanner a
308-
// moment to finish the in-flight sync before the storage handles are dropped, then stop it
309-
// explicitly, so that it cannot panic over the closed storage.
310-
tokio::time::sleep(Duration::from_millis(500)).await;
324+
// Note: the scanner task is stopped explicitly before the test's handles are dropped:
325+
// abort cancels it at its next await point and the join below waits for the unwind to
326+
// finish, so the daemon can never race the closing Postgres pool (no timing heuristics
327+
// needed).
311328
scanner_task.abort();
312329
let _ = scanner_task.await;
313330
}

0 commit comments

Comments
 (0)