Skip to main content

forge_server/transport/
batch.rs

1//! When a connection's stream goes out.
2//!
3//! A turn's frames arrive in bursts, and most of a storm is one kind: the CLI
4//! reports its thinking tokens about every 50 of them. A batch waits for the
5//! shorter of two intervals, folds the token appends it holds into the single
6//! frame they draw as, and writes the rest out as they came - so the frame
7//! count a storm produces drops, and what any frame means does not.
8
9use std::time::{Duration, Instant};
10
11use axum::extract::ws::Message;
12use futures_util::SinkExt;
13
14use crate::delivery::delivery_turn;
15use crate::transport::envelope::ServerMessage;
16use crate::transport::{WRITE_BOUND, write_within};
17use crate::{SeatUpdate, SessionUpdate};
18
19/// How long a batch waits for the updates behind it before it goes out.
20///
21/// One paint: a view that draws at the frame rate sees a batch in its next
22/// frame whether it waited or not.
23pub const FLUSH_INTERVAL: Duration = Duration::from_millis(16);
24
25/// The longest an update waits for company, however busy the stream is.
26///
27/// A turn emitting continuously never leaves the window quiet, so without
28/// this a batch would be held for as long as the turn runs.
29pub const FLUSH_CEILING: Duration = Duration::from_millis(100);
30
31/// The updates waiting for one write, and when they have waited long enough.
32#[derive(Default)]
33pub struct Batch {
34    held: Vec<SeatUpdate>,
35    /// When the first held update landed, which the ceiling counts from.
36    opened: Option<Instant>,
37    /// When the window the last arrival opened closes.
38    quiet: Option<Instant>,
39}
40
41impl Batch {
42    /// Hold one update, and hold the window open from its arrival.
43    ///
44    /// The frame carries the number its seat minted, so what the client reads
45    /// off the wire says which frames of the seat's stream this write stands
46    /// for.
47    pub fn push(&mut self, at: Instant, frame: SeatUpdate) {
48        if self.held.is_empty() {
49            self.opened = Some(at);
50        }
51        self.quiet = Some(at + FLUSH_INTERVAL);
52        self.held.push(frame);
53    }
54
55    /// When the held updates go out: the window the last arrival opened,
56    /// unless the ceiling is nearer.
57    ///
58    /// `None` with nothing held, which is what keeps a caller from waiting on
59    /// a timer it has no reason to arm.
60    pub fn due(&self) -> Option<Instant> {
61        let opened = self.opened?;
62        let quiet = self.quiet?;
63        Some(quiet.min(opened + FLUSH_CEILING))
64    }
65
66    /// Take what is held, leaving the batch empty.
67    pub fn take(&mut self) -> Vec<SeatUpdate> {
68        self.opened = None;
69        self.quiet = None;
70        std::mem::take(&mut self.held)
71    }
72}
73
74/// Fold `update` into `last` when the two are consecutive token appends for
75/// one seat, and answer whether it did.
76///
77/// Both views draw a turn's estimate as the SUM of the deltas, because
78/// `estimated_tokens` restarts at each thinking block and the running value is
79/// not a turn total - so one frame carrying the sum draws exactly what the run
80/// drew, and the run itself is what a storm is made of.
81///
82/// Everything but the growth is the arrival being folded in: the running
83/// value, the id and the session are that frame's own, and only the counter is
84/// the run's.
85fn merge(last: &mut SessionUpdate, update: &SessionUpdate) -> bool {
86    let SessionUpdate::ChatAppended { key, source, msg } = update else {
87        return false;
88    };
89    let forge_primitives::Message::ThinkingTokens {
90        estimated_tokens,
91        estimated_tokens_delta,
92        uuid,
93        session_id,
94        extras,
95    } = msg
96    else {
97        return false;
98    };
99    let SessionUpdate::ChatAppended { key: held_key, source: held_source, msg: held_msg } = last
100    else {
101        return false;
102    };
103    // The occupant needs no comparing beside the seat: a change of session
104    // rides `SessionReplaced`, which is not a counter and breaks the run.
105    if held_key != key || held_source != source {
106        return false;
107    }
108    let forge_primitives::Message::ThinkingTokens { estimated_tokens_delta: held_delta, .. } =
109        held_msg
110    else {
111        return false;
112    };
113
114    let summed = held_delta.saturating_add(*estimated_tokens_delta);
115    // The id, the session and the extras are the surviving frame's own -
116    // the newest is the one the fold keeps, and a fact it carried must
117    // survive the fold rather than being rebuilt away.
118    *held_msg = forge_primitives::Message::ThinkingTokens {
119        estimated_tokens: *estimated_tokens,
120        estimated_tokens_delta: summed,
121        uuid: uuid.clone(),
122        session_id: session_id.clone(),
123        extras: extras.clone(),
124    };
125    true
126}
127
128/// Write a run of updates to a client: every one its own frame - the token
129/// appends among them folded into the one frame they draw as - in the order
130/// given, and ONE flush for the lot.
131///
132/// A run is a batch drained by its deadline, or the backlog a subscribe is
133/// handed before its snapshot. Nothing held is nothing to write, which the
134/// caller relies on when it clears the way for an answer.
135pub async fn flush<S>(socket: &mut S, frames: Vec<SeatUpdate>) -> anyhow::Result<()>
136where
137    S: futures_util::Sink<Message, Error = axum::Error> + Unpin,
138{
139    if frames.is_empty() {
140        return Ok(());
141    }
142    for (frame, span) in folded(frames) {
143        let update = std::sync::Arc::unwrap_or_clone(frame.update);
144        // A delivery is drawn as the turn it reaches the model as, and the
145        // CLI echoes no prompt back: a client drawing only frames would show
146        // the assistant answering something nobody saw. The frame carries the
147        // delivery's own kind, so a reader's follow can tell it from a
148        // person's send (#2198).
149        //
150        // **The forged turn carries no number.** It is this connection's own
151        // re-shaping of the frame below it, which carries the delivery's own
152        // - so a client advancing its sequence on both would read one frame
153        // twice, and one it missed is repaired from the ring as the envelope
154        // it is.
155        if let Some(key) = update.slot().cloned()
156            && let Some((msg, source)) = delivery_turn(&update, &key)
157        {
158            feed(
159                socket,
160                ServerMessage::Update {
161                    update: Box::new(SessionUpdate::ChatAppended {
162                        key,
163                        msg,
164                        source: Some(source),
165                    }),
166                    seq: None,
167                    span: 0,
168                },
169            )
170            .await?;
171        }
172        feed(socket, ServerMessage::Update { update: Box::new(update), seq: frame.seq, span })
173            .await?;
174    }
175    write_within(WRITE_BOUND, async { socket.flush().await.map_err(anyhow::Error::from) }).await?;
176    Ok(())
177}
178
179/// The run of frames as the client reads them: the token appends folded into
180/// the one frame they draw as, each survivor carrying how many of the seat's
181/// numbers it stands for.
182///
183/// **The span is what keeps a folded run from reading as a gap.** The numbers
184/// of the frames the fold took are still consumed - nothing re-numbers a
185/// stream - so a client told only the survivor's own would ask for frames it
186/// had already drawn.
187fn folded(frames: Vec<SeatUpdate>) -> Vec<(SeatUpdate, u64)> {
188    let mut run: Vec<(SeatUpdate, u64)> = Vec::with_capacity(frames.len());
189    for frame in frames {
190        if let Some((last, span)) = run.last_mut()
191            && merge(std::sync::Arc::make_mut(&mut last.update), &frame.update)
192        {
193            // The survivor keeps the OLDEST frame's number and counts the
194            // rest: a client's sequence advances over the run exactly as it
195            // does over one frame.
196            *span = span.saturating_add(1);
197            continue;
198        }
199        run.push((frame, 1));
200    }
201    run
202}
203
204/// Put one message in the socket's buffer, where the batch's flush picks it
205/// up - under the write bound, because a full buffer blocks here and a peer
206/// that stopped reading keeps it full.
207async fn feed<S>(socket: &mut S, message: ServerMessage) -> anyhow::Result<()>
208where
209    S: futures_util::Sink<Message, Error = axum::Error> + Unpin,
210{
211    let text = serde_json::to_string(&message)?;
212    write_within(WRITE_BOUND, async {
213        socket.feed(Message::Text(text.into())).await.map_err(anyhow::Error::from)
214    })
215    .await?;
216    Ok(())
217}
218
219#[cfg(test)]
220mod tests {
221    use std::pin::Pin;
222    use std::task::{Context, Poll};
223    use std::time::{Duration, Instant};
224
225    use forge_primitives::SessionSlot;
226    use forge_primitives::cloud::service_status::ServiceSeverity;
227    use futures_util::Sink;
228
229    use super::*;
230    use crate::SessionUpdate;
231    use crate::transport::envelope::ServerMessage;
232
233    /// One update, told from another by the name it carries.
234    fn status(message: &str) -> SessionUpdate {
235        SessionUpdate::ServiceStatus {
236            severity: ServiceSeverity::Warning,
237            message: message.to_owned(),
238        }
239    }
240
241    /// One frame of the stream: the update, with the number a seat would have
242    /// minted for it.
243    fn frame(update: SessionUpdate) -> SeatUpdate {
244        SeatUpdate { seq: Some(1), update: std::sync::Arc::new(update) }
245    }
246
247    /// What a run of frames carries, in the order it was given.
248    fn names(frames: &[SeatUpdate]) -> Vec<&str> {
249        frames
250            .iter()
251            .map(|frame| match &*frame.update {
252                SessionUpdate::ServiceStatus { message, .. } => message.as_str(),
253                other => panic!("a batch carries what was put in it, and this is {other:?}"),
254            })
255            .collect()
256    }
257
258    /// A sink that keeps the frames it was fed and counts its flushes.
259    ///
260    /// The count is the property under test: one flush per batch rather than
261    /// one per frame is all a client could measure of this.
262    #[derive(Default)]
263    struct Recording {
264        fed: Vec<Message>,
265        flushes: usize,
266    }
267
268    impl Sink<Message> for Recording {
269        type Error = axum::Error;
270
271        fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
272            Poll::Ready(Ok(()))
273        }
274
275        fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
276            self.fed.push(item);
277            Ok(())
278        }
279
280        fn poll_flush(
281            mut self: Pin<&mut Self>,
282            _: &mut Context<'_>,
283        ) -> Poll<Result<(), Self::Error>> {
284            self.flushes += 1;
285            Poll::Ready(Ok(()))
286        }
287
288        fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
289            Poll::Ready(Ok(()))
290        }
291    }
292
293    /// What each fed frame carries, read through the shape a client reads.
294    fn sent(sink: &Recording) -> Vec<SeatUpdate> {
295        sink.fed
296            .iter()
297            .map(|frame| {
298                let Message::Text(text) = frame else {
299                    panic!("the stream crosses as text, and this is {frame:?}")
300                };
301                let ServerMessage::Update { update, seq, .. } =
302                    serde_json::from_str(text.as_str()).expect("a frame a client decodes")
303                else {
304                    panic!("every frame a batch writes is an update");
305                };
306                SeatUpdate { seq, update: update.into() }
307            })
308            .collect()
309    }
310
311    /// The window: each arrival holds the batch open for another interval, so
312    /// a burst leaves together rather than one update per interval.
313    #[test]
314    fn an_arrival_holds_the_batch_open_for_another_interval() {
315        let base = Instant::now();
316        let mut batch = Batch::default();
317
318        batch.push(base, frame(status("one")));
319        assert_eq!(
320            batch.due(),
321            Some(base + FLUSH_INTERVAL),
322            "a lone update goes out one interval after it lands",
323        );
324
325        let later = base + Duration::from_millis(5);
326        batch.push(later, frame(status("two")));
327        assert_eq!(
328            batch.due(),
329            Some(later + FLUSH_INTERVAL),
330            "and the update behind it holds the batch open from its own arrival",
331        );
332    }
333
334    /// The ceiling: a stream that never leaves the window quiet - a turn
335    /// emitting every few milliseconds - still goes out, and the ceiling is
336    /// what decides when.
337    #[test]
338    fn a_stream_that_never_goes_quiet_goes_out_at_the_ceiling() {
339        let base = Instant::now();
340        let mut batch = Batch::default();
341
342        let mut at = base;
343        for step in 0..=20 {
344            batch.push(at, frame(status(&format!("frame {step}"))));
345            at += Duration::from_millis(5);
346        }
347
348        assert_eq!(
349            batch.due(),
350            Some(base + FLUSH_CEILING),
351            "the ceiling decides, not the last arrival, so a busy batch is never held past it",
352        );
353    }
354
355    /// Taking a batch hands back what it holds in the order it was given, and
356    /// leaves nothing behind.
357    #[test]
358    fn taking_a_batch_hands_back_its_updates_in_order_and_clears_it() {
359        let base = Instant::now();
360        let mut batch = Batch::default();
361        for name in ["one", "two", "three"] {
362            batch.push(base, frame(status(name)));
363        }
364
365        let taken = batch.take();
366
367        assert_eq!(
368            names(&taken),
369            ["one", "two", "three"],
370            "the batch keeps the order the core emitted",
371        );
372        assert!(
373            batch.take().is_empty(),
374            "and taking it leaves nothing for a second flush to write",
375        );
376        assert_eq!(batch.due(), None, "with nothing held there is no deadline to keep");
377    }
378
379    /// The write itself: every update its own frame, in order, and one flush
380    /// for the lot.
381    #[tokio::test]
382    async fn a_batch_goes_out_as_its_updates_and_one_flush() {
383        let base = Instant::now();
384        let mut batch = Batch::default();
385        for name in ["one", "two", "three"] {
386            batch.push(base, frame(status(name)));
387        }
388        let mut sink = Recording::default();
389
390        flush(&mut sink, batch.take()).await.expect("the batch writes");
391
392        assert_eq!(sink.flushes, 1, "a batch is one flush on the socket, not one per update");
393        assert_eq!(
394            names(&sent(&sink)),
395            ["one", "two", "three"],
396            "and every update crosses as its own frame, in the order it was emitted",
397        );
398    }
399
400    /// A sink that takes frames and never completes a flush: a peer that
401    /// stopped reading accepts into its buffer and stops draining it.
402    struct NeverFlushes;
403
404    impl Sink<Message> for NeverFlushes {
405        type Error = axum::Error;
406
407        fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
408            Poll::Ready(Ok(()))
409        }
410
411        fn start_send(self: Pin<&mut Self>, _: Message) -> Result<(), Self::Error> {
412            Ok(())
413        }
414
415        fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
416            Poll::Pending
417        }
418
419        fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
420            Poll::Ready(Ok(()))
421        }
422    }
423
424    /// **A batched write is bounded like a single frame.** Most frames a
425    /// client receives go out in a batch, so a peer that stops reading in a
426    /// stream hangs here - and the bound must end the connection rather than
427    /// leave it waiting on a flush that never returns. A bound on the
428    /// single-frame path alone is the change this catches.
429    #[tokio::test(start_paused = true)]
430    async fn a_batch_whose_flush_never_completes_ends_at_the_write_bound() {
431        let mut sink = NeverFlushes;
432        let outcome = tokio::time::timeout(
433            crate::transport::WRITE_BOUND * 2,
434            flush(&mut sink, vec![frame(status("one"))]),
435        )
436        .await
437        .expect("the batch ends at the write bound rather than never");
438        let error = outcome.expect_err("a flush that never completes must not hold the connection");
439        assert!(
440            error.downcast_ref::<crate::transport::WriteStalled>().is_some(),
441            "the batch ends on the write bound rather than on a sink error: {error:?}",
442        );
443    }
444
445    /// A sink whose buffer is full: `feed` blocks in `poll_ready` before it
446    /// can take a frame, which is the stall a stopped reader leaves before any
447    /// flush is reached.
448    struct FullBuffer;
449
450    impl Sink<Message> for FullBuffer {
451        type Error = axum::Error;
452
453        fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
454            Poll::Pending
455        }
456
457        fn start_send(self: Pin<&mut Self>, _: Message) -> Result<(), Self::Error> {
458            Ok(())
459        }
460
461        fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
462            Poll::Pending
463        }
464
465        fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
466            Poll::Ready(Ok(()))
467        }
468    }
469
470    /// **The feed is bounded, not only the flush.** A full buffer blocks at
471    /// `feed` before any flush is reached, so a bound on the flush alone
472    /// leaves the batch's first write holding the connection.
473    #[tokio::test(start_paused = true)]
474    async fn a_batch_whose_feed_cannot_take_a_frame_ends_at_the_write_bound() {
475        let mut sink = FullBuffer;
476        let outcome = tokio::time::timeout(
477            crate::transport::WRITE_BOUND * 2,
478            flush(&mut sink, vec![frame(status("one"))]),
479        )
480        .await
481        .expect("the feed ends at the write bound rather than never");
482        let error =
483            outcome.expect_err("a frame the buffer cannot take must not hold the connection");
484        assert!(
485            error.downcast_ref::<crate::transport::WriteStalled>().is_some(),
486            "the feed ends on the write bound rather than on a sink error: {error:?}",
487        );
488    }
489
490    /// One token append, as the CLI sends them: the running value the block
491    /// has reached and the growth since the previous event. Both restart at
492    /// each thinking block, so a run of them is a turn's estimate only as a
493    /// sum of its deltas.
494    fn token(seat: &SessionSlot, running: u64, delta: i64, uuid: &str) -> SessionUpdate {
495        SessionUpdate::ChatAppended {
496            key: seat.clone(),
497            source: None,
498            msg: forge_primitives::Message::ThinkingTokens {
499                estimated_tokens: running,
500                estimated_tokens_delta: delta,
501                uuid: uuid.to_owned(),
502                session_id: "s".to_owned(),
503                extras: serde_json::Map::new(),
504            },
505        }
506    }
507
508    /// An unmodelled fact a token frame carried, read back for the fold's
509    /// own pin.
510    fn carried(update: &SessionUpdate) -> Option<&serde_json::Value> {
511        match update {
512            SessionUpdate::ChatAppended {
513                msg: forge_primitives::Message::ThinkingTokens { extras, .. },
514                ..
515            } => extras.get("carried"),
516            _ => None,
517        }
518    }
519
520    /// The id a token frame carries, which the frame a run folds into has to
521    /// name.
522    fn naming(update: &SessionUpdate) -> (&str, &str) {
523        match update {
524            SessionUpdate::ChatAppended {
525                msg: forge_primitives::Message::ThinkingTokens { uuid, session_id, .. },
526                ..
527            } => (uuid, session_id),
528            other => panic!("a token frame names itself, and this is {other:?}"),
529        }
530    }
531
532    /// One frame, said shortly enough to compare a run of them.
533    fn describe(update: &SessionUpdate) -> String {
534        match update {
535            SessionUpdate::ChatAppended {
536                key,
537                msg:
538                    forge_primitives::Message::ThinkingTokens {
539                        estimated_tokens,
540                        estimated_tokens_delta,
541                        ..
542                    },
543                ..
544            } => format!(
545                "{}: {estimated_tokens} running, {estimated_tokens_delta} grown",
546                key.label(),
547            ),
548            SessionUpdate::ChatAppended { key, .. } => {
549                format!("{}: a frame that is not a counter", key.label())
550            }
551            SessionUpdate::TurnCancelled { key } => format!("{}: cancelled", key.label()),
552            other => format!("{other:?}"),
553        }
554    }
555
556    /// A run of token appends inside one batch leaves as ONE frame carrying
557    /// the sum of what they grew by - the whole point of the item, and what
558    /// makes the frames a client receives fewer than the frames the CLI sent.
559    #[tokio::test]
560    async fn a_run_of_token_appends_leaves_as_one_frame_carrying_their_sum() {
561        let seat = SessionSlot::lead("Org", "forge");
562        let mut sink = Recording::default();
563
564        // Two blocks' worth, so the last running value is not the total: the
565        // second block restarts at 30, where the deltas sum to 280. The last
566        // arrival carries an unmodelled fact, because the fold REBUILDS the
567        // frame it keeps: the counter is the only field it may change, so a
568        // fact riding the surviving arrival has to come through the rebuild.
569        let mut last = token(&seat, 30, 30, "c");
570        if let SessionUpdate::ChatAppended {
571            msg: forge_primitives::Message::ThinkingTokens { extras, .. },
572            ..
573        } = &mut last
574        {
575            extras.insert("carried".to_owned(), serde_json::json!(true));
576        }
577        flush(
578            &mut sink,
579            vec![
580                frame(token(&seat, 200, 200, "a")),
581                frame(token(&seat, 250, 50, "b")),
582                frame(last),
583            ],
584        )
585        .await
586        .expect("the batch writes");
587
588        assert_eq!(sink.flushes, 1, "the run is one flush, as any batch is");
589        assert_eq!(sink.fed.len(), 1, "and one frame where the CLI sent three");
590        // **The span is what keeps the run from reading as a hole.** A client
591        // told only the survivor's own number would ask for frames it had
592        // already drawn - and be handed rows it had already drawn.
593        let Message::Text(text) = &sink.fed[0] else {
594            panic!("the stream crosses as text, and this is {:?}", sink.fed[0])
595        };
596        let ServerMessage::Update { seq, span, .. } =
597            serde_json::from_str(text.as_str()).expect("a frame a client decodes")
598        else {
599            panic!("every frame a batch writes is an update");
600        };
601        assert_eq!(seq, Some(1), "the survivor keeps the run's oldest number");
602        assert_eq!(span, 3, "and carries how many of the seat's numbers it stands for");
603        assert_eq!(
604            describe(&sent(&sink)[0].update),
605            "lead: 30 running, 280 grown",
606            "carrying the last arrival's running value and the deltas summed",
607        );
608        assert_eq!(
609            naming(&sent(&sink)[0].update),
610            ("c", "s"),
611            "and the id of the arrival the run ended on, which is the one the frame stands for",
612        );
613        assert_eq!(
614            carried(&sent(&sink)[0].update),
615            Some(&serde_json::json!(true)),
616            "and a fact the surviving arrival carried must survive the fold's rebuild",
617        );
618    }
619
620    /// The merge's guard, both halves: an update of another kind between two
621    /// appends keeps them apart, and so does one for another seat.
622    ///
623    /// The first is what keeps everything else in order - nothing but a
624    /// counter may be moved or absorbed. The second is what keeps two seats'
625    /// estimates out of each other's rows.
626    #[tokio::test]
627    async fn only_consecutive_token_appends_for_one_seat_merge() {
628        let seat = SessionSlot::lead("Org", "forge");
629        let elsewhere = SessionSlot::lead("Org", "other");
630        let mut sink = Recording::default();
631
632        flush(
633            &mut sink,
634            vec![
635                frame(token(&seat, 200, 200, "a")),
636                frame(SessionUpdate::TurnCancelled { key: seat.clone() }),
637                frame(token(&seat, 250, 50, "b")),
638                frame(token(&seat, 30, 30, "c")),
639                frame(token(&elsewhere, 10, 10, "d")),
640            ],
641        )
642        .await
643        .expect("the batch writes");
644
645        let said: Vec<String> = sent(&sink).iter().map(|frame| describe(&frame.update)).collect();
646        assert_eq!(
647            said,
648            [
649                "lead: 200 running, 200 grown",
650                "lead: cancelled",
651                "lead: 30 running, 80 grown",
652                "lead: 10 running, 10 grown",
653            ],
654            "the run the cancelled frame interrupted, and another seat's counter, both stay whole",
655        );
656    }
657}