Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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’s QueryParser and 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 with StatementNotFound if you bind a name that was never parsed: the state machine, enforced.
  • Execute {max_rows} pulls up to max_rows rows 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 — :138 in init (public) and :277 in init_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-168 for the public server and :301 for 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.

RESPpgwiregRPC (qdrant)
framingASCII length prefixes, \r\n1 type byte + i32 length (messages/mod.rs:121-136)HTTP/2 frames
length semanticslength of the payloadincludes itself, excludes the type byte (codec.rs:77-78)frame header
partial inputresume by re-parsingOk(None), buffer untouched (codec.rs:83)h2 handles it
3-field row32 bytes29 bytesprotobuf, varint-tagged
parse costmemchr + atoifixed 5-byte header readprotobuf decode into generated structs
streamingno — buffer allrows as produced (query.rs:584-588)available; used once (storage_read_service.proto:29)
backpressureoutput-buffer-limit killPortalSuspended (query.rs:351-354)HTTP/2 flow control, unused by the unary API
session statenone7-state enum (api/mod.rs:46-64)per-RPC; none between
cancellationCLIENT KILLcancel key + select (query.rs:94-98)HTTP/2 stream reset
debuggabilitync worksneeds a client libraryneeds 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.

AnchorWhatStep
pgwire src/api/mod.rs:46-64the 7-state connection lifecycle1
pgwire src/tokio/server.rs:198-214state dispatch; discard-until-Sync1, 4
pgwire src/messages/mod.rs:121-136Message::encode — type byte + i32 length2
pgwire src/messages/codec.rs:63-84decode_packet — what the length counts, resumability2
pgwire src/messages/mod.rs:51-56the four packet-size limits2
pgwire src/api/query.rs:48SimpleQueryHandler3
pgwire src/api/query.rs:568-596send_query_response — stream rows, feed, flush once3
pgwire src/api/query.rs:174ExtendedQueryHandler4
pgwire src/api/query.rs:341-361the suspend/resume branch4
pgwire src/api/query.rs:599-637send_partial_query_response; max_rows == 0 means unlimited4
pgwire src/api/auth/mod.rs:22 + src/api/mod.rs:555StartupHandler5
pgwire src/api/cancel.rs, query.rs:38-44, :94-98cancel keys, raced against execution5
qdrant lib/api/src/grpc/proto/*.proto4,446 lines of IDL, 16 files6
qdrant lib/api/src/grpc/qdrant.rs:117,428 generated lines6
qdrant src/tonic/mod.rs:138, :277two servers, two trust domains6
qdrant src/tonic/mod.rs:155-172, :255-264, :277-285tower onion; opt-in internal auth; the h2 reset-stream fix6

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

  1. FalkorDB result sets ride RESP arrays — huge ones buffer entirely in the module. Design GRAPH.CURSOR as RESP commands: what replaces Bind (where does the portal name come from), what replaces PortalSuspended (RESP has no “I stopped” reply), and who evicts an abandoned cursor? Compare what FalkorDB actually does today (GRAPH.QUERY timeout + result-set size config).
  2. Why does qdrant run two tonic servers instead of one with authz — and, given enforce_internal_auth defaults 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.
  3. 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.
  4. feed versus send (query.rs:587 against :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 64 penalty for getting it wrong.
  5. 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 .proto you 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_packet requires `buf.remaining() >= msg_len

    • offset (codec.rs:77) where offsetis 1 when a type byte is present, then advancesoffset + 4 (:78). If the whole message has not arrived it returns Ok(None) (:83) without consuming anything, so the decoder resumes cleanly — and get_length (:53-59`) peeks the length without moving the cursor.
  • 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 a DataRow (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 -1 rather 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 what max_rows = 0 means.

    Answer

    A portal is a bound, executable instance of a prepared statement — the server-side state of a partially consumed query. _on_execute starts it once (PortalExecutionState::Initial, query.rs:271-274) and thereafter only fetches. After sending up to max_rows rows it sends PortalSuspended if more remain (:351-354) or CommandComplete if not (:355-360). max_rows = 0 means 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-207 and implemented as the AwaitingSync state. A failed Parse cannot 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 .proto across 16 files produce 17,428 lines in lib/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 nc debuggability, 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’s Sync rule 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.rs and src/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 at lib/api/ (not api/: protos in lib/api/src/grpc/proto/, generated code in lib/api/src/grpc/qdrant.rs).
  • Fetch either with tools/pinned-source.py show <repo> <path> -r A:B; verify an anchor with tools/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 (auth is a directory).
  • qdrant “api/ crate” → lib/api/.
  • “middleware layers in mod.rs (auth around :138)” → :138 is Server::builder(); auth is option_layer at :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/pgwire and ~/repos/qdrant” — no such clones exist in this repo’s workflow; tools/pinned-source.py is the access path.

Read alongside

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.