Skip to content
shamimstack
★ Pinned backend #Rust #Async #Tokio #Performance

Rust async stream processing: walking a 4 GB event log without blocking the runtime

Rayhan Kabir @rayhan

Systems engineer · Rust · 1 week ago

↑ 186 👍 62 ❤️ 19 💡 28

We ingest a rolling 4 GB event log across hundreds of thousands of lines, and the first cut blocked a worker on every 8 MB page fetch. Here is the async stream version that keeps the runtime hot end to end — patch, review comments and all.

The streaming ingest, before and after

The current shape, then the reviewed patch. Lines carrying review comments expand — click one to read the discussion where it was written.

As it ships today

src/ingest.rs read-only preview
use futures::stream::{self, StreamExt};
use tokio::io::{BufReader, AsyncBufReadExt};

pub async fn ingest(path: &str) -> Result<(), Error> {
    let file = File::open(path).await?;
    let lines = BufReader::new(file).lines();

    // Cap in-flight pages; the decoder runs as its own task.
    let events = stream::iter(lines)
        .filter_map(|line| async move { line.ok() })
        .buffer_unordered(8);

    events.for_each(handle).await;

    Ok(())
}

A 4 GB rolling log, hundreds of thousands of lines, one worker parked on every 8 MB page fetch.

Reviewed patch

src/ingest.rs

Replace the blocking page loop with an ordered async stream. Concurrency is capped at 8 to keep backpressure on our side of the socket.

01 use tokio::fs::File;
02 use tokio::io::{BufReader, AsyncBufReadExt};
08 @@ -8,6 +8,11 @@ pub async fn ingest(path: &str) {
08 let mut cursor = 0; — has review comments, activate to read
RK

Rayhan Kabir review

This loop held the worker hostage: every `blocking_fetch` parked the thread for ~200ms while the socket ran cold between pages.

PM

Priya Menon 4h ago

That cold-socket gap is exactly the 140ms → 18ms shape we saw on the dashboard. Same disease, different runtime.

09 loop {
10 let bytes = blocking_fetch(cursor, PAGE_SIZE)?;
11 for line in bytes.lines() { handle(line)?; }
12 cursor += PAGE_SIZE;
13 }
08 let file = File::open(path).await?; — has review comments, activate to read
TA

Tanzil Ahmed review

Opening through tokio keeps the open() itself non-blocking — worth a module-doc note, because `std::fs::File` compiles fine here and quietly reintroduces the stall.

09 let lines = BufReader::new(file).lines();
10 let events = stream::iter(lines)
11 .filter_map(|line| async move { line.ok() })
12 .buffer_unordered(8); — has review comments, activate to read
RK

Rayhan Kabir review

Eight is not magic: it is core count minus headroom for the decoder task. Sweep 4 → 32 in the bench harness; throughput plateaus at 8 on our volume.

DO

Daniel Okafor 2h ago

Plateau-at-8 matches our queue worker findings. The cap also protects you when someone runs this on a 4-core CI box.

TA

Tanzil Ahmed 1h ago

Then the constant deserves a name and this comment — sending a patch.

13 events.for_each(handle).await;

The conversation

  • Priya Menon 5h ago

    The before/after trace is worth pinning on its own: worker busy-time went from 41% to 94% once the fetch pages stopped stalling the scheduler.

  • Kenji Sato 3h ago

    If you wrap this in join with the decoder stage, the two overlap end to end. Benchmarked 1.4× on a 2-core box.

  • Aisha Rahman 1h ago

    Bookmarking this as the review example for our onboarding doc: real patch, real line comments, real reasoning about backpressure.

Back to the feed
Enter your credentials to continue to your workspace.
Email address
Remember me
Don't have an account? Sign up
Enter your details below to create your account
Name
Email address
Password
Confirm password
Already have an account? Log in
Enter your email and we will send you a reset link
Email address
Or, return to log in