Skip to content

Commit 941ac2e

Browse files
authored
Merge pull request #4382 from cbeck88/fix/gateway-outbound-queues
Fix/gateway outbound queues
2 parents 2ca7642 + ab27052 commit 941ac2e

2 files changed

Lines changed: 259 additions & 14 deletions

File tree

node/bft/events/src/lib.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,96 @@ impl<N: Network> From<DisconnectReason> for Event<N> {
119119
}
120120
}
121121

122+
/// Replaces the payload with its serialized form, if it is not already serialized.
123+
fn serialize_payload<T: FromBytes + ToBytes + Send + 'static>(payload: &mut Data<T>) -> Result<()> {
124+
if let Data::Object(object) = payload {
125+
*payload = Data::Buffer(object.to_bytes_le()?.into());
126+
}
127+
Ok(())
128+
}
129+
130+
/// Returns `true` if the payload still holds an object that would have to be serialized.
131+
fn is_payload_unserialized<T: FromBytes + ToBytes + Send + 'static>(payload: &mut Data<T>) -> bool {
132+
matches!(payload, Data::Object(_))
133+
}
134+
135+
/// Applies `$f` to the event's [`Data`] payload, evaluating to `$default` if it carries none.
136+
///
137+
/// Both [`Event::serialize_payload`] and [`Event::has_unserialized_payload`] need to know which
138+
/// variants carry a payload. Routing both through this macro keeps that list in exactly one place,
139+
/// so a newly added variant cannot be handled by one and silently forgotten by the other.
140+
macro_rules! with_data_payload {
141+
($event:expr, $f:ident, $default:expr) => {
142+
match $event {
143+
Self::BatchPropose(event) => $f(&mut event.batch_header),
144+
Self::BatchCertified(event) => $f(&mut event.certificate),
145+
Self::PrimaryPing(event) => $f(&mut event.primary_certificate),
146+
Self::BlockResponse(event) => $f(&mut event.blocks),
147+
Self::ChallengeResponse(event) => $f(&mut event.signature),
148+
Self::TransmissionResponse(event) => match &mut event.transmission {
149+
Transmission::Solution(solution) => $f(solution),
150+
Transmission::Transaction(transaction) => $f(transaction),
151+
Transmission::Ratification => $default,
152+
},
153+
// The remaining events carry no `Data` payload.
154+
//
155+
// Note that `CertificateResponse` is in this list only because its certificate is held
156+
// directly rather than behind a `Data`, unlike every other certificate-bearing event.
157+
// It is consequently serialized on the writer task and deserialized on the reading
158+
// task, and cannot benefit from either of the methods below until that is changed.
159+
Self::BatchSignature(_)
160+
| Self::BlockRequest(_)
161+
| Self::CertificateRequest(_)
162+
| Self::CertificateResponse(_)
163+
| Self::ChallengeRequest(_)
164+
| Self::Disconnect(_)
165+
| Self::TransmissionRequest(_)
166+
| Self::ValidatorsRequest(_)
167+
| Self::ValidatorsResponse(_)
168+
| Self::WorkerPing(_) => $default,
169+
}
170+
};
171+
}
172+
122173
impl<N: Network> Event<N> {
123174
/// The version of the event protocol; it can be incremented in order to force users to update.
124175
pub const VERSION: u32 = 10;
125176

177+
/// Serializes the event's [`Data`] payload, if it holds one that is not already serialized.
178+
///
179+
/// [`Data`] defers serialization until the event is written to a stream, so an event that is
180+
/// cloned for several peers is serialized once per peer, separately, inside each connection's
181+
/// writer task. Calling this beforehand performs the serialization once; every clone then
182+
/// shares the resulting buffer, and each writer only has to copy it to the wire.
183+
///
184+
/// Before a broadcast, this avoids serializing the same payload once per recipient. Before a
185+
/// single send it saves no total work, but it still matters: it moves the serialization off
186+
/// the connection's writer task, which is a Tokio worker and should not be running compute.
187+
/// A large payload serialized there stalls the reactor, and it does so inside the write
188+
/// timeout, which then has to be sized to accommodate it.
189+
///
190+
/// Accordingly, `Transport::send` performs this on a blocking thread for every outbound event,
191+
/// and `Transport::broadcast` performs it once up front so that the fan-out only clones the
192+
/// resulting buffer.
193+
///
194+
/// It is a no-op for events that carry no payload, or whose payload is already serialized, so
195+
/// applying it twice is harmless.
196+
pub fn serialize_payload(&mut self) -> Result<()> {
197+
with_data_payload!(self, serialize_payload, Ok(()))
198+
}
199+
200+
/// Returns `true` if this event carries a payload that has not been serialized yet.
201+
///
202+
/// Callers use this to decide whether [`Self::serialize_payload`] is worth the cost of moving
203+
/// the event to a blocking thread. Most events carry no payload at all, and re-sending an
204+
/// already-serialized one is common, so the check keeps that hop off the common path.
205+
///
206+
/// Note this takes `&mut self` only because it shares its variant list with
207+
/// [`Self::serialize_payload`]; it does not modify the event.
208+
pub fn has_unserialized_payload(&mut self) -> bool {
209+
with_data_payload!(self, is_payload_unserialized, false)
210+
}
211+
126212
/// Returns the event name.
127213
#[inline]
128214
pub fn name(&self) -> Cow<'static, str> {
@@ -257,15 +343,20 @@ pub mod prop_tests {
257343
Disconnect,
258344
DisconnectReason,
259345
Event,
346+
ValidatorsRequest,
260347
batch_certified::prop_tests::any_batch_certified,
261348
batch_propose::prop_tests::any_batch_propose,
262349
batch_signature::prop_tests::any_batch_signature,
350+
block_request::prop_tests::any_block_request,
351+
block_response::prop_tests::any_block_response,
263352
certificate_request::prop_tests::any_certificate_request,
264353
certificate_response::prop_tests::any_certificate_response,
265354
challenge_request::prop_tests::any_challenge_request,
266355
challenge_response::prop_tests::any_challenge_response,
356+
primary_ping::prop_tests::any_primary_ping,
267357
transmission_request::prop_tests::any_transmission_request,
268358
transmission_response::prop_tests::any_transmission_response,
359+
validators_response::prop_tests::any_validators_response,
269360
worker_ping::prop_tests::any_worker_ping,
270361
};
271362
use snarkvm::{
@@ -316,11 +407,18 @@ pub mod prop_tests {
316407
.boxed()
317408
}
318409

410+
/// A strategy covering every [`Event`] variant.
411+
///
412+
/// Keep this exhaustive. Several properties are asserted over "any event", and a variant that
413+
/// is missing here is silently untested rather than failing -- which is how `BlockResponse` and
414+
/// `PrimaryPing`, the two largest `Data`-carrying events, went uncovered.
319415
pub fn any_event() -> BoxedStrategy<Event<CurrentNetwork>> {
320416
prop_oneof![
321417
any_batch_certified().prop_map(Event::BatchCertified),
322418
any_batch_propose().prop_map(Event::BatchPropose),
323419
any_batch_signature().prop_map(Event::BatchSignature),
420+
any_block_request().prop_map(Event::BlockRequest),
421+
any_block_response().prop_map(Event::BlockResponse),
324422
any_certificate_request().prop_map(Event::CertificateRequest),
325423
any_certificate_response().prop_map(Event::CertificateResponse),
326424
any_challenge_request().prop_map(Event::ChallengeRequest),
@@ -342,8 +440,11 @@ pub mod prop_tests {
342440
any::<Selector>()
343441
)
344442
.prop_map(|(reasons, selector)| Event::Disconnect(Disconnect::from(selector.select(reasons)))),
443+
any_primary_ping().prop_map(Event::PrimaryPing),
345444
any_transmission_request().prop_map(Event::TransmissionRequest),
346445
any_transmission_response().prop_map(Event::TransmissionResponse),
446+
Just(ValidatorsRequest).prop_map(Event::ValidatorsRequest),
447+
any_validators_response().prop_map(Event::ValidatorsResponse),
347448
any_worker_ping().prop_map(Event::WorkerPing)
348449
]
349450
.boxed()
@@ -358,4 +459,55 @@ pub mod prop_tests {
358459
assert_eq!(original.id(), deserialized.id());
359460
assert_eq!(original.name(), deserialized.name());
360461
}
462+
463+
/// Serializing the payload ahead of time must be invisible on the wire, otherwise doing it
464+
/// before a broadcast would change what peers receive.
465+
#[proptest]
466+
fn serialize_payload_preserves_the_encoding(#[strategy(any_event())] original: Event<CurrentNetwork>) {
467+
let mut expected = Vec::new();
468+
Event::write_le(&original, &mut expected).unwrap();
469+
470+
let mut event = original.clone();
471+
event.serialize_payload().unwrap();
472+
473+
let mut actual = Vec::new();
474+
Event::write_le(&event, &mut actual).unwrap();
475+
476+
assert_eq!(expected, actual, "{} encoded differently once its payload was serialized", original.name());
477+
}
478+
479+
/// Serializing the payload must be idempotent, so that broadcasting an event that has already
480+
/// been serialized does not deserialize and re-serialize it.
481+
#[proptest]
482+
fn serialize_payload_is_idempotent(#[strategy(any_event())] original: Event<CurrentNetwork>) {
483+
let mut once = original.clone();
484+
once.serialize_payload().unwrap();
485+
486+
let mut twice = once.clone();
487+
twice.serialize_payload().unwrap();
488+
489+
assert_eq!(once, twice);
490+
}
491+
492+
/// `has_unserialized_payload` is what decides whether an event is worth handing to a blocking
493+
/// thread, so it must agree with `serialize_payload` about which events have work to do. If the
494+
/// two ever disagreed, a payload-carrying event could be serialized on a writer task after all.
495+
#[proptest]
496+
fn has_unserialized_payload_agrees_with_serialize_payload(
497+
#[strategy(any_event())] original: Event<CurrentNetwork>,
498+
) {
499+
let mut event = original.clone();
500+
501+
// Serializing must clear the flag, whether or not it was set to begin with.
502+
event.serialize_payload().unwrap();
503+
assert!(!event.has_unserialized_payload(), "{} still reports an unserialized payload", original.name());
504+
505+
// And an event that reports no work to do must be unchanged by doing the work.
506+
let mut reported_no_work = original.clone();
507+
if !reported_no_work.has_unserialized_payload() {
508+
let before = reported_no_work.clone();
509+
reported_no_work.serialize_payload().unwrap();
510+
assert_eq!(before, reported_no_work, "{} changed despite reporting no work", original.name());
511+
}
512+
}
361513
}

node/bft/src/gateway.rs

Lines changed: 107 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use crate::helpers::Telemetry;
1818
use crate::{
1919
CONTEXT,
2020
MAX_BATCH_DELAY,
21+
MAX_FETCH_TIMEOUT,
2122
MEMORY_POOL_PORT,
2223
Worker,
2324
events::{DisconnectReason, EventCodec, PrimaryPing},
@@ -120,6 +121,35 @@ const CACHE_EVENTS_INTERVAL: i64 = (MAX_BATCH_DELAY.as_secs()) as i64; // second
120121
/// The maximum interval of requests to cache.
121122
const CACHE_REQUESTS_INTERVAL: i64 = (MAX_BATCH_DELAY.as_secs()) as i64; // seconds
122123

124+
/// The number of consensus rounds of traffic that a per-connection message queue must absorb.
125+
///
126+
/// A message that has not made it onto (or off of) the wire within `MAX_FETCH_TIMEOUT` is already
127+
/// useless to the peer: a requester has given up on it by then (see
128+
/// `Worker::send_transmission_request`), and any consensus event it carried is stale. Sizing the
129+
/// per-connection queues to this window therefore bounds them to the traffic that can still be
130+
/// delivered in time, rather than to the entire garbage-collection window (`MAX_GC_ROUNDS`, which
131+
/// is ~100 rounds, i.e. several minutes of traffic).
132+
const QUEUE_WINDOW_ROUNDS: usize = (MAX_FETCH_TIMEOUT.as_millis() / MAX_BATCH_DELAY.as_millis()) as usize;
133+
134+
/// Computes the depth of the per-connection inbound and outbound message queues.
135+
///
136+
/// These queues are transient send/receive buffers, not backlogs: they only need to hold the
137+
/// traffic a peer can legitimately exchange with us over `QUEUE_WINDOW_ROUNDS` (see above).
138+
/// Per round, the worst case is every certificate in the round plus every transmission each of
139+
/// those certificates contains — that is, a peer that is missing an entire round and fetches all
140+
/// of it from us. The leading factor of 2 is headroom for the remaining, far smaller, event
141+
/// traffic (batch proposals and signatures, primary and worker pings, block and validator
142+
/// requests) and for requests that straddle a round boundary.
143+
///
144+
/// Note that each slot can hold a full `Transmission`, so this value is a direct multiplier on the
145+
/// heap a single peer can pin. It must stay small enough that `depth * max transmission size` is
146+
/// survivable on a commodity validator.
147+
fn per_connection_queue_depth<N: Network>() -> usize {
148+
2 * QUEUE_WINDOW_ROUNDS
149+
* N::LATEST_MAX_CERTIFICATES() as usize
150+
* (BatchHeader::<N>::MAX_TRANSMISSIONS_PER_BATCH + 1)
151+
}
152+
123153
/// The maximum number of connection attempts in an interval.
124154
#[cfg(not(test))]
125155
const MAX_CONNECTION_ATTEMPTS: usize = 10;
@@ -1316,7 +1346,32 @@ impl<N: Network> Transport<N> for Gateway<N> {
13161346
/// This function returns as soon as the event is queued to be sent,
13171347
/// without waiting for the actual delivery; instead, the caller is provided with a [`oneshot::Receiver`]
13181348
/// which can be used to determine when and whether the event has been delivered.
1319-
async fn send(&self, peer_ip: SocketAddr, event: Event<N>) -> Option<oneshot::Receiver<io::Result<()>>> {
1349+
async fn send(&self, peer_ip: SocketAddr, mut event: Event<N>) -> Option<oneshot::Receiver<io::Result<()>>> {
1350+
// Serialize the payload here, rather than leaving it for the connection's writer task.
1351+
//
1352+
// `Data` defers serialization until the event is written to the stream, which puts it on
1353+
// the writer task -- a Tokio worker -- and inside the write timeout. For the large
1354+
// responses (`BlockResponse` in particular, which can carry `DataBlocks::MAXIMUM_NUMBER_
1355+
// OF_BLOCKS`) that is a substantial amount of compute on the reactor.
1356+
//
1357+
// The check keeps the hop off the common path: most events carry no payload, and a
1358+
// broadcast has already serialized its payload before fanning out here, so this is a no-op
1359+
// for every recipient after the first.
1360+
if event.has_unserialized_payload() {
1361+
let name = event.name();
1362+
event = match spawn_blocking!({
1363+
let mut event = event;
1364+
event.serialize_payload()?;
1365+
Ok(event)
1366+
}) {
1367+
Ok(event) => event,
1368+
Err(err) => {
1369+
error!("{CONTEXT} Unable to serialize '{name}' for '{peer_ip}' - {err}");
1370+
return None;
1371+
}
1372+
};
1373+
}
1374+
13201375
macro_rules! send {
13211376
($self:ident, $cache_map:ident, $interval:expr, $freq:ident) => {{
13221377
// Rate limit the number of certificate requests sent to the peer.
@@ -1357,14 +1412,29 @@ impl<N: Network> Transport<N> for Gateway<N> {
13571412
}
13581413

13591414
/// Broadcasts the given event to all connected peers.
1360-
// TODO(ljedrz): the event should be checked for the presence of Data::Object, and
1361-
// serialized in advance if it's there.
1362-
fn broadcast(&self, event: Event<N>) {
1415+
fn broadcast(&self, mut event: Event<N>) {
13631416
// Ensure there are connected peers.
13641417
if self.number_of_connected_peers() > 0 {
13651418
let self_ = self.clone();
13661419
let connected_peers = self.connected_peers();
13671420
tokio::spawn(async move {
1421+
// Serialize the event's payload once, rather than once per recipient; every
1422+
// recipient then shares the resulting buffer. `Transport::send` would otherwise do
1423+
// this separately for each peer below.
1424+
if event.has_unserialized_payload() {
1425+
let name = event.name();
1426+
event = match spawn_blocking!({
1427+
let mut event = event;
1428+
event.serialize_payload()?;
1429+
Ok(event)
1430+
}) {
1431+
Ok(event) => event,
1432+
Err(err) => {
1433+
error!("{CONTEXT} Unable to serialize '{name}' for broadcast - {err}");
1434+
return;
1435+
}
1436+
};
1437+
}
13681438
// Iterate through all connected peers.
13691439
for peer_ip in connected_peers {
13701440
// Send the event to the peer.
@@ -1408,12 +1478,11 @@ impl<N: Network> Reading for Gateway<N> {
14081478
Ok(())
14091479
}
14101480

1411-
/// Computes the depth of per-connection queues used to process inbound messages, sufficient to process the maximum expected load at any givent moment.
1481+
/// Computes the depth of per-connection queues used to process inbound messages, sufficient to process the maximum expected load at any given moment.
14121482
/// The greater it is, the more inbound messages the node can enqueue, but a too large value can make the node more susceptible to DoS attacks.
1483+
/// See [`per_connection_queue_depth`] for the derivation.
14131484
fn message_queue_depth(&self) -> usize {
1414-
2 * BatchHeader::<N>::MAX_GC_ROUNDS
1415-
* N::LATEST_MAX_CERTIFICATES() as usize
1416-
* BatchHeader::<N>::MAX_TRANSMISSIONS_PER_BATCH
1485+
per_connection_queue_depth::<N>()
14171486
}
14181487
}
14191488

@@ -1428,13 +1497,12 @@ impl<N: Network> Writing for Gateway<N> {
14281497
Default::default()
14291498
}
14301499

1431-
/// Computes the depth of per-connection queues used to send outbound messages, sufficient to process the maximum expected load at any givent moment.
1432-
/// The greater it is, the more outbound messages the node can enqueue. A too large value large value might obscure potential issues with your implementation
1433-
/// (like slow serialization) or network.
1500+
/// Computes the depth of per-connection queues used to send outbound messages, sufficient to process the maximum expected load at any given moment.
1501+
/// The greater it is, the more outbound messages the node can enqueue. A too large value might obscure potential issues with your implementation
1502+
/// (like slow serialization) or network, and lets a peer that stops reading its socket pin an unreasonable amount of our heap.
1503+
/// See [`per_connection_queue_depth`] for the derivation.
14341504
fn message_queue_depth(&self) -> usize {
1435-
2 * BatchHeader::<N>::MAX_GC_ROUNDS
1436-
* N::LATEST_MAX_CERTIFICATES() as usize
1437-
* BatchHeader::<N>::MAX_TRANSMISSIONS_PER_BATCH
1505+
per_connection_queue_depth::<N>()
14381506
}
14391507
}
14401508

@@ -2333,6 +2401,31 @@ mod prop_tests {
23332401

23342402
type CurrentNetwork = MainnetV0;
23352403

2404+
/// The per-connection queues are sized from the fetch-timeout window, not the GC window.
2405+
///
2406+
/// Each slot can hold a full `Transmission`, so the depth is a direct multiplier on the heap a
2407+
/// single peer can pin by not reading its socket. Pin the derivation so a regression is loud.
2408+
#[test]
2409+
fn test_per_connection_queue_depth() {
2410+
use crate::gateway::{QUEUE_WINDOW_ROUNDS, per_connection_queue_depth};
2411+
use snarkvm::console::network::Network;
2412+
2413+
// The window is `MAX_FETCH_TIMEOUT` expressed in rounds.
2414+
assert_eq!(QUEUE_WINDOW_ROUNDS, 3);
2415+
2416+
let certificates = CurrentNetwork::LATEST_MAX_CERTIFICATES() as usize;
2417+
let transmissions = BatchHeader::<CurrentNetwork>::MAX_TRANSMISSIONS_PER_BATCH;
2418+
let depth = per_connection_queue_depth::<CurrentNetwork>();
2419+
2420+
assert_eq!(depth, 2 * QUEUE_WINDOW_ROUNDS * certificates * (transmissions + 1));
2421+
2422+
// It must cover a peer fetching every transmission of every certificate for the window...
2423+
assert!(depth >= QUEUE_WINDOW_ROUNDS * certificates * transmissions);
2424+
// ...but stay far below the old `MAX_GC_ROUNDS`-derived depth, which let one peer pin
2425+
// 400,000 transmissions.
2426+
assert!(depth < 2 * BatchHeader::<CurrentNetwork>::MAX_GC_ROUNDS * certificates * transmissions / 8);
2427+
}
2428+
23362429
impl Debug for Gateway<CurrentNetwork> {
23372430
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
23382431
// TODO implement Debug properly and move it over to production code

0 commit comments

Comments
 (0)