Skip to main content

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}