Skip to content
43 changes: 38 additions & 5 deletions src/client/messaging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,54 @@ 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.
pub(crate) async fn send_raw_bytes_burst(
&self,
frames: Vec<Vec<u8>>,
) -> Result<Vec<crate::socket::error::EncryptSendResult>, ClientError> {
let noise_socket = self.get_noise_socket().await?;
let sends = frames
.into_iter()
.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
81 changes: 76 additions & 5 deletions src/client/node_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,17 @@ impl Client {
let _ = tx.try_send((node, guard));
}

/// 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 +678,76 @@ impl Client {
let client = Arc::downgrade(self);
self.runtime
.spawn(Box::pin(async move {
while let Ok((node, guard)) = rx.recv().await {
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.
let mut batch = vec![first];
while batch.len() < Self::MAX_ACK_BURST
&& let Ok(next) = rx.try_recv()
{
warn!("Failed to send ack: {e:?}");
batch.push(next);
}

// Encoding is synchronous, so the whole burst is marshalled
// before anything is sent and arrival order survives.
let mut frames = Vec::with_capacity(batch.len());
let mut guards = Vec::with_capacity(batch.len());
for (node, guard) in batch {
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. `instrument`
// rather than `entered()`: an EnteredSpan is not Send and
// cannot be held across the await.
#[cfg(feature = "tracing")]
let burst = {
use tracing::Instrument;
let span = tracing::trace_span!("wa.conn.ack_burst", frames = frames.len());
client.send_raw_bytes_burst(frames).instrument(span).await
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
};
#[cfg(not(feature = "tracing"))]
let burst = client.send_raw_bytes_burst(frames).await;
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
match burst {
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:?}");
}
}
}
drop(guard);
drop(guards);
}
}))
.detach();
Expand Down
51 changes: 48 additions & 3 deletions src/message/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ impl Client {
let _ = tx.try_send((Arc::clone(info), guard));
}

/// How many queued receipts one burst may take; mirrors the ack worker's
/// [`MAX_ACK_BURST`](Client::MAX_ACK_BURST), where the tradeoff is measured.
const MAX_RECEIPT_BURST: usize = 4;

/// Worker task shared by every live delivery receipt. Holds only a `Weak`
/// so a dropped `Client` closes the channel and ends the task instead of
/// keeping the client alive.
Expand All @@ -138,12 +142,53 @@ impl Client {
let client = Arc::downgrade(self);
self.runtime
.spawn(Box::pin(async move {
while let Ok((info, guard)) = rx.recv().await {
while let Ok(first) = rx.recv().await {
let Some(client) = client.upgrade() else {
break;
};
client.send_delivery_receipt(&info).await;
drop(guard);

// Same reasoning as the ack worker: awaiting each receipt
// before reading the next means the noise sender never has
// two frames to coalesce. `try_recv` only, so nothing waits
// on work that has not arrived.
let mut batch = vec![first];
while batch.len() < Self::MAX_RECEIPT_BURST
&& let Ok(next) = rx.try_recv()
{
batch.push(next);
}

let mut frames = Vec::with_capacity(batch.len());
let mut guards = Vec::with_capacity(batch.len());
for (info, guard) in batch {
// Building the node is synchronous, so the burst is
// fully prepared before anything reaches the socket.
if let Some(frame) = client.prepare_delivery_receipt(&info) {
frames.push(frame);
guards.push(guard);
}
}
if frames.is_empty() {
continue;
}

match client.send_raw_bytes_burst(frames).await {
Ok(results) => {
for result in results {
if let Err(e) = result
&& !e.is_transport_unavailable()
{
log::warn!(target: "Client/Receipt", "Failed to send delivery receipt: {e:?}");
}
}
}
Err(e) => {
if !matches!(e, crate::client::ClientError::NotConnected) {
log::warn!(target: "Client/Receipt", "Failed to send delivery receipt burst: {e:?}");
}
}
}
drop(guards);
}
}))
.detach();
Expand Down
33 changes: 26 additions & 7 deletions src/receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,10 +589,29 @@ impl Client {
/// `Send/DeliveryReceiptJob.js`); these are NOT skipped anymore.
/// - Newsletters and messages without an ID are skipped (newsletters are
/// handled by the ack gate, not here).
#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.receipt.send_delivery", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), sender = %info.source.sender.observe(), msg_id = %info.id)))]
pub(crate) async fn send_delivery_receipt(&self, info: &MessageInfo) {
if !Self::should_send_delivery_receipt(info) {
let Some(frame) = self.prepare_delivery_receipt(info) else {
return;
};
if let Err(e) = self.send_raw_bytes(frame).await
&& !matches!(e, crate::client::ClientError::NotConnected)
{
log::warn!(target: "Client/Receipt", "Failed to send delivery receipt for message {}: {:?}", info.id, e);
}
}

/// Everything [`Self::send_delivery_receipt`] does short of the send: the
/// eligibility gate, node construction, logging and marshalling. Returns
/// `None` when no receipt is owed. Split out so the receipt worker can
/// prepare a whole burst before touching the socket.
///
/// Carries the per-receipt tracing span, because this is the step both the
/// single-receipt and the burst path go through - the span used to sit on
/// [`Self::send_delivery_receipt`], which the worker no longer calls.
#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.receipt.send_delivery", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), sender = %info.source.sender.observe(), msg_id = %info.id)))]
pub(crate) fn prepare_delivery_receipt(&self, info: &MessageInfo) -> Option<Vec<u8>> {
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
if !Self::should_send_delivery_receipt(info) {
return None;
}

let receipt_node = build_delivery_receipt_node(info, self.receipts_are_active());
Expand All @@ -611,11 +630,11 @@ impl Client {
debug!(target: "Client/Receipt", "Sending {} receipt for message {} to {}",
receipt_kind.as_wire_str(), info.id, info.source.sender.observe());

if let Err(e) = self.send_node(receipt_node).await
&& !matches!(e, crate::client::ClientError::NotConnected)
{
log::warn!(target: "Client/Receipt", "Failed to send delivery receipt for message {}: {:?}", info.id, e);
}
self.marshal_node_for_send(receipt_node)
.inspect_err(|e| {
log::warn!(target: "Client/Receipt", "Failed to marshal delivery receipt for message {}: {:?}", info.id, e);
})
.ok()
}

/// Buffer an offline-drained message's delivery receipt for the aggregate
Expand Down
3 changes: 3 additions & 0 deletions src/socket/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ pub enum SocketError {

pub type Result<T> = std::result::Result<T, SocketError>;

/// Outcome of one frame's trip through the noise sender.
pub type EncryptSendResult = std::result::Result<(), EncryptSendError>;

#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum EncryptSendErrorKind {
Expand Down
Loading
Loading