Lesson 31 of 44
Workshop 5: Streams and Protocols
Each project starts with a problem and requirements, then grows in two stages. Every stage is a complete program. The last stage is the full program, followed by a table of its parts and extension tasks.
The projects use only earlier lessons: Streams, Bytes and Framing, Codecs with tokio-util, mpsc Channels, oneshot, broadcast, and watch, Semaphore, Notify, Barrier, and OnceCell, TCP Clients and Servers, and Time: sleep, timeout, and interval.
The Rust blocks in this lesson are flagged rust,ignore. The example checker does not run them. Their output was derived by reading the code, and each program is written so that the output does not depend on scheduling. Servers bind 127.0.0.1:0, and clients run in the same program.
Project 21: Length-delimited chat protocol
A chat server and its clients exchange typed messages over TCP. TCP has no message boundaries, so each message is one length-prefixed frame. The frame body is a small binary format built with bytes.
Requirements:
- Three messages:
Join(name),Say(text), andChat { from, text }. - A frame body is a one-byte tag followed by strings, each a two-byte big-endian length and UTF-8 bytes.
- Decoding reports bad input as an
io::Errorand never panics. - The server broadcasts every chat message to every connected client, including the sender.
Stage 1: The message format
encode writes with BufMut methods. decode reads with Buf methods and checks remaining before every read, because Buf getters panic when the buffer is too short.
use bytes::{Buf, BufMut, Bytes, BytesMut};
use std::io;
#[derive(Debug, Clone, PartialEq)]
enum Msg {
Join(String),
Say(String),
Chat { from: String, text: String },
}
fn bad(reason: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, reason.to_string())
}
fn put_str(buf: &mut BytesMut, s: &str) {
buf.put_u16(s.len() as u16);
buf.put_slice(s.as_bytes());
}
fn get_str(buf: &mut Bytes) -> io::Result<String> {
if buf.remaining() < 2 {
return Err(bad("truncated string"));
}
let len = buf.get_u16() as usize;
if buf.remaining() < len {
return Err(bad("truncated string"));
}
String::from_utf8(buf.split_to(len).to_vec()).map_err(|_| bad("invalid utf-8"))
}
fn encode(msg: &Msg) -> Bytes {
let mut buf = BytesMut::new();
match msg {
Msg::Join(name) => { buf.put_u8(1); put_str(&mut buf, name); }
Msg::Say(text) => { buf.put_u8(2); put_str(&mut buf, text); }
Msg::Chat { from, text } => { buf.put_u8(3); put_str(&mut buf, from); put_str(&mut buf, text); }
}
buf.freeze()
}
fn decode(mut buf: Bytes) -> io::Result<Msg> {
if buf.remaining() < 1 {
return Err(bad("empty frame"));
}
match buf.get_u8() {
1 => Ok(Msg::Join(get_str(&mut buf)?)),
2 => Ok(Msg::Say(get_str(&mut buf)?)),
3 => Ok(Msg::Chat { from: get_str(&mut buf)?, text: get_str(&mut buf)? }),
tag => Err(bad(&format!("unknown tag {tag}"))),
}
}
fn main() {
let messages = [
Msg::Join("alice".into()),
Msg::Say("hi".into()),
Msg::Chat { from: "alice".into(), text: "hi".into() },
];
for msg in &messages {
let bytes = encode(msg);
println!("{msg:?} -> {} bytes -> {:?}", bytes.len(), decode(bytes.clone()).unwrap());
}
println!("bad tag: {}", decode(Bytes::from_static(&[9])).unwrap_err());
println!("truncated: {}", decode(Bytes::from_static(&[1, 0, 5, b'a'])).unwrap_err());
}Join("alice") -> 8 bytes -> Join("alice")
Say("hi") -> 5 bytes -> Say("hi")
Chat { from: "alice", text: "hi" } -> 12 bytes -> Chat { from: "alice", text: "hi" }
bad tag: unknown tag 9
truncated: truncated stringJoin("alice") is 1 tag byte, 2 length bytes, and 5 name bytes. The last line declares five bytes but supplies one.
Stage 2: Framed connections and a broadcast room
Framed<TcpStream, LengthDelimitedCodec> turns the socket into a Stream of frames and a Sink of frames, each with a four-byte length prefix. The codec yields BytesMut, so freeze converts a frame to Bytes for decode. Each server connection subscribes to a broadcast channel and uses select! to handle a frame from its client or a message from the room.
The client side runs in main and drives the steps one at a time. Each step waits for the reply it expects before the next starts, which fixes the order. This is the full program.
use bytes::{Buf, BufMut, Bytes, BytesMut};
use futures::{SinkExt, StreamExt};
use std::io;
use std::net::SocketAddr;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::broadcast;
use tokio_util::codec::{Framed, LengthDelimitedCodec};
#[derive(Debug, Clone, PartialEq)]
enum Msg {
Join(String),
Say(String),
Chat { from: String, text: String },
}
fn bad(reason: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, reason.to_string())
}
fn put_str(buf: &mut BytesMut, s: &str) {
buf.put_u16(s.len() as u16);
buf.put_slice(s.as_bytes());
}
fn get_str(buf: &mut Bytes) -> io::Result<String> {
if buf.remaining() < 2 {
return Err(bad("truncated string"));
}
let len = buf.get_u16() as usize;
if buf.remaining() < len {
return Err(bad("truncated string"));
}
String::from_utf8(buf.split_to(len).to_vec()).map_err(|_| bad("invalid utf-8"))
}
fn encode(msg: &Msg) -> Bytes {
let mut buf = BytesMut::new();
match msg {
Msg::Join(name) => { buf.put_u8(1); put_str(&mut buf, name); }
Msg::Say(text) => { buf.put_u8(2); put_str(&mut buf, text); }
Msg::Chat { from, text } => { buf.put_u8(3); put_str(&mut buf, from); put_str(&mut buf, text); }
}
buf.freeze()
}
fn decode(mut buf: Bytes) -> io::Result<Msg> {
if buf.remaining() < 1 {
return Err(bad("empty frame"));
}
match buf.get_u8() {
1 => Ok(Msg::Join(get_str(&mut buf)?)),
2 => Ok(Msg::Say(get_str(&mut buf)?)),
3 => Ok(Msg::Chat { from: get_str(&mut buf)?, text: get_str(&mut buf)? }),
tag => Err(bad(&format!("unknown tag {tag}"))),
}
}
async fn serve_connection(stream: TcpStream, room: broadcast::Sender<Msg>) {
let mut framed = Framed::new(stream, LengthDelimitedCodec::new());
let mut updates = room.subscribe();
let mut name = String::new();
loop {
tokio::select! {
frame = framed.next() => {
let Some(Ok(frame)) = frame else { break };
match decode(frame.freeze()) {
Ok(Msg::Join(who)) => {
let text = format!("{who} joined");
name = who;
let _ = room.send(Msg::Chat { from: "server".into(), text });
}
Ok(Msg::Say(text)) => {
let _ = room.send(Msg::Chat { from: name.clone(), text });
}
_ => break,
}
}
Ok(msg) = updates.recv() => {
if framed.send(encode(&msg)).await.is_err() {
break;
}
}
}
}
}
async fn serve(listener: TcpListener) {
let (room, _) = broadcast::channel(32);
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(serve_connection(stream, room.clone()));
}
}
struct Client { framed: Framed<TcpStream, LengthDelimitedCodec> }
impl Client {
async fn connect(addr: SocketAddr) -> Client {
let stream = TcpStream::connect(addr).await.unwrap();
Client { framed: Framed::new(stream, LengthDelimitedCodec::new()) }
}
async fn send(&mut self, msg: Msg) {
self.framed.send(encode(&msg)).await.unwrap();
}
async fn recv(&mut self) -> Msg {
decode(self.framed.next().await.unwrap().unwrap().freeze()).unwrap()
}
}
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(serve(listener));
let mut alice = Client::connect(addr).await;
alice.send(Msg::Join("alice".into())).await;
println!("alice got: {:?}", alice.recv().await);
let mut bob = Client::connect(addr).await;
bob.send(Msg::Join("bob".into())).await;
println!("bob got: {:?}", bob.recv().await);
println!("alice got: {:?}", alice.recv().await);
alice.send(Msg::Say("hi".into())).await;
println!("alice got: {:?}", alice.recv().await);
println!("bob got: {:?}", bob.recv().await);
bob.send(Msg::Say("hello".into())).await;
println!("alice got: {:?}", alice.recv().await);
println!("bob got: {:?}", bob.recv().await);
}alice got: Chat { from: "server", text: "alice joined" }
bob got: Chat { from: "server", text: "bob joined" }
alice got: Chat { from: "server", text: "bob joined" }
alice got: Chat { from: "alice", text: "hi" }
bob got: Chat { from: "alice", text: "hi" }
alice got: Chat { from: "bob", text: "hello" }
bob got: Chat { from: "bob", text: "hello" }Each connection subscribes before it reads its first frame. So when a client has received its own join notice, the room already delivers to it, and later messages cannot be missed. The codec removes all length handling from the program: partial frames and several frames in one read are its job.
| Part | Role |
|---|---|
encode / decode |
binary message format with checked reads |
LengthDelimitedCodec |
four-byte length prefix, default 8 MiB maximum frame |
Framed |
one connection as Stream plus Sink |
broadcast channel |
delivers each chat message to every connection |
select! in the handler |
serves the client's frames and the room's messages at once |
Extension tasks:
- Add a
Leave(name)message and broadcast it when a connection ends. - Set
max_frame_lengthto 1024 on the codec and show that an oversized frame ends the connection. - Add a
Whisper { to, text }message. Keep a map from name tompsc::Sender<Msg>in place of the broadcast channel.
Project 22: Streaming ETL pipeline
A pipeline reads raw text records, drops the malformed ones, enriches each record with a slow lookup, groups records into batches, and loads each batch with a slow writer. Each stage pulls from the one before it, so a slow writer slows the whole pipeline.
Requirements:
- The source produces 12 lines of the form
id,amount. Lines with ids 5 and 10 are malformed. - Up to three enrichments run at once, and results keep their input order.
- Records are grouped into batches of at most 4, or after 500 ms of waiting.
- The loader takes 200 ms per batch. A channel of size 1 connects it to the pipeline.
- The program reports how many lines were bad and how many records were loaded.
Stage 1: Parse and filter
map parses each line into a Result, and collect gathers all results. The two malformed lines become Err values that the program counts.
use futures::{StreamExt, stream};
#[derive(Debug)]
struct Rec { id: u32, amount: u32 }
fn parse(line: &str) -> Result<Rec, String> {
let parts: Vec<&str> = line.split(',').collect();
match parts.as_slice() {
[id, amount] => Ok(Rec {
id: id.parse().map_err(|_| format!("bad id in {line:?}"))?,
amount: amount.parse().map_err(|_| format!("bad amount in {line:?}"))?,
}),
_ => Err(format!("wrong field count in {line:?}")),
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let lines = ["1,10", "2,x", "3,25", "4,", "5,40", "7,abc,9", "8,30"];
let parsed: Vec<Result<Rec, String>> = stream::iter(lines).map(parse).collect().await;
for item in &parsed {
match item {
Ok(rec) => println!("ok: {rec:?}"),
Err(e) => println!("skipped: {e}"),
}
}
}ok: Rec { id: 1, amount: 10 }
skipped: bad amount in "2,x"
ok: Rec { id: 3, amount: 25 }
skipped: bad amount in "4,"
ok: Rec { id: 5, amount: 40 }
skipped: wrong field count in "7,abc,9"
ok: Rec { id: 8, amount: 30 }Stage 2: Enrich, batch, and load with backpressure
The pipeline is one chain of stream adapters:
filter_mapdrops bad lines and counts them.mapfollowed bybuffered(3)runs up to three enrichments at once and yields results in input order.buffer_unorderedwould yield them as they finish.chunks_timeout(4, 500 ms)comes fromtokio_stream. Bothfutures::StreamExtandtokio_stream::StreamExtdefine methods namednextandmap, so the program imports thefuturestrait and callschunks_timeoutby its full path.- A
forloop sends each batch into a channel of capacity 1.sendwaits while the channel is full, so the slow loader stops the loop from pulling more records. Streams are pull-based, so the source runs only as fast as this loop asks.
The enrichment latency depends on the id, so the work finishes out of order internally, and buffered restores the order. The batches are [1,2,3,4], [6,7,8,9], and [11,12]. The last one is flushed when the stream ends.
use futures::{StreamExt, stream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::sleep;
#[derive(Debug)]
struct Rec { id: u32, amount: u32 }
#[derive(Debug)]
struct Enriched { id: u32, total: u32 }
fn parse(line: &str) -> Result<Rec, String> {
let (id, amount) = line.split_once(',').ok_or("no comma")?;
Ok(Rec {
id: id.parse().map_err(|_| "bad id".to_string())?,
amount: amount.parse().map_err(|_| "bad amount".to_string())?,
})
}
async fn enrich(rec: Rec) -> Enriched {
sleep(Duration::from_millis((rec.id as u64 % 3 + 1) * 20)).await;
Enriched { id: rec.id, total: rec.amount + rec.amount / 10 }
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let bad = Arc::new(AtomicUsize::new(0));
let bad_in_stream = bad.clone();
let enriched = stream::iter(1..=12u32)
.map(|id| if id % 5 == 0 { format!("{id},oops") } else { format!("{id},{}", id * 10) })
.filter_map(move |line| {
let bad = bad_in_stream.clone();
async move {
match parse(&line) {
Ok(rec) => Some(rec),
Err(_) => { bad.fetch_add(1, SeqCst); None }
}
}
})
.map(enrich)
.buffered(3);
let batches = tokio_stream::StreamExt::chunks_timeout(enriched, 4, Duration::from_millis(500));
tokio::pin!(batches);
let (tx, mut rx) = mpsc::channel::<Vec<Enriched>>(1);
let loader = tokio::spawn(async move {
let (mut count, mut rows) = (0, 0);
while let Some(batch) = rx.recv().await {
sleep(Duration::from_millis(200)).await;
count += 1;
rows += batch.len();
let ids: Vec<u32> = batch.iter().map(|r| r.id).collect();
let sum: u32 = batch.iter().map(|r| r.total).sum();
println!("batch {count}: ids={ids:?} sum={sum}");
}
(count, rows)
});
while let Some(batch) = batches.next().await {
tx.send(batch).await.unwrap();
}
drop(tx);
let (count, rows) = loader.await.unwrap();
println!("bad lines: {}", bad.load(SeqCst));
println!("loaded {rows} records in {count} batches");
}batch 1: ids=[1, 2, 3, 4] sum=110
batch 2: ids=[6, 7, 8, 9] sum=330
batch 3: ids=[11, 12] sum=253
bad lines: 2
loaded 10 records in 3 batchesThe sum of total is amount + amount / 10 for each record. For batch 1 that is 11 + 22 + 33 + 44 = 110. Dropping tx closes the channel, which ends the loader loop after the last batch.
| Part | Role |
|---|---|
stream::iter |
source of 12 ids |
filter_map |
drops malformed lines and counts them |
buffered(3) |
three concurrent enrichments, results in input order |
chunks_timeout(4, 500 ms) |
groups records into batches of at most 4 |
mpsc::channel(1) |
bounded hand-off to the loader, so a slow loader slows the loop |
| loader task | writes one batch at a time |
Extension tasks:
- Replace
buffered(3)withbuffer_unordered(3)and sort each batch by id before loading. - Send the bad lines to a second bounded channel that a dead-letter task drains.
- Lower the timeout to 30 ms and add a
sleepto the source. Show that partial batches appear.
Project 23: Mini-Redis with TTL expiry and pub/sub
A small in-memory server speaks a line-based text protocol. It stores keys with optional expiry times, removes expired keys in the background, and lets clients subscribe to channels.
Requirements:
- Commands:
SET key value [PX millis],GET key,DEL key,DBSIZE,SUBSCRIBE channel,PUBLISH channel message. Values have no spaces. - Replies:
+OK,$value,$nil,:number,-ERR reason, and!channel messagefor pushed messages. - A custom codec implements
Decoderfor commands andEncoderfor replies. - An expiry task removes keys at their deadline. It sleeps until the earliest deadline and wakes early through a
Notifywhen a new earlier deadline is set. GETnever returns an expired value, even if the expiry task has not run yet.
Stage 1: The codec and a key-value store
The codec reads up to the first \n, splits that line off the buffer, and parses it. If there is no newline yet, decode returns Ok(None) and the framing layer reads more bytes. A malformed command becomes Cmd::Bad, so the connection stays open and the client gets an error reply. The client side uses LinesCodec.
use bytes::BytesMut;
use futures::{SinkExt, StreamExt};
use std::collections::HashMap;
use std::io;
use std::sync::{Arc, Mutex};
use tokio::net::{TcpListener, TcpStream};
use tokio_util::codec::{Decoder, Encoder, Framed, LinesCodec};
enum Cmd { Set(String, String), Get(String), Del(String), Bad(String) }
enum Resp { Ok, Value(String), Nil, Int(i64), Err(String) }
struct Codec;
fn parse(line: &str) -> Cmd {
let parts: Vec<&str> = line.split_whitespace().collect();
match parts.as_slice() {
["SET", k, v] => Cmd::Set(k.to_string(), v.to_string()),
["GET", k] => Cmd::Get(k.to_string()),
["DEL", k] => Cmd::Del(k.to_string()),
_ => Cmd::Bad(format!("unknown command '{line}'")),
}
}
impl Decoder for Codec {
type Item = Cmd;
type Error = io::Error;
fn decode(&mut self, src: &mut BytesMut) -> io::Result<Option<Cmd>> {
let Some(end) = src.iter().position(|b| *b == b'\n') else {
return Ok(None);
};
let line = src.split_to(end + 1);
let text = std::str::from_utf8(&line[..end])
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid utf-8"))?;
Ok(Some(parse(text.trim_end_matches('\r'))))
}
}
impl Encoder<Resp> for Codec {
type Error = io::Error;
fn encode(&mut self, resp: Resp, dst: &mut BytesMut) -> io::Result<()> {
let text = match resp {
Resp::Ok => "+OK".to_string(),
Resp::Value(v) => format!("${v}"),
Resp::Nil => "$nil".to_string(),
Resp::Int(n) => format!(":{n}"),
Resp::Err(e) => format!("-ERR {e}"),
};
dst.extend_from_slice(text.as_bytes());
dst.extend_from_slice(b"\n");
Ok(())
}
}
type Db = Arc<Mutex<HashMap<String, String>>>;
async fn conn(stream: TcpStream, db: Db) {
let mut framed = Framed::new(stream, Codec);
while let Some(Ok(cmd)) = framed.next().await {
let resp = match cmd {
Cmd::Set(k, v) => { db.lock().unwrap().insert(k, v); Resp::Ok }
Cmd::Get(k) => db.lock().unwrap().get(&k).cloned().map_or(Resp::Nil, Resp::Value),
Cmd::Del(k) => Resp::Int(db.lock().unwrap().remove(&k).is_some() as i64),
Cmd::Bad(reason) => Resp::Err(reason),
};
if framed.send(resp).await.is_err() {
break;
}
}
}
async fn ask(client: &mut Framed<TcpStream, LinesCodec>, line: &str) -> String {
client.send(line).await.unwrap();
client.next().await.unwrap().unwrap()
}
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let db: Db = Arc::default();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(conn(stream, db.clone()));
}
});
let stream = TcpStream::connect(addr).await.unwrap();
let mut client = Framed::new(stream, LinesCodec::new());
for line in ["SET k v", "GET k", "GET missing", "DEL k", "DEL k", "BAD"] {
println!("{line} -> {}", ask(&mut client, line).await);
}
}SET k v -> +OK
GET k -> $v
GET missing -> $nil
DEL k -> :1
DEL k -> :0
BAD -> -ERR unknown command 'BAD'The custom codec is the only place that knows the wire format. conn works with Cmd and Resp values.
Stage 2: TTL, background expiry, and pub/sub
The database now keeps two structures under one lock: a map from key to (value, deadline), and a BTreeSet<(Instant, key)> of deadlines ordered by time, so the earliest deadline is the first element.
setreplaces any old deadline for the key, then callsnotify_oneon aNotify.Notifystores one permit when nobody is waiting, so a wake sent while the expiry task is busy is not lost.- The expiry task calls
purge, which removes every expired key and returns the next deadline. It then waits for eithersleep_until(deadline)ornotified(). With no deadline it waits fornotified()alone. getcompares the deadline with the current time, so an expired key is never returned even before the task runs.SUBSCRIBEcreates abroadcast::Receiverfor the channel and spawns a small task that forwards each message into a per-connectionmpscchannel. The connection loop usesselect!on its frames and on that channel, then writes!channel messagereplies.PUBLISHreplies with the number of receivers that got the message.
The program runs under the paused clock. sleep in main moves time forward, and the expiry task runs at each deadline on the way. This is the full program.
use bytes::BytesMut;
use futures::{SinkExt, StreamExt};
use std::collections::{BTreeSet, HashMap};
use std::io;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{Notify, broadcast, mpsc};
use tokio::time::{Instant, sleep, sleep_until};
use tokio_util::codec::{Decoder, Encoder, Framed, LinesCodec};
enum Cmd {
Set(String, String, Option<Duration>),
Get(String),
Del(String),
Size,
Subscribe(String),
Publish(String, String),
Bad(String),
}
enum Resp { Ok, Value(String), Nil, Int(i64), Push(String, String), Err(String) }
struct Codec;
fn parse(line: &str) -> Cmd {
let parts: Vec<&str> = line.split_whitespace().collect();
match parts.as_slice() {
["SET", k, v] => Cmd::Set(k.to_string(), v.to_string(), None),
["SET", k, v, "PX", ms] => match ms.parse() {
Ok(ms) => Cmd::Set(k.to_string(), v.to_string(), Some(Duration::from_millis(ms))),
Err(_) => Cmd::Bad("bad PX value".to_string()),
},
["GET", k] => Cmd::Get(k.to_string()),
["DEL", k] => Cmd::Del(k.to_string()),
["DBSIZE"] => Cmd::Size,
["SUBSCRIBE", c] => Cmd::Subscribe(c.to_string()),
["PUBLISH", c, m] => Cmd::Publish(c.to_string(), m.to_string()),
_ => Cmd::Bad(format!("unknown command '{line}'")),
}
}
impl Decoder for Codec {
type Item = Cmd;
type Error = io::Error;
fn decode(&mut self, src: &mut BytesMut) -> io::Result<Option<Cmd>> {
let Some(end) = src.iter().position(|b| *b == b'\n') else {
return Ok(None);
};
let line = src.split_to(end + 1);
let text = std::str::from_utf8(&line[..end])
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid utf-8"))?;
Ok(Some(parse(text.trim_end_matches('\r'))))
}
}
impl Encoder<Resp> for Codec {
type Error = io::Error;
fn encode(&mut self, resp: Resp, dst: &mut BytesMut) -> io::Result<()> {
let text = match resp {
Resp::Ok => "+OK".to_string(),
Resp::Value(v) => format!("${v}"),
Resp::Nil => "$nil".to_string(),
Resp::Int(n) => format!(":{n}"),
Resp::Push(channel, message) => format!("!{channel} {message}"),
Resp::Err(e) => format!("-ERR {e}"),
};
dst.extend_from_slice(text.as_bytes());
dst.extend_from_slice(b"\n");
Ok(())
}
}
#[derive(Default)]
struct State {
data: HashMap<String, (String, Option<Instant>)>,
deadlines: BTreeSet<(Instant, String)>,
}
#[derive(Default)]
struct Db {
state: Mutex<State>,
wake: Notify,
channels: Mutex<HashMap<String, broadcast::Sender<String>>>,
}
impl Db {
fn set(&self, key: String, value: String, ttl: Option<Duration>) {
let deadline = ttl.map(|t| Instant::now() + t);
let mut st = self.state.lock().unwrap();
if let Some((_, Some(old))) = st.data.insert(key.clone(), (value, deadline)) {
st.deadlines.remove(&(old, key.clone()));
}
if let Some(d) = deadline {
st.deadlines.insert((d, key));
}
drop(st);
self.wake.notify_one();
}
fn get(&self, key: &str) -> Option<String> {
match self.state.lock().unwrap().data.get(key) {
Some((_, Some(deadline))) if *deadline <= Instant::now() => None,
Some((value, _)) => Some(value.clone()),
None => None,
}
}
fn del(&self, key: &str) -> bool {
let mut st = self.state.lock().unwrap();
match st.data.remove(key) {
Some((_, Some(d))) => { st.deadlines.remove(&(d, key.to_string())); true }
Some(_) => true,
None => false,
}
}
fn size(&self) -> usize {
self.state.lock().unwrap().data.len()
}
fn purge(&self) -> Option<Instant> {
let mut st = self.state.lock().unwrap();
let now = Instant::now();
while let Some((deadline, key)) = st.deadlines.first().cloned() {
if deadline > now {
return Some(deadline);
}
st.deadlines.pop_first();
st.data.remove(&key);
}
None
}
fn channel(&self, name: &str) -> broadcast::Sender<String> {
let mut channels = self.channels.lock().unwrap();
channels.entry(name.to_string()).or_insert_with(|| broadcast::channel(16).0).clone()
}
}
async fn expirer(db: Arc<Db>) {
loop {
match db.purge() {
Some(when) => {
tokio::select! {
_ = sleep_until(when) => {}
_ = db.wake.notified() => {}
}
}
None => db.wake.notified().await,
}
}
}
async fn conn(stream: TcpStream, db: Arc<Db>) {
let mut framed = Framed::new(stream, Codec);
let (push_tx, mut push_rx) = mpsc::channel::<Resp>(16);
loop {
tokio::select! {
frame = framed.next() => {
let Some(Ok(cmd)) = frame else { break };
let resp = match cmd {
Cmd::Set(k, v, ttl) => { db.set(k, v, ttl); Resp::Ok }
Cmd::Get(k) => db.get(&k).map_or(Resp::Nil, Resp::Value),
Cmd::Del(k) => Resp::Int(db.del(&k) as i64),
Cmd::Size => Resp::Int(db.size() as i64),
Cmd::Subscribe(channel) => {
let mut rx = db.channel(&channel).subscribe();
let out = push_tx.clone();
tokio::spawn(async move {
while let Ok(message) = rx.recv().await {
if out.send(Resp::Push(channel.clone(), message)).await.is_err() {
break;
}
}
});
Resp::Ok
}
Cmd::Publish(channel, message) => {
Resp::Int(db.channel(&channel).send(message).unwrap_or(0) as i64)
}
Cmd::Bad(reason) => Resp::Err(reason),
};
if framed.send(resp).await.is_err() {
break;
}
}
Some(push) = push_rx.recv() => {
if framed.send(push).await.is_err() {
break;
}
}
}
}
}
async fn ask(client: &mut Framed<TcpStream, LinesCodec>, line: &str) -> String {
client.send(line).await.unwrap();
client.next().await.unwrap().unwrap()
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let db = Arc::new(Db::default());
tokio::spawn(expirer(db.clone()));
tokio::spawn({
let db = db.clone();
async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(conn(stream, db.clone()));
}
}
});
let mut a = Framed::new(TcpStream::connect(addr).await.unwrap(), LinesCodec::new());
let mut b = Framed::new(TcpStream::connect(addr).await.unwrap(), LinesCodec::new());
for line in ["SET a 1 PX 100", "SET b 2 PX 300", "SET c 3", "GET a", "DBSIZE"] {
println!("{line} -> {}", ask(&mut a, line).await);
}
sleep(Duration::from_millis(150)).await;
for line in ["GET a", "DBSIZE"] {
println!("after 150 ms: {line} -> {}", ask(&mut a, line).await);
}
sleep(Duration::from_millis(200)).await;
for line in ["GET b", "DBSIZE"] {
println!("after 350 ms: {line} -> {}", ask(&mut a, line).await);
}
println!("BAD -> {}", ask(&mut a, "BAD").await);
println!("SUBSCRIBE news -> {}", ask(&mut b, "SUBSCRIBE news").await);
println!("PUBLISH news hello -> {}", ask(&mut a, "PUBLISH news hello").await);
println!("subscriber got: {}", b.next().await.unwrap().unwrap());
println!("PUBLISH other x -> {}", ask(&mut a, "PUBLISH other x").await);
}SET a 1 PX 100 -> +OK
SET b 2 PX 300 -> +OK
SET c 3 -> +OK
GET a -> $1
DBSIZE -> :3
after 150 ms: GET a -> $nil
after 150 ms: DBSIZE -> :2
after 350 ms: GET b -> $nil
after 350 ms: DBSIZE -> :1
BAD -> -ERR unknown command 'BAD'
SUBSCRIBE news -> +OK
PUBLISH news hello -> :1
subscriber got: !news hello
PUBLISH other x -> :0DBSIZE falls from 3 to 2 to 1 because the expiry task removes keys at their deadlines. Lazy expiry in get alone would leave the entries in the map. The first DBSIZE after 150 ms would still be 3. PUBLISH other x reaches no subscriber, so it replies :0.
| Part | Role |
|---|---|
Codec |
Decoder for commands and Encoder for replies; the only code that knows the wire format |
Db.state |
key-value map plus a BTreeSet of deadlines ordered by time |
expirer with Notify |
sleeps to the earliest deadline, wakes early when set adds one |
lazy check in get |
correct reads between a deadline and the next purge |
broadcast per channel |
fan-out to subscribers; send returns the receiver count |
forwarder task and mpsc |
moves broadcast messages into the connection's own loop |
Extension tasks:
- Add
UNSUBSCRIBE channelby keeping the forwarder'sJoinHandleper channel and aborting it. - Add a maximum line length to the decoder and reply
-ERR line too longinstead of buffering without limit. - Add
TTL key, which replies with the remaining milliseconds or-1when the key has no deadline.
Interview points
- TCP delivers a byte stream. A protocol needs framing, such as a length prefix or a delimiter.
Bufgetters panic on short input. Decoders checkremainingfirst and return an error.Framedcombines aStreamof decoded items and aSinkof encoded items on one connection. A decode error ends the stream.buffered(n)keeps input order andbuffer_unordered(n)does not. Both run at mostnfutures at once.chunks_timeout(n, d)emits a batch when it hasnitems, whendhas passed since the batch started, or when the stream ends.- Streams are pull-based. A bounded channel between stages makes a slow stage slow the earlier ones.
Notify::notify_onestores a permit when no task waits, so a wake that arrives beforenotified()is not lost.- A TTL store needs both lazy expiry, for correct reads, and a background purge, for memory.
- A
BTreeSetordered by deadline gives the next expiry in one lookup. - Returning a malformed-command value from a decoder keeps the connection open. Returning an error ends it.
Summary
- Project 21 encodes typed messages with
bytes, frames them withLengthDelimitedCodec, and fans them out withbroadcast. - Project 22 chains
filter_map,buffered, andchunks_timeout, and connects the last stage through a bounded channel. - Project 23 implements
DecoderandEncoderfor a text protocol, expires keys with aNotify-driven task, and forwardsbroadcastmessages to subscribers.
