melin_server_runtime/client_frames.rs
1//! Transport-agnostic client frame processing.
2//!
3//! Both the kernel (io_uring) and DPDK client readers parse the same
4//! length-prefixed wire format (split by the wire protocol's zero-copy
5//! splitter, the one `melin-client` frames with too), decode through the
6//! same [`ErasedDecoder`], and publish [`InputSlot`]s to the same
7//! disruptor ring with identical batching semantics. This module extracts that
8//! shared logic into [`process_client_frames`] so both backends call a
9//! single implementation, over the per-connection input state both hold
10//! ([`ClientIngress`]).
11//!
12//! # Shedding and parking
13//!
14//! No request is left unanswered, and none waits for bytes that may never
15//! come. A request that finds the input ring full is answered
16//! `ServerBusy`, and so is every complete frame already buffered behind
17//! it, read only as far as its ID: the *shed tail*. Nothing in that tail
18//! is published, so nothing a client sent behind a shed request in the
19//! same burst is applied ahead of its resend. A trailing partial frame is
20//! not part of the tail: it is judged once it is whole.
21//!
22//! An answer with nowhere to go (a full early-reply queue, which fills
23//! only while the response stage is not draining it) *parks* the
24//! connection instead ([`FrameAction::Park`]): processing stops before the
25//! frame, which stays buffered with everything behind it, and the reader
26//! stops reading the connection and retries the buffer later. A park in
27//! the middle of a shed tail resumes shedding where it stopped, since the
28//! tail's extent is kept with the buffer.
29
30use std::time::{Duration, Instant};
31
32use tracing::debug;
33
34use melin_app::auth::{ClientRole, RoleId};
35use melin_app::decoder::{Decoded, ErasedDecoder};
36use melin_app::{AppEvent, Application};
37use melin_pipeline::ring;
38use melin_transport_core::health::{IngressMetrics, IngressTally};
39use melin_transport_core::pipeline::{ClientOrigin, InputSlot, RequestId};
40use melin_transport_core::trace::MonoTraceInstant;
41#[cfg(any(feature = "latency-trace", feature = "tick-to-trade"))]
42use melin_transport_core::trace::mono_trace_ns;
43use melin_wire_protocol::control::RefusalKind;
44use melin_wire_protocol::framing::{split_frame_limited, split_request};
45
46use crate::early_reply::{EarlyKind, EarlyReply, EarlyReplySender};
47use crate::halt::{HaltGate, Verdict};
48
49/// Bound on one client request frame, after the 4-byte length prefix:
50/// the wire protocol's, so a client library and a node agree on it by
51/// construction.
52///
53/// A frame declaring more is not read: both readers treat it as a
54/// protocol violation and drop the connection. A program that reads
55/// client frames itself takes the same bound from `melin-client`; an
56/// application sizes its requests against [`MAX_REQUEST_BODY`].
57pub(crate) const MAX_FRAME_SIZE: usize = melin_wire_protocol::blocking::MAX_FRAME_SIZE;
58
59/// Bound on one request body — what an application's `RequestDecoder`
60/// is handed after the runtime has read the tag and the request's ID.
61/// Public so an application can check its widest request against it at
62/// compile time; see the re-export in the crate root. The wire
63/// protocol's, the bound a client's request framing enforces, so the two
64/// sides agree on it by construction.
65pub const MAX_REQUEST_BODY: usize = melin_wire_protocol::framing::MAX_REQUEST_BODY;
66
67/// Outcome of [`process_client_frames`].
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub(crate) enum FrameAction {
70 /// Every complete frame was processed: published, or answered early (a
71 /// shed tail included). A partial trailing frame remains buffered for
72 /// the next receive.
73 Continue,
74 /// The connection is to be closed (the frames before the cause were
75 /// committed): an oversized frame, a frame that is not a request (an
76 /// empty frame, another tag, a request too short for its ID, in a shed
77 /// tail too), or a superseded node that takes nothing more from anyone.
78 Disconnect,
79 /// The early-reply queue is full: processing stopped before the frame
80 /// whose answer had nowhere to go, which stays buffered with everything
81 /// behind it. The reader stops reading the connection and calls again
82 /// later with no new bytes, until the call returns something else.
83 Park,
84}
85
86/// How long a connection may stay parked before the reader reports it:
87/// past this the response stage has stopped draining early replies, which
88/// is degraded operation rather than load. Also the shortest interval
89/// between two reports ([`LongParkReport`]).
90pub(crate) const LONG_PARK: Duration = Duration::from_secs(1);
91
92/// Paces the reader's report of connections parked for longer than
93/// [`LONG_PARK`]: at most one per [`LONG_PARK`], while any is.
94#[derive(Debug, Default)]
95pub(crate) struct LongParkReport {
96 last: Option<Instant>,
97}
98
99impl LongParkReport {
100 /// Whether a connection parked `since` has been parked past
101 /// [`LONG_PARK`] at `now`, and so counts towards the report.
102 pub(crate) fn parked_long(since: Instant, now: Instant) -> bool {
103 now.saturating_duration_since(since) > LONG_PARK
104 }
105
106 /// Whether to report `long_parked` connections parked past
107 /// [`LONG_PARK`] at `now`: never for none, and at most once per
108 /// [`LONG_PARK`] otherwise. A `true` starts the next interval.
109 fn due(&mut self, long_parked: usize, now: Instant) -> bool {
110 if long_parked == 0 {
111 return false;
112 }
113 if self
114 .last
115 .is_some_and(|last| now.duration_since(last) < LONG_PARK)
116 {
117 return false;
118 }
119 self.last = Some(now);
120 true
121 }
122
123 /// Report, at `warn`, `long_parked` connections parked past
124 /// [`LONG_PARK`] at `now`, when [`due`](Self::due): the one wording
125 /// both readers log.
126 pub(crate) fn report(&mut self, long_parked: usize, now: Instant) {
127 if self.due(long_parked, now) {
128 tracing::warn!(
129 connections = long_parked,
130 "client connections parked for over a second: the response stage is not taking \
131 early replies, and these clients are not read"
132 );
133 }
134 }
135}
136
137/// One client connection's input, as both readers hold it: the bytes
138/// received and not yet processed, and how far into them a shed tail
139/// still runs (see the module docs). Only [`process_client_frames`] reads
140/// or consumes it; a reader appends what it receives.
141#[derive(Debug)]
142pub(crate) struct ClientIngress {
143 /// Bytes received and not yet processed: complete frames still to be
144 /// answered after a park, then at most one partial frame. A `Vec`
145 /// compacted to the front after each call: frames are split where they
146 /// lie, as contiguous slices.
147 parse_buf: Vec<u8>,
148 /// How many bytes at the front of `parse_buf` are the rest of a shed
149 /// tail, each frame in them still to be answered `ServerBusy`; zero
150 /// outside one. Never more than `parse_buf.len()`, and always ending on
151 /// a frame boundary: only `process_client_frames` sets it, and nothing
152 /// else consumes bytes once the connection sends requests. `usize`, as
153 /// it counts bytes of a buffer.
154 shed_remaining: usize,
155}
156
157impl ClientIngress {
158 /// Input over `parse_buf`, an empty buffer (one recycled from a pool,
159 /// so its capacity is kept).
160 pub(crate) fn new(parse_buf: Vec<u8>) -> Self {
161 debug_assert!(parse_buf.is_empty(), "a connection starts with no input");
162 Self {
163 parse_buf,
164 shed_remaining: 0,
165 }
166 }
167
168 /// Bytes buffered and not yet processed.
169 #[cfg(feature = "dpdk")]
170 pub(crate) fn len(&self) -> usize {
171 self.parse_buf.len()
172 }
173
174 /// Append received bytes.
175 pub(crate) fn extend_from_slice(&mut self, bytes: &[u8]) {
176 self.parse_buf.extend_from_slice(bytes);
177 }
178
179 /// The buffer itself, for a receive that appends into it in place, and
180 /// for an authentication handshake, which consumes its own frame before
181 /// any request is read (and so before any tail can be shed). Nothing
182 /// may remove bytes through it once requests flow.
183 #[cfg(feature = "dpdk")]
184 pub(crate) fn buf_mut(&mut self) -> &mut Vec<u8> {
185 &mut self.parse_buf
186 }
187
188 /// The buffer, emptied, for a pool to hand the next connection.
189 #[cfg(feature = "dpdk")]
190 pub(crate) fn into_buf(mut self) -> Vec<u8> {
191 self.parse_buf.clear();
192 self.parse_buf
193 }
194
195 /// The bytes buffered, for the crate's tests.
196 #[cfg(test)]
197 pub(crate) fn bytes(&self) -> &[u8] {
198 &self.parse_buf
199 }
200
201 /// How many buffered bytes are the rest of a shed tail, for the
202 /// crate's tests.
203 #[cfg(test)]
204 pub(crate) fn shed_remaining(&self) -> usize {
205 self.shed_remaining
206 }
207}
208
209/// Extract, decode, and publish client request frames from `ingress`.
210///
211/// Processes every complete length-prefixed frame: each must be a request
212/// (`TAG_REQUEST` and the client's ID), decoded through `decoder`, and
213/// published to the input ring, the ID in its slot, under batched commits
214/// (cap: 16 events per commit to bound consumer visibility delay).
215/// Compacts the buffer on return.
216///
217/// Every request the reader does not publish is answered through
218/// `early_replies`, under its ID (see [`crate::halt`]): a filtered request
219/// with an empty batch, a refused one with the decoder's reason, a write
220/// refused while `halt` refuses writes with the application's rejection
221/// (queries are published either way), and the request that finds the
222/// input ring full with `ServerBusy`, as is every complete frame buffered
223/// behind it (the shed tail, see the module docs). The halt is sampled
224/// once per call, so one receive is judged as a whole, and the writes it
225/// refused are counted into `halt` once, at the end, as the requests it
226/// shed, filtered and refused are into `ingress`. On a superseded node
227/// nothing is read: the call returns [`FrameAction::Disconnect`] before
228/// the first frame.
229///
230/// Every early reply the call queues is visible to the response stage by
231/// the time it returns, whatever it returns: the queue is flushed once on
232/// the way out, around every return path. An early reply waits for
233/// nothing published, so where that flush falls relative to the input
234/// ring's commits does not matter.
235///
236/// A full early-reply queue parks the connection ([`FrameAction::Park`]):
237/// nothing is dropped and nothing is answered out of turn, and the next
238/// call, with or without new bytes, starts at the frame that met it.
239///
240/// `recv_ts` is the trace timestamp the caller captured once, at the
241/// moment the kernel handed it this recv's bytes (the io_uring CQE /
242/// DPDK `recv_into_vec` site). Every slot published from `parse_buf`
243/// is stamped with it, so the `reader: ingest` and `server e2e` stages
244/// measure from true wire receipt — frame decode included — rather than
245/// re-sampling per frame after decode (which excluded decode and drifted
246/// forward for later frames in a multi-frame recv). `()` (zero-sized)
247/// when `latency-trace` is disabled.
248#[allow(clippy::too_many_arguments)]
249pub(crate) fn process_client_frames<A: Application>(
250 ingress: &mut ClientIngress,
251 connection_id: u64,
252 key_hash: u64,
253 role: ClientRole<RoleId>,
254 producer: &mut ring::Producer<InputSlot<A::Event>>,
255 decoder: &dyn ErasedDecoder<A::Event>,
256 halt: &HaltGate,
257 early_replies: &mut EarlyReplySender<A::Report>,
258 metrics: &IngressMetrics,
259 batch_wall_ns: u64,
260 recv_ts: MonoTraceInstant,
261 #[cfg(feature = "latency-trace")] publish_rec: &mut melin_transport_core::trace::StageRecorder,
262 #[cfg(feature = "tick-to-trade")] ingest_rec: &mut melin_transport_core::trace::StageRecorder,
263) -> FrameAction {
264 let action = process_frames_unflushed::<A>(
265 ingress,
266 connection_id,
267 key_hash,
268 role,
269 producer,
270 decoder,
271 halt,
272 early_replies,
273 metrics,
274 batch_wall_ns,
275 recv_ts,
276 #[cfg(feature = "latency-trace")]
277 publish_rec,
278 #[cfg(feature = "tick-to-trade")]
279 ingest_rec,
280 );
281 // The one flush of the early replies the call staged, whatever the
282 // outcome: here, outside the body, so no return path inside it can
283 // leave a reply staged and unseen.
284 early_replies.flush();
285 action
286}
287
288/// [`process_client_frames`] without its closing flush of the early-reply
289/// queue: every reply it queues is only staged.
290#[allow(clippy::too_many_arguments)]
291#[inline]
292fn process_frames_unflushed<A: Application>(
293 ingress: &mut ClientIngress,
294 connection_id: u64,
295 key_hash: u64,
296 role: ClientRole<RoleId>,
297 producer: &mut ring::Producer<InputSlot<A::Event>>,
298 decoder: &dyn ErasedDecoder<A::Event>,
299 halt: &HaltGate,
300 early_replies: &mut EarlyReplySender<A::Report>,
301 metrics: &IngressMetrics,
302 batch_wall_ns: u64,
303 recv_ts: MonoTraceInstant,
304 #[cfg(feature = "latency-trace")] publish_rec: &mut melin_transport_core::trace::StageRecorder,
305 #[cfg(feature = "tick-to-trade")] ingest_rec: &mut melin_transport_core::trace::StageRecorder,
306) -> FrameAction {
307 let ClientIngress {
308 parse_buf,
309 shed_remaining,
310 } = ingress;
311 debug_assert!(*shed_remaining <= parse_buf.len());
312 let mut cursor = 0;
313 // Where the shed tail ends, as an offset into `parse_buf`: every frame
314 // that starts before it is answered `ServerBusy`. What a park left of
315 // a tail, then set when a request finds the ring full.
316 let mut shed_end = *shed_remaining;
317 let mut result = FrameAction::Continue;
318 // Requests answered early in this call, counted into `metrics` once at
319 // the end rather than with an atomic add each; the shed count is also
320 // the call's one log line.
321 let mut tally = IngressTally::default();
322
323 // Batch publishes into a single Release store on the input ring's
324 // producer cursor. Bounded at COMMIT_EVERY to cap consumer-
325 // visibility delay (see reader.rs for the measured rationale).
326 const COMMIT_EVERY: u64 = 16;
327 let refusal_reason = match halt.verdict() {
328 Verdict::Take => None,
329 Verdict::Refuse(reason) => Some(reason),
330 Verdict::Close => {
331 debug!(connection_id, "node superseded, closing connection");
332 return FrameAction::Disconnect;
333 }
334 };
335 let mut batch = producer.batch();
336 // Writes refused in this call, counted into the gate once at the end
337 // rather than with an atomic add each.
338 let mut refused: u64 = 0;
339
340 loop {
341 let frame_start = cursor;
342 // An oversized prefix is refused as soon as its 4 bytes are in,
343 // without waiting for a payload that would never be read.
344 let frame = match split_frame_limited(&parse_buf[cursor..], MAX_FRAME_SIZE) {
345 Ok(Some(frame)) => frame,
346 // A partial frame stays in `parse_buf` for the next recv.
347 Ok(None) => break,
348 Err(too_large) => {
349 debug!(
350 connection_id,
351 frame_len = too_large.declared,
352 "frame too large, dropping connection"
353 );
354 result = FrameAction::Disconnect;
355 break;
356 }
357 };
358 cursor += frame.consumed();
359
360 // Only a request may come after the handshake: a client sends none
361 // of the protocol's own frames, and the tag and the ID are read
362 // here, so no decoder ever sees them. Anything else is a client
363 // speaking another protocol, told at once by the close.
364 let (request_id, body) = match split_request(frame.payload) {
365 Ok(request) => request,
366 Err(not_a_request) => {
367 debug!(
368 connection_id,
369 %not_a_request,
370 "not a request, dropping connection"
371 );
372 result = FrameAction::Disconnect;
373 break;
374 }
375 };
376
377 // In a shed tail: answered busy, read only as far as its ID, never
378 // decoded.
379 if frame_start < shed_end {
380 if !queue_early(early_replies, connection_id, request_id, EarlyKind::Busy) {
381 cursor = frame_start;
382 result = FrameAction::Park;
383 break;
384 }
385 tally.shed += 1;
386 continue;
387 }
388
389 // An answer decided here, or the event to publish.
390 let early = match decoder.decode_erased(body, role) {
391 Decoded::Filter => EarlyKind::Filtered,
392 Decoded::PermissionDenied(reason) => EarlyKind::Refused {
393 kind: RefusalKind::PermissionDenied,
394 reason,
395 },
396 Decoded::DecodeError(reason) => EarlyKind::Refused {
397 kind: RefusalKind::Malformed,
398 reason,
399 },
400 Decoded::Permitted(event) => match refusal_reason {
401 Some(reason) if !event.is_query() => {
402 EarlyKind::Rejected(A::build_reject(&event, reason))
403 }
404 _ => {
405 if publish::<A>(
406 &mut batch,
407 ClientOrigin {
408 connection_id,
409 request_id,
410 key_hash,
411 },
412 event,
413 batch_wall_ns,
414 recv_ts,
415 #[cfg(feature = "latency-trace")]
416 publish_rec,
417 #[cfg(feature = "tick-to-trade")]
418 ingest_rec,
419 ) {
420 if batch.len() >= COMMIT_EVERY {
421 batch.commit();
422 batch = producer.batch();
423 }
424 continue;
425 }
426 // The input ring is full: this request is shed, and
427 // so is every complete frame buffered behind it. The
428 // tail starts here, so a park on this very answer
429 // sheds this request on the next call rather than
430 // publishing it.
431 shed_end = frame_start + complete_frames_len(&parse_buf[frame_start..]);
432 if !queue_early(early_replies, connection_id, request_id, EarlyKind::Busy) {
433 cursor = frame_start;
434 result = FrameAction::Park;
435 break;
436 }
437 tally.shed += 1;
438 continue;
439 }
440 },
441 };
442 if !queue_early(early_replies, connection_id, request_id, early) {
443 // Re-read on the next call: a decoder is a pure function of
444 // the body and the role, so it decides the same again.
445 cursor = frame_start;
446 result = FrameAction::Park;
447 break;
448 }
449 // Counted and logged once its answer is queued: a request parked
450 // on is decoded again by every retry until then.
451 match early {
452 EarlyKind::Rejected(_) => refused += 1,
453 EarlyKind::Refused { kind, reason } => {
454 tally.refused(kind);
455 match kind {
456 RefusalKind::PermissionDenied => {
457 debug!(connection_id, reason, "permission denied, refusing request")
458 }
459 RefusalKind::Malformed => {
460 debug!(connection_id, reason, "decode error, refusing request")
461 }
462 }
463 }
464 EarlyKind::Filtered => tally.filtered += 1,
465 // A busy is queued and counted where the ring is found full,
466 // never through here.
467 EarlyKind::Busy => {}
468 }
469 }
470
471 batch.commit();
472
473 if refused > 0 {
474 halt.record_refused(refused);
475 }
476 metrics.record(&tally);
477 if tally.shed > 0 {
478 debug!(
479 connection_id,
480 shed = tally.shed,
481 "input ring full, requests shed"
482 );
483 }
484
485 // What is left of the tail: nothing once the call has passed its end.
486 *shed_remaining = shed_end.saturating_sub(cursor);
487
488 // Compact: shift remaining bytes to the front.
489 if cursor > 0 {
490 let remaining = parse_buf.len() - cursor;
491 parse_buf.copy_within(cursor.., 0);
492 parse_buf.truncate(remaining);
493 }
494
495 result
496}
497
498/// How many bytes at the front of `bytes` are complete frames: up to the
499/// first partial frame, or an oversized prefix, or the end. What a shed
500/// tail covers.
501fn complete_frames_len(bytes: &[u8]) -> usize {
502 let mut len = 0;
503 // The scan stops at an oversized prefix without reporting it: the
504 // frame is outside the tail, and the frame loop closes the connection
505 // when it reaches it.
506 while let Ok(Some(frame)) = split_frame_limited(&bytes[len..], MAX_FRAME_SIZE) {
507 len += frame.consumed();
508 }
509 len
510}
511
512/// Stage the early reply `kind` for request `request_id`. `false` when the
513/// queue is full: the caller parks the connection, since the answer has
514/// nowhere to go yet.
515fn queue_early<R: Copy>(
516 early_replies: &mut EarlyReplySender<R>,
517 connection_id: u64,
518 request_id: RequestId,
519 kind: EarlyKind<R>,
520) -> bool {
521 // A full queue is not logged here: the reader logs the park it
522 // causes, once, where a line here would repeat on every retry of the
523 // parked connection.
524 early_replies
525 .try_send(EarlyReply {
526 connection_id,
527 request_id,
528 kind,
529 })
530 .is_ok()
531}
532
533/// Publish one client event into `batch`. `false` when the input ring is
534/// full and nothing was published.
535#[allow(clippy::too_many_arguments)]
536#[inline]
537fn publish<A: Application>(
538 batch: &mut ring::Batch<'_, InputSlot<A::Event>>,
539 origin: ClientOrigin,
540 event: A::Event,
541 batch_wall_ns: u64,
542 recv_ts: MonoTraceInstant,
543 #[cfg(feature = "latency-trace")] publish_rec: &mut melin_transport_core::trace::StageRecorder,
544 #[cfg(feature = "tick-to-trade")] ingest_rec: &mut melin_transport_core::trace::StageRecorder,
545) -> bool {
546 let ts = if event.is_query() { 0 } else { batch_wall_ns };
547
548 #[cfg(feature = "latency-trace")]
549 let pre_publish = mono_trace_ns();
550
551 // The whole slot is rebuilt in place, through the one constructor
552 // for a client slot, so no field keeps the previous occupant's
553 // value.
554 if batch
555 .try_push_with(|slot| {
556 *slot = InputSlot::client(origin, event, ts, recv_ts);
557 })
558 .is_err()
559 {
560 return false;
561 }
562
563 #[cfg(feature = "latency-trace")]
564 {
565 let publish_done = mono_trace_ns();
566 publish_rec.record_elapsed(pre_publish, publish_done);
567 }
568 #[cfg(feature = "tick-to-trade")]
569 ingest_rec.record_elapsed(recv_ts, mono_trace_ns());
570 true
571}
572
573// `process_client_frames` is tested in `reader.rs`, beside the reader that
574// calls it, on its fixtures.
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 /// The long-park report: never for no connection, then at most once
580 /// per `LONG_PARK` while any stays parked.
581 #[test]
582 fn a_long_park_is_reported_at_most_once_per_interval() {
583 let mut report = LongParkReport::default();
584 let start = Instant::now();
585 assert!(!report.due(0, start), "nothing parked long");
586 assert!(report.due(2, start), "the first report");
587 assert!(!report.due(2, start + LONG_PARK / 2), "within the interval");
588 assert!(report.due(1, start + LONG_PARK), "the next interval");
589 assert!(!report.due(0, start + LONG_PARK * 3), "none any more");
590 assert!(report.due(1, start + LONG_PARK * 3), "a new one, later");
591 }
592
593 /// A connection counts towards the report only once parked past
594 /// `LONG_PARK`, and a clock read before the park (the slow checks'
595 /// read, taken ahead of the pass that parks it) counts as no time.
596 #[test]
597 fn a_park_is_long_past_the_interval_only() {
598 let since = Instant::now();
599 assert!(!LongParkReport::parked_long(since, since));
600 assert!(!LongParkReport::parked_long(since, since + LONG_PARK));
601 assert!(LongParkReport::parked_long(
602 since,
603 since + LONG_PARK + Duration::from_millis(1)
604 ));
605 assert!(!LongParkReport::parked_long(
606 since + Duration::from_secs(5),
607 since
608 ));
609 }
610
611 /// The tail covers whole frames only: up to a partial frame, an
612 /// oversized prefix, or the end.
613 #[test]
614 fn a_shed_tail_covers_complete_frames_only() {
615 let frame = |len: u8| {
616 let mut f = u32::from(len).to_le_bytes().to_vec();
617 f.extend(std::iter::repeat_n(0xAB, usize::from(len)));
618 f
619 };
620 let two = [frame(3), frame(5)].concat();
621 assert_eq!(complete_frames_len(&[]), 0);
622 assert_eq!(complete_frames_len(&two), two.len());
623 assert_eq!(
624 complete_frames_len(&[two.as_slice(), &[9, 0]].concat()),
625 two.len()
626 );
627 let oversized = (MAX_FRAME_SIZE as u32 + 1).to_le_bytes();
628 assert_eq!(
629 complete_frames_len(&[two.as_slice(), &oversized, &frame(1)].concat()),
630 two.len()
631 );
632 }
633}