pgwire & tonic: sessions, portals, and protocols you don’t write
Two contrasts with RESP: a protocol with stateful sessions and streaming
(the postgres wire protocol, read through the pgwire Rust crate), and a
protocol you don’t write at all (gRPC, read through 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.
This chapter builds the concepts step by step — framing, state machine, the two
query modes, portals, sessions, generated protocols — and checks each against the
pinned source, which corrects several claims an earlier version of this chapter
made from memory.
Which versions this chapter is about. Both trees are pinned, and the
anchors below were re-checked against those pins, not against whatever is on
main today:
tools/pinned-source.py ref pgwire # sunng87/pgwire@6bb6299
tools/pinned-source.py ref qdrant # qdrant/qdrant@44ad62f
tools/pinned-source.py show pgwire src/api/query.rs -r 599:637
tools/pinned-source.py check qdrant lib/api/src/grpc/qdrant.rs:1 --contains 'generated by prost-build'
Two path corrections fall out immediately, because the earlier version of this
chapter cited files that do not exist at these pins: pgwire’s startup handler is
in src/api/auth/mod.rs, not src/api/auth.rs (auth is a directory of
seven files), and qdrant’s generated protocol crate is lib/api/, not
api/. There are also no local clones at ~/repos/pgwire or ~/repos/qdrant;
tools/pinned-source.py fetches from the pin.
The problem in one sentence
A query returning 10M rows must not require the server to hold 10M rows in a
reply buffer — RESP’s only answer — and postgres’s protocol says so structurally:
Execute carries a max_rows count, the server replies PortalSuspended and
stops producing (pgwire src/api/query.rs:351-354), and the client decides
whether to ask for more.
The concepts, step by step
Step 1 — a wire protocol is a framing rule plus a state machine
In: RESP, in which any command is legal at any time and every reply is complete. Out: the two questions every wire protocol answers, and postgres’s answer to the second one read out of pgwire’s connection-state enum.
Framing is how the receiver knows where one message ends and the next begins. A state machine is which messages are legal now. RESP answers the first with in-band ASCII lengths and the second with “all of them, always” — a RESP connection has no state you can name.
Postgres does have states, and pgwire spells them out:
#![allow(unused)]
fn main() {
// pgwire src/api/mod.rs — the connection lifecycle, 46-64
46 /// States of a PostgreSQL connection lifecycle.
47 #[derive(Debug, Clone, Copy, Default)]
48 pub enum PgWireConnectionState {
49 /// Waiting for an SSL request from the client.
50 #[default]
51 AwaitingSslRequest,
52 /// Waiting for a startup message from the client.
53 AwaitingStartup,
54 /// Authentication handshake in progress.
55 AuthenticationInProgress,
56 /// Connection is idle and ready for queries.
57 ReadyForQuery,
58 /// A query is currently being executed.
59 QueryInProgress,
60 /// A COPY operation is in progress.
61 CopyInProgress(bool),
62 /// Waiting for a Sync message from the client.
63 AwaitingSync,
64 }
}
Seven states, and the interesting one is the last. AwaitingSync is error
recovery as a protocol state, and pgwire’s dispatcher quotes the postgres manual
for why it exists:
#![allow(unused)]
fn main() {
// pgwire src/tokio/server.rs — the dispatch on connection state, 198-214
198 match socket.state() {
199 PgWireConnectionState::AwaitingStartup
200 | PgWireConnectionState::AuthenticationInProgress => {
201 authenticator.on_startup(socket, message).await?;
202 }
203 // From Postgres docs:
204 // When an error is detected while processing any extended-query
205 // message, the backend issues ErrorResponse, then reads and discards
206 // messages until a Sync is reached, then issues ReadyForQuery and
207 // returns to normal message processing.
208 PgWireConnectionState::AwaitingSync => {
209 if let PgWireFrontendMessage::Sync(sync) = message {
210 extended_query_handler.on_sync(socket, sync).await?;
211 // TODO: confirm if we need to track transaction state there
212 socket.set_state(PgWireConnectionState::ReadyForQuery);
213 }
214 }
}
Hold on to lines 203–207, because they turn up again in Step 4: discard until Sync is precisely what makes it safe to send a batch of extended-query messages without waiting for each reply. RESP has no equivalent rule, which is why a pipelined RESP batch keeps executing after one of its commands fails.
Where does session state live? pgwire threads a ClientInfo through every
handler call, and src/api/mod.rs:66-70 adds a “per-connection typed extension
store, keyed by TypeId” for handlers to hang their own state on. Your RESP
server keeps the equivalent implicitly, in the local variables of the tokio task
serving the connection. Both are answers to “protocol = state machine”; only one
of them is written down.
Step 2 — postgres framing: one type byte + a binary length, and resumable
In: the need for framing from Step 1. Out: postgres’s 5-byte header, exactly what its length field counts, and the partial-message behaviour — worked on real byte counts against RESP.
Every postgres message after the startup packet is 1 ASCII type byte, a 4-byte
big-endian i32 length, then the body. pgwire’s Message trait writes it in one
place:
#![allow(unused)]
fn main() {
// pgwire src/messages/mod.rs — Message::encode, 121-136
121 fn encode(&self, buf: &mut BytesMut) -> PgWireResult<()> {
122 if let Some(mt) = Self::message_type() {
123 buf.put_u8(mt);
124 }
125
126 let len = self.message_length();
127 if len > Self::max_message_length() {
128 return Err(PgWireError::MessageTooLarge(
129 Self::max_message_length(),
130 len,
131 ));
132 }
133
134 buf.put_i32(len as i32);
135 self.encode_body(buf)
136 }
}
And reads it in one place, where the exact meaning of the length field is visible:
#![allow(unused)]
fn main() {
// pgwire src/messages/codec.rs — decode_packet, 63-84 (signature elided)
63 pub(crate) fn decode_packet<T, F>(
// ... 64-71: buf, offset, max_size, decode_fn and their bounds ...
72 if let Some(msg_len) = get_length(buf, offset) {
73 if msg_len > max_size {
74 return Err(PgWireError::MessageTooLarge(max_size, msg_len));
75 }
76
77 if buf.remaining() >= msg_len + offset {
78 buf.advance(offset + 4);
79 return decode_fn(buf, msg_len).map(|r| Some(r));
80 }
81 }
82
83 Ok(None)
84 }
}
offset is 1 when the message has a type byte. Line 77 needs msg_len + offset
bytes and line 78 skips offset + 4, so the length counts itself and the body
but not the type byte. Line 83 is the other half: if the whole message has not
arrived, return Ok(None) and leave the buffer untouched — the decoder is
resumable, and get_length (codec.rs:53-59) deliberately reads the length
without advancing the cursor.
Now the arithmetic, because “fixed header, no scanning” is usually oversold. A
simple Query for SELECT 1 — message_length is 5 + query.len()
(simplequery.rs:22-24), the 5 being 4 length bytes plus the trailing NUL:
'Q' type byte, not counted in the length 1 byte
00 00 00 0D length = 5 + 8 = 13 4 bytes
"SELECT 1" 00 cstring body 9 bytes
on the wire = 14 bytes
A three-column result row ("alice", "30", "NYC") as a DataRow —
message_length is 4 + 2 + data.len() (data.rs:174-176), and each field
inside data is a 4-byte length followed by the bytes (api/results.rs:319
writes -1 as a placeholder, :328-330 back-patches the real length):
'D' 1 byte
00 00 00 1C length = 4 + 2 + 22 = 28 4 bytes
00 03 field count 2 bytes
00 00 00 05 "alice" 9 bytes
00 00 00 02 "30" 6 bytes
00 00 00 03 "NYC" 7 bytes
on the wire = 29 bytes
the same row in RESP2:
*3\r\n $5\r\nalice\r\n $2\r\n30\r\n $3\r\nNYC\r\n
4 + 11 + 8 + 9 = 32 bytes
29 against 32 — under 10%. The binary framing is not where postgres wins;
what it buys is that the reader never scans for a delimiter and never parses
ASCII digits, and that a NULL is a 4-byte -1 inside the row rather than RESP2’s
separate $-1\r\n form. The wins that matter are in Steps 3–5, and they are
structural, not byte-level.
One detail worth carrying to M7: pgwire caps message sizes by type.
SMALL_PACKET_SIZE_LIMIT is 10,000 bytes (messages/mod.rs:52) and is the
default max_message_length for a frontend message; Query overrides it to
LARGE_PACKET_SIZE_LIMIT, 0x3fffffff - 1 (simplequery.rs:27-29,
mod.rs:51), and DataRow overrides it to i32::MAX (data.rs:170-172). A
tiny per-message budget by default, raised deliberately where large payloads are
legitimate — compare redis’s single global proto-max-bulk-len.
Step 3 — the simple query protocol: RESP with row framing
In: the framing from Step 2. Out: the RESP-shaped mode of postgres, and the one thing it already does better — writing rows as it produces them.
SimpleQueryHandler (src/api/query.rs:48) is the familiar shape: one Query
message carrying a SQL string, and a stream of messages back — RowDescription,
zero or more DataRows, CommandComplete. What it does with them is the part
worth reading:
#![allow(unused)]
fn main() {
// pgwire src/api/query.rs — send_query_response, 568-596 (destructuring elided)
568 let QueryResponse {
// ... 569-572: command_tag, row_schema, mut data_rows ...
576 if send_describe {
577 let row_desc = into_row_description(&row_schema);
578 client
579 .send(PgWireBackendMessage::RowDescription(row_desc))
580 .await?;
581 }
582
583 let mut rows = 0;
584 while let Some(row) = data_rows.next().await {
585 let row = row?;
586 rows += 1;
587 client.feed(PgWireBackendMessage::DataRow(row)).await?;
588 }
589
590 let tag = Tag::new(&command_tag).with_rows(rows);
591 client
592 .send(PgWireBackendMessage::CommandComplete(tag.into()))
593 .await?;
}
Two things. First, data_rows is a stream — line 584 awaits each row from
the executor, so rows are encoded and handed to the socket as they are produced,
never materialized as a whole result. Second, look at feed on line 587 against
send on lines 579 and 591: in the futures Sink API, feed buffers and send
buffers and flushes. So a thousand-row response is a thousand buffered
DataRows and exactly one flush, at CommandComplete. That is the identical
discipline redis’s reply buffer implements by hand
(reading-redis-ae-networking.md, the write
path) and that this topic’s own lane implements as “flush only when drained”
(notes.md) — three codebases, one rule: never let a syscall
boundary follow a logical record boundary.
Third, and easy to miss: _on_query loops over Vec<Response>
(query.rs:102-121), because one simple-query message may carry several
semicolon-separated statements. The simple protocol’s “one request, one response”
is really “one request, one stream of responses”.
Step 4 — the extended query protocol: portals are protocol-level backpressure
In: the simple protocol from Step 3, which streams but cannot be paused. Out: the five-message decomposition, the suspend/resume mechanism read out of
_on_execute, and what the extra messages cost in round trips — worked on this topic’s measured numbers.
ExtendedQueryHandler (src/api/query.rs:174) splits “run this SQL” into
five frontend messages, not the four an earlier version of this chapter
listed: Parse → Bind → Describe → Execute → Sync (Describe is optional in
practice but is a first-class message with its own handler at query.rs:374, and
Step 3’s send_describe flag exists precisely because “not all Execute comes
with Describe” — query.rs:555-557). Flush and Close are two more.
- Parse compiles SQL into a named prepared statement — a parsed,
server-held query, reusable with different parameters.
on_parse(query.rs:185-200) runs the crate’sQueryParserand stores the result in the portal store. - Bind attaches concrete parameter values to a statement, producing a
portal — a named, executable instance of that statement.
on_bind(query.rs:206-225) errors withStatementNotFoundif you bind a name that was never parsed: the state machine, enforced. - Execute {max_rows} pulls up to
max_rowsrows from the portal. - Sync ends the sequence, drops the unnamed portal and returns
ReadyForQuery(on_sync,query.rs:436-452).
The suspend/resume is the whole point, and it is eleven lines:
#![allow(unused)]
fn main() {
// pgwire src/api/query.rs — the fetch half of _on_execute, 341-361
341 // Fetch rows from the portal and send to client
342 if needs_fetch {
343 let fetch_result = portal.fetch(max_rows).await?;
344 let mut response = fetch_result.response;
345 let command_tag = response.command_tag().to_owned();
346 let mut row_count = 0;
347 while let Some(row) = response.data_rows().next().await {
348 client.feed(PgWireBackendMessage::DataRow(row?)).await?;
349 row_count += 1;
350 }
351 if fetch_result.suspended {
352 client
353 .send(PgWireBackendMessage::PortalSuspended(PortalSuspended))
354 .await?;
355 } else {
356 let tag = Tag::new(&command_tag).with_rows(row_count);
357 client
358 .send(PgWireBackendMessage::CommandComplete(tag.into()))
359 .await?;
360 }
361 }
}
Line 351 is the branch RESP does not have. PortalSuspended means “there are
more rows and I have stopped producing them”; the query’s state stays on the
server and the client decides whether to spend more. The portal is only started
once — _on_execute checks PortalExecutionState::Initial at :271-274 and
runs do_query only then; a second Execute on the same portal falls to the
else at :336-339 and goes straight to the fetch. The public helper
send_partial_query_response (:599-637) implements the same loop for handlers
that want it directly, and it documents the protocol’s zero convention:
while max_rows == 0 || rows < max_rows at :614 — max_rows = 0 means no
limit.
Now the cost, worked. A round trip is one send-and-wait-for-reply exchange. Five messages sent one at a time, each awaited, is five round trips. This topic’s lane measures a loopback round trip at 22.68 µs at pipeline depth 1 (FINDINGS.md row 7, table in notes.md):
unpipelined, loopback: 5 × 22.68 µs = 113.4 µs → 8 818 queries/s
pipelined , loopback: 1 × 22.68 µs = 22.68 µs → 44 088 queries/s (5.0×)
unpipelined, 100 µs network RTT: 5 × 100 µs = 500 µs → 2 000 queries/s
pipelined , 100 µs network RTT: 1 × 100 µs = 100 µs → 10 000 queries/s (5.0×)
The server code is identical in both rows. Pipelining — writing the next
request before the previous reply arrives — is what collapses the 5 into 1, and
Step 1’s discard-until-Sync rule is what makes it safe: if Parse fails, the
backend throws away Bind, Describe and Execute unread rather than executing
half a batch. That is the design pgwire’s AwaitingSync state implements at
src/tokio/server.rs:208-214.
So the extended protocol’s price is not really five round trips; it is five messages, and a client that batches them pays one. What it buys is prepared statements, typed parameter binding, binary result formats, and results in client-sized bites.
Step 5 — sessions from byte 0: the startup handshake
In: a fresh TCP connection. Out: what postgres does before the first query is legal, what that costs, and what it buys that RESP cannot express.
A postgres connection is a state machine before any query. Step 1’s enum starts
at AwaitingSslRequest — the very first exchange is not even a postgres message
but an 8-byte “may I use TLS?” probe with no type byte at all: a 4-byte
length of 8 followed by the magic number 80877103 (src/messages/startup.rs:570,
:587-593) — then AwaitingStartup, then AuthenticationInProgress.
StartupHandler (src/api/auth/mod.rs:22, selected per server by
src/api/mod.rs:555) drives the rest: startup parameters (user, database,
options), then an authentication exchange, and only then ReadyForQuery.
The auth exchange is where “possibly multi-round-trip” stops being a hedge:
pgwire ships cleartext.rs, md5pass.rs, noop.rs, sasl.rs,
sasl/scram.rs and sasl/oauth.rs under src/api/auth/, and SCRAM is a
challenge-response protocol — client-first, server-first, client-final,
server-final. Count the round trips before the first byte of SQL: TLS probe,
TLS handshake, startup packet, and 2–4 more for SCRAM. At the 100 µs RTT of
Step 4’s arithmetic that is roughly half a millisecond of connection setup; at a
1 ms cross-AZ RTT it is 5 ms. That is why every postgres deployment runs a
connection pool and no redis deployment needs one — the handshake is the reason
pgbouncer exists.
What it buys, and RESP cannot express: per-session parameters (ServerParameterProvider,
src/api/auth/mod.rs:36), transaction status carried on every ReadyForQuery
(send_ready_for_query, query.rs:640-655), and cancel keys — a secret
issued at startup that a second connection can present to cancel your running
query (src/api/cancel.rs; the query path arms it at query.rs:38-44 and races
it against execution with select at :94-98). RESP’s answer to “cancel my
query” is CLIENT KILL, which kills the connection.
Step 6 — tonic/gRPC: the protocol you don’t write
In: two hand-written protocols, framing and state machine included. Out: what generation gives you, measured in lines; what it takes away; and two things qdrant had to fix in the protocol it did not write.
gRPC inverts the exercise. You write a .proto interface definition and the
framing, the parser, the streaming and the flow control are generated and
inherited. The exchange rate is measurable at qdrant’s pin:
lib/api/src/grpc/proto/*.proto 16 files, 4 446 lines (written)
lib/api/src/grpc/qdrant.rs 17 428 lines (generated)
────────
ratio = 3.9× the hand-written IDL
lib/api/src/grpc/qdrant.rs:1 says // This file is @generated by prost-build.
and points_service.proto describes the entire Points API — upsert, delete, get,
search, payload operations, index management — in 107 lines.
The costs are real. Every message is protobuf: field tags and varints, decoded
into generated structs, so there is no zero-copy path into your own value types
the way RESP’s $5\r\nalice\r\n can borrow a slice. HTTP/2 framing means nc
and telnet are useless and you need grpcurl. And the protocol you did not
write has defaults you did not choose — which is the most useful thing in
qdrant’s tonic setup:
#![allow(unused)]
fn main() {
// qdrant src/tonic/mod.rs — inside init_internal, 277-285
277 let mut server = Server::builder()
278 // Internally use a high limit for pending accept streams.
279 // We can have a huge number of reset/dropped HTTP2 streams in our internal
280 // communication when there are a lot of clients dropping connections. This
281 // internally causes an GOAWAY/ENHANCE_YOUR_CALM error breaking cluster consensus.
282 // We prefer to keep more pending reset streams even though this may be expensive,
283 // versus an internal error that is very hard to handle.
284 // More info: <https://github.com/qdrant/qdrant/issues/1907>
285 .http2_max_pending_accept_reset_streams(Some(1024));
}
That knob is HTTP/2’s defence against the “Rapid Reset” class of abuse — a peer
that opens streams and immediately resets them. qdrant’s own cluster traffic
looked like the attack, tripped the library’s ENHANCE_YOUR_CALM response, and
broke raft consensus. Inheriting a protocol means inheriting its threat model and
its defaults. The same file raises another inherited default six times:
.max_decoding_message_size(usize::MAX) on every service (:179, :185,
:191, :197, :203, :209) removes tonic’s per-message cap outright.
Two deployment details, both of which the earlier version of this chapter got partly wrong:
Server::builder()appears twice —:138ininit(public) and:277ininit_internal(peer-to-peer: raft, internal points/collections services, shard snapshots). The protocol surface is split by trust domain: two ports, two service lists, and the public one deliberately advertises only the public services through reflection (“Only advertise the public services”,:131-132). Compare redis, which exposes admin and data commands on one port and separates them with ACLs.- Auth is not at
:138. It is a tower layer,option_layer(...AuthLayer...)at:160-168for the public server and:301for the internal one — and the internal one is opt-in:
#![allow(unused)]
fn main() {
// qdrant src/tonic/mod.rs — internal auth is off by default, 255-264
255 // Only enforce authentication on the internal API when the operator
256 // explicitly opts in. The API key is still forwarded unconditionally
257 // on outgoing internal requests, so the cluster keeps working
258 // across a rolling upgrade while `enforce_internal_auth` is false.
259 let internal_auth_layer = if settings.service.enforce_internal_auth.unwrap_or_default()
260 {
261 AuthKeys::try_create(&settings.service, toc.clone()).map(auth::AuthLayer::new)
262 } else {
263 None
264 };
265
}
unwrap_or_default() on an Option<bool> is false, so the internal gRPC port
is unauthenticated unless configured otherwise, and the comment says why:
during a rolling upgrade, half the cluster would not yet be sending the key. The
split is therefore not “trusted because authenticated” but “trusted because
network-isolated” — a deployment assumption, not a code one. That is worth
knowing before copying the pattern.
The middleware itself is tower’s onion: ServiceBuilder::new().layer(logging) .layer(telemetry).option_layer(auth).into_inner() (:155-169), each concern
wrapping the next, applied once at :172. Compare redis, which checks ACLs
inside processCommand — a call, not a wrapper.
Finally, the honest note about streaming. gRPC gives you server-streaming RPCs
for free, and qdrant uses exactly one in its entire surface:
rpc ReadBytesStream(...) returns (stream ReadBytesStreamResponse)
(storage_read_service.proto:29). Grep the other fifteen protos for stream and
you get nothing. Search, scroll, retrieve — all unary: one request, one fully
materialized response. Having protocol-level streaming and using it are
different things, and on the axis this topic cares about, qdrant’s public API
behaves like RESP.
Step 7 — the design space, assembled
In: all three protocols, read rather than remembered. Out: the comparison table with the evidence attached, and the last row left for you.
| RESP | pgwire | gRPC (qdrant) | |
|---|---|---|---|
| framing | ASCII length prefixes, \r\n | 1 type byte + i32 length (messages/mod.rs:121-136) | HTTP/2 frames |
| length semantics | length of the payload | includes itself, excludes the type byte (codec.rs:77-78) | frame header |
| partial input | resume by re-parsing | Ok(None), buffer untouched (codec.rs:83) | h2 handles it |
| 3-field row | 32 bytes | 29 bytes | protobuf, varint-tagged |
| parse cost | memchr + atoi | fixed 5-byte header read | protobuf decode into generated structs |
| streaming | no — buffer all | rows as produced (query.rs:584-588) | available; used once (storage_read_service.proto:29) |
| backpressure | output-buffer-limit kill | PortalSuspended (query.rs:351-354) | HTTP/2 flow control, unused by the unary API |
| session state | none | 7-state enum (api/mod.rs:46-64) | per-RPC; none between |
| cancellation | CLIENT KILL | cancel key + select (query.rs:94-98) | HTTP/2 stream reset |
| debuggability | nc works | needs a client library | needs grpcurl |
your GRAPH.QUERY | ? | ? | ? |
Where each step lives in the code
No local clones are needed; every row was read with
tools/pinned-source.py show <repo> <path> -r A:B at the pins named at the top.
| Anchor | What | Step |
|---|---|---|
pgwire src/api/mod.rs:46-64 | the 7-state connection lifecycle | 1 |
pgwire src/tokio/server.rs:198-214 | state dispatch; discard-until-Sync | 1, 4 |
pgwire src/messages/mod.rs:121-136 | Message::encode — type byte + i32 length | 2 |
pgwire src/messages/codec.rs:63-84 | decode_packet — what the length counts, resumability | 2 |
pgwire src/messages/mod.rs:51-56 | the four packet-size limits | 2 |
pgwire src/api/query.rs:48 | SimpleQueryHandler | 3 |
pgwire src/api/query.rs:568-596 | send_query_response — stream rows, feed, flush once | 3 |
pgwire src/api/query.rs:174 | ExtendedQueryHandler | 4 |
pgwire src/api/query.rs:341-361 | the suspend/resume branch | 4 |
pgwire src/api/query.rs:599-637 | send_partial_query_response; max_rows == 0 means unlimited | 4 |
pgwire src/api/auth/mod.rs:22 + src/api/mod.rs:555 | StartupHandler | 5 |
pgwire src/api/cancel.rs, query.rs:38-44, :94-98 | cancel keys, raced against execution | 5 |
qdrant lib/api/src/grpc/proto/*.proto | 4,446 lines of IDL, 16 files | 6 |
qdrant lib/api/src/grpc/qdrant.rs:1 | 17,428 generated lines | 6 |
qdrant src/tonic/mod.rs:138, :277 | two servers, two trust domains | 6 |
qdrant src/tonic/mod.rs:155-172, :255-264, :277-285 | tower onion; opt-in internal auth; the h2 reset-stream fix | 6 |
Read pgwire asking Step 1’s question — where does session state live? — and qdrant asking Step 6’s: what did I not have to write, and what did that cost me?
Questions to answer in notes.md
- FalkorDB result sets ride RESP arrays — huge ones buffer entirely in the
module. Design
GRAPH.CURSORas RESP commands: what replacesBind(where does the portal name come from), what replacesPortalSuspended(RESP has no “I stopped” reply), and who evicts an abandoned cursor? Compare what FalkorDB actually does today (GRAPH.QUERYtimeout + result-set size config). - Why does qdrant run two tonic servers instead of one with authz — and, given
enforce_internal_authdefaults to false (src/tonic/mod.rs:259), what is actually protecting the internal port? Write the deployment assumption down as if it were a requirement. - Step 4 shows five extended-query messages costing one round trip when pipelined. What has to be true of your server for that to be safe? Name redis’s equivalent guarantee for a pipelined RESP batch, and say what happens there when command 2 of 5 fails.
feedversussend(query.rs:587against:591) is the same decision as redis’s reply buffer and this topic’s “flush only when drained”. Find the place in your M7 server where you make it, and measure the-P 64penalty for getting it wrong.- gRPC gave qdrant streaming for free and it used it once
(
storage_read_service.proto:29). For a graph query returning 1M rows, write the.protoyou would need, then ask what the client library does with a stream that outlives the request — and whether that is different from a portal.
Done when
Answer each before unfolding it.
-
You can state exactly what a postgres message header contains and what its length field counts, and prove it from pgwire’s decoder.
Answer
One ASCII type byte (absent only on the startup packet) then a 4-byte big-endian
i32. The length includes its own 4 bytes and the body, and excludes the type byte:decode_packetrequires `buf.remaining() >= msg_len- offset
(codec.rs:77) whereoffsetis 1 when a type byte is present, then advancesoffset + 4(:78). If the whole message has not arrived it returnsOk(None)(:83) without consuming anything, so the decoder resumes cleanly — andget_length(:53-59`) peeks the length without moving the cursor.
- offset
-
You can say how much smaller postgres’s framing is than RESP’s for a real row, and therefore what the protocol’s actual advantage is.
Answer
For
("alice", "30", "NYC"): 29 bytes as aDataRow(1 type + 4 length + 2 field count + 3×4 field lengths + 10 bytes of value) against 32 bytes as a RESP2 array. Under 10% — framing is not the advantage. The advantages are structural: rows stream as they are produced (query.rs:584-588), a portal can be suspended (:351-354), the session carries state, and a NULL is an in-row-1rather than a distinct wire form. -
You can explain what a portal is, what
Execute {max_rows}does when the rows run out and when they do not, and whatmax_rows = 0means.Answer
A portal is a bound, executable instance of a prepared statement — the server-side state of a partially consumed query.
_on_executestarts it once (PortalExecutionState::Initial,query.rs:271-274) and thereafter only fetches. After sending up tomax_rowsrows it sendsPortalSuspendedif more remain (:351-354) orCommandCompleteif not (:355-360).max_rows = 0means no limit:while max_rows == 0 || rows < max_rows(send_partial_query_response,:614).That is protocol-level backpressure: the server stops producing and the client chooses whether to pay for more. RESP’s only options are buffer-everything or kill the client on the output-buffer limit.
-
You can compute what the extended protocol’s extra messages cost, and name the rule that lets a client avoid paying it.
Answer
Five messages awaited one at a time is five round trips. On this repo’s loopback measurement (22.68 µs per round trip at P=1, FINDINGS.md row 7) that is 113.4 µs per query — 8,818 queries/s — against 22.68 µs and 44,088 queries/s when the five are written together: exactly 5×. At a 100 µs network RTT, 500 µs against 100 µs.
The rule that makes batching safe is postgres’s error recovery: on an error in any extended-query message the backend “reads and discards messages until a Sync is reached”, quoted in pgwire’s dispatcher at
src/tokio/server.rs:203-207and implemented as theAwaitingSyncstate. A failedParsecannot leave a half-executed batch behind. -
You can quantify what gRPC generates for you, and name two things qdrant had to override in the protocol it did not write.
Answer
4,446 lines of
.protoacross 16 files produce 17,428 lines inlib/api/src/grpc/qdrant.rs— 3.9× — and the entire Points API is 107 lines of IDL.Overrides: (1)
http2_max_pending_accept_reset_streams(Some(1024))(src/tonic/mod.rs:285), because the library’s Rapid-Reset defence fired on qdrant’s own cluster traffic and broke raft consensus (issue #1907); (2)max_decoding_message_size(usize::MAX)on all six public services (:179,:185,:191,:197,:203,:209), removing tonic’s per-message cap. A third, if you want it: TLS and compression are configured per server rather than inherited. -
You can say whether gRPC actually solves the 10M-row problem in qdrant, with evidence.
Answer
No. The capability exists — server-streaming RPCs and HTTP/2 flow-control windows — but qdrant declares exactly one streaming RPC in its whole surface,
ReadBytesStream(storage_read_service.proto:29), and it is a storage-read internal. Search, scroll and retrieve are unary: one request, one fully materialized response, which is RESP’s failure mode with a nicer encoding. Inheriting backpressure is not the same as using it — and that is the question M7 has to answer for itself rather than delegate to a framework. -
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.
Answer
There is no single right answer; there is a defensible one. RESP + explicit cursor commands keeps
ncdebuggability, keeps redis clients working, and puts the cursor where the cost actually is — but it makes you write the cursor lifetime, eviction and error recovery that postgres’sSyncrule and gRPC’s stream lifetime give you. gRPC hands you streaming and flow control and takes away the zero-copy parse path, the single-port deployment and every existing redis client — and qdrant’s one streaming RPC out of sixteen protos is evidence that having the mechanism does not mean you will use it.Whatever you choose, the defence has to name what you are buying and what you are writing yourself. “It’s the standard” is not a row in the table.
References
Code, at this repo’s pins
- sunng87/pgwire @
6bb6299—src/messages/mod.rsandsrc/messages/codec.rs(framing),src/api/query.rs(both query protocols),src/api/auth/mod.rs(startup and authentication),src/api/mod.rs(connection state, handler selection),src/tokio/server.rs(the dispatcher). The crate layout is the protocol lesson: one module per message family, one type per message. - qdrant/qdrant @
44ad62f—src/tonic/mod.rs(two servers, tower middleware, the inherited-default overrides) and the generated protocol crate atlib/api/(notapi/: protos inlib/api/src/grpc/proto/, generated code inlib/api/src/grpc/qdrant.rs). - Fetch either with
tools/pinned-source.py show <repo> <path> -r A:B; verify an anchor withtools/pinned-source.py check <repo> <path>:<line> --contains '…'.
Corrections made to the previous version of this chapter
src/api/auth.rs→src/api/auth/mod.rs:22(authis a directory).- qdrant “
api/crate” →lib/api/. - “middleware layers in mod.rs (auth around :138)” →
:138isServer::builder(); auth isoption_layerat:160-168(public) and:301(internal), and the internal one is disabled by default (:259). - “five messages — Parse → Bind → Execute → Sync” listed four; the five are Parse, Bind, Describe, Execute, Sync.
- “local clones at
~/repos/pgwireand~/repos/qdrant” — no such clones exist in this repo’s workflow;tools/pinned-source.pyis the access path.
Read alongside
- reading-redis-ae-networking.md — the same buffer-and-flush-once discipline, in C.
- reading-bolt-packstream.md — a third answer, where the cursor is specified and then not implemented.
- Topic 7 README §4 — the framing this chapter supplies the evidence for.
Measured in this repo
- FINDINGS.md row 7 — 44,088 ops/s at P=1, 12,321,414 at P=256, 279×; the 22.68 µs round trip used in Steps 4 and 5 is the P=1 row of notes.md.