Skip to content
60 changes: 55 additions & 5 deletions src/client/messaging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,71 @@ impl Client {
Ok(())
}

/// Send several pre-marshaled stanzas as one burst, returning a result per
/// stanza in the order given.
///
/// The noise sender coalesces whatever is queued when it wakes, but a
/// worker that awaits each send before starting the next never has two
/// frames queued at once, so the coalescing it was built for never fires.
/// Handing over the whole burst is what turns batching from incidental into
/// the normal case.
///
/// Order is preserved, which the ack worker depends on. The socket is
/// resolved once up front, so the only await left inside each send is the
/// channel push, and a channel with room resolves that on its first poll:
/// polling the joined sends in order therefore queues them in order.
/// Resolving the socket per send instead would put a contended mutex
/// between the futures and let them queue in any order.
///
/// Always drains `frames`, including when no socket is installed, while
/// retaining its outer allocation for the persistent workers to reuse.
pub(crate) async fn send_raw_bytes_burst(
&self,
frames: &mut Vec<Vec<u8>>,
) -> Result<Vec<crate::socket::error::EncryptSendResult>, ClientError> {
let noise_socket = match self.get_noise_socket().await {
Ok(socket) => socket,
Err(error) => {
frames.clear();
return Err(error);
}
};
if frames.len() == 1 {
let plaintext = frames.pop().expect("length checked");
return Ok(vec![
noise_socket
.encrypt_and_send(bytes::Bytes::from(plaintext))
.await,
]);
}
let sends = frames
.drain(..)
.map(|plaintext| noise_socket.encrypt_and_send(bytes::Bytes::from(plaintext)));
Ok(futures::future::join_all(sends).await)
}

#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.node", level = "debug", skip_all, fields(tag = %node.tag), err(Debug)))]
pub async fn send_node(&self, node: Node) -> Result<(), ClientError> {
let plaintext_buf = self.marshal_node_for_send(node)?;
self.send_raw_bytes(plaintext_buf).await
}

/// Everything [`send_node`](Client::send_node) does short of the send:
/// logging, waiter resolution and marshalling. Split out so a burst can
/// marshal its whole batch before touching the socket, which is what keeps
/// the sends orderable.
pub(crate) fn marshal_node_for_send(&self, node: Node) -> Result<Vec<u8>, ClientError> {
debug!(target: "Client/Send", "{}", DisplayableNode(&node));
if self.sent_node_waiter_count.load(Ordering::Acquire) > 0 {
self.resolve_sent_node_waiters(&Arc::new(node.clone()));
}

// Exact two-pass sizing: typical stanzas are a few hundred bytes, so
// the 1 KiB default reserve of the one-pass path mostly over-allocates.
let plaintext_buf = wacore_binary::marshal::marshal_exact(&node).map_err(|e| {
wacore_binary::marshal::marshal_exact(&node).map_err(|e| {
error!("Failed to marshal node: {e:?}");
SocketError::Marshal(e)
})?;

self.send_raw_bytes(plaintext_buf).await
SocketError::Marshal(e).into()
})
}

#[cfg_attr(
Expand Down
120 changes: 115 additions & 5 deletions src/client/node_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,28 @@ impl Client {
let _ = tx.try_send((node, guard));
}

/// Whether queued outbound work should be dropped rather than sent.
///
/// This is the gate [`Self::send_ack_for`] applies before every ack, hoisted
/// so the burst path applies it too: during an expected teardown (an
/// intentional disconnect, or a 515) queued acks are deliberately dropped
/// rather than raced against the disconnect, and sending them anyway would
/// also hold the outbound flush open until its timeout.
pub(crate) fn outbound_teardown_in_progress(&self) -> bool {
self.expected_disconnect.load(Ordering::Relaxed) || !self.is_connected()
}

/// How many queued acks one burst may take.
///
/// Measured, not guessed: the send-job channel holds 8, so a larger burst
/// fills it and makes unrelated producers (a reply, a receipt) wait for a
/// slot. At 16 the harness showed 29% fewer writes but 3.7% worse pong
/// latency (paired t = 2.8); at 4 the write saving is ~16% and latency is
/// no worse than main. Raising the channel instead recovers the latency but
/// gives back most of the coalescing, because a sender that never waits
/// consumes jobs one at a time.
const MAX_ACK_BURST: usize = 4;

/// Worker shared by every deferred ack. Holds a `Weak`, so a dropped
/// `Client` closes the channel and ends the task instead of keeping the
/// client alive.
Expand All @@ -667,16 +689,104 @@ impl Client {
let client = Arc::downgrade(self);
self.runtime
.spawn(Box::pin(async move {
while let Ok((node, guard)) = rx.recv().await {
// Reuse the bounded control buffers for the worker's lifetime.
// Encoded payload allocations still move into `Bytes`; only
// the outer storage stays here.
let mut batch = Vec::with_capacity(Self::MAX_ACK_BURST);
let mut frames = Vec::with_capacity(Self::MAX_ACK_BURST);
let mut guards = Vec::with_capacity(Self::MAX_ACK_BURST);
while let Ok(first) = rx.recv().await {
let Some(client) = client.upgrade() else {
break;
};
if let Err(e) = client.send_ack_for(node.get()).await
&& !e.is_transport_unavailable()

// Take everything already waiting, not just the one job that
// woke us. Awaiting each ack before reading the next is what
// kept the noise sender from ever seeing two frames at once,
// so its batching only fired when some *other* producer
// happened to interleave. `try_recv` only: this never waits
// for work that has not arrived.
batch.push(first);
while batch.len() < Self::MAX_ACK_BURST
&& let Ok(next) = rx.try_recv()
{
batch.push(next);
}

// The queue is still drained, exactly as the
// one-at-a-time worker did; only the send is skipped.
if client.outbound_teardown_in_progress() {
batch.clear();
continue;
}

// Encoding is synchronous, so the whole burst is marshalled
// before anything is sent and arrival order survives.
for (node, guard) in batch.drain(..) {
match client.encode_ack_from_snapshot(
node.get(),
AckParticipantPolicy::OmitReceiptDestinationDuplicate,
) {
Ok(buf) => {
frames.push(buf);
guards.push(guard);
}
// Matches the single-ack path: log and drop this one
// rather than failing the rest of the burst.
Err(e) => warn!("Failed to encode ack: {e}"),
}
}
if frames.is_empty() {
continue;
}

// The per-ack `wa.conn.ack` span lived in `send_ack_for`,
// which this path no longer calls; a burst reports itself
// once, with its size, rather than N times. The result
// inspection is inside the instrumented future, not after
// it: a failure has to be recorded while the span is open,
// the way `send_ack_for`'s `err(Debug)` used to. And
// `instrument` rather than `entered()`, because an
// EnteredSpan is not Send and cannot cross the await.
let frame_count = frames.len();
let send_and_report = async {
match client.send_raw_bytes_burst(&mut frames).await {
Ok(results) => {
for result in results {
if let Err(e) = result
&& !e.is_transport_unavailable()
{
warn!("Failed to send ack: {e:?}");
}
}
}
Err(e) => {
if !matches!(e, ClientError::NotConnected) {
warn!("Failed to send ack burst: {e:?}");
}
}
}
};
#[cfg(feature = "tracing")]
{
warn!("Failed to send ack: {e:?}");
use tracing::Instrument;
send_and_report
.instrument(tracing::trace_span!(
"wa.conn.ack_burst",
frames = frame_count
))
.await;
}
drop(guard);
#[cfg(not(feature = "tracing"))]
{
let _ = frame_count;
send_and_report.await;
}
debug_assert!(
frames.is_empty(),
"send_raw_bytes_burst must always drain its input"
);
guards.clear();
}
}))
.detach();
Expand Down
Loading
Loading