pgwire & tonic: sessions, portals, and protocols you don’t write
Two contrasts with RESP: a protocol with stateful sessions and streaming (postgres wire, via the pgwire Rust crate), and a protocol you don’t write at all (gRPC, via qdrant’s tonic setup). Together they bracket RESP’s design point — no handshake, no cursors, buffer-or-die — and fill in the design-space table M7 has to take a position on.
1. pgwire — ~/repos/pgwire
The crate structure IS the protocol lesson:
src/messages/— every frontend/backend message as a typed struct with encode/decode. Postgres framing: 1 type byte + i32 length + payload — like RESP’s type-first byte but with binary length (RESP: ASCII digits).src/api/query.rs— the two query protocols:SimpleQueryHandler— :48: oneQuerymessage in, a stream ofRowDescription+DataRow* +CommandCompleteout. RESP-like.ExtendedQueryHandler— :174: Parse → Bind → Execute → Sync, five round-trips of state. Prepared statements, parameter binding, binary result formats, and portals — a suspended query you pull N rows from. This is protocol-level backpressure and cursoring; RESP has neither (a module either buffers the whole reply or blocks the loop).
src/api/auth.rs—StartupHandler(see api/mod.rs:555): the connection is a state machine from byte 0 — startup params, auth exchange, then ready-for-query. RESP connections have no handshake at all (HELLO is optional) — count what that costs postgres in connection setup and what it buys (per-session GUCs, tx state, cancel keys).
Read it asking: where does session state live? — pgwire forces a
ClientInfo through every call; your RESP server keeps per-connection state
implicitly in the task. Both are answers to “protocol = state machine”.
The extended-query state machine, distilled:
#![allow(unused)]
fn main() {
// The protocol IS a session state machine; portals are protocol-level
// backpressure — a suspended query the client pulls N rows at a time.
match msg {
Parse { name, sql } => { self.stmts.insert(name, prepare(sql)?); }
Bind { portal, stmt, args } => { self.portals.insert(portal, cursor(stmt, args)?); }
Execute { portal, max_rows } => {
let cur = self.portals.get_mut(&portal)?;
for row in cur.take(max_rows) { send(DataRow(row))?; }
if cur.done() { send(CommandComplete)?; }
else { send(PortalSuspended)?; } // client decides when to pull more
}
Sync => { self.close_txn_if_failed(); send(ReadyForQuery)?; }
_ => { /* Describe, Close, Flush … */ }
}
}
2. qdrant — ~/repos/qdrant/src/tonic/
src/tonic/mod.rs:138and:277—Server::builder()twice: separate internal (peer-to-peer raft) and public gRPC servers. Protocol surface split by trust domain — compare redis exposing admin + data on one port.- The services are generated from
.proto(seeapi/crate: qdrant’s protos) — the parser, framing, streaming, and backpressure (HTTP/2 flow control windows) are inherited, not written. The cost: every message is protobuf — field tags, varints, no zero-copy into your value types; and HTTP/2 framing means you can’t debug withnc. - Note the middleware layers in mod.rs (auth :138 area, logging, telemetry) — tower’s onion model vs redis’s “check ACL inside processCommand”.
3. The design-space table (fill the last row yourself)
| RESP | pgwire | gRPC | |
|---|---|---|---|
| framing | ASCII len prefixes | type byte + i32 len | HTTP/2 frames |
| parse cost | memchr + atoi | fixed header read | protobuf decode |
| streaming | no (buffer all) | portals, row-at-a-time | HTTP/2 streams |
| backpressure | output-buffer kill | portal suspend | flow-control windows |
| debuggability | nc works | needs a tool | needs grpcurl |
| your GRAPH.QUERY | ? | ? | ? |
Questions to answer in notes.md
- FalkorDB result sets ride RESP arrays — huge ones buffer entirely in the module. What would a portal-style GRAPH.QUERY cursor look like as RESP commands? (FalkorDB actually has one — recall GRAPH.QUERY’s timeout + result-set config; design the missing GRAPH.CURSOR anyway.)
- Why does qdrant run TWO tonic servers instead of one with authz? What attack/ops story does the split simplify?
- Extended query’s 5 messages cost a round-trip each unless pipelined — how does pgwire’s async design let Parse/Bind/Execute/Sync coalesce, and what’s the RESP equivalent? (MULTI? No — pipelining itself.)
Done when
You can fill the table’s last row with committed answers for M7 and defend “RESP + explicit cursor commands” against “just use gRPC” for a graph DB.
References
Code
- sunng87/pgwire —
src/messages/,src/api/query.rs,src/api/auth.rs; the crate structure IS the protocol lesson. Local clone at~/repos/pgwire. - qdrant/qdrant —
src/tonic/mod.rs(two servers, tower middleware) plus the generated services in theapi/crate. Local clone at~/repos/qdrant.