Skip to content

Spike: native gRPC support (unary, server/client/bidirectional streaming) #246

@FumingPower3925

Description

@FumingPower3925

Context

middleware/protobuf today handles application/x-protobuf serialization only — a thin wrapper around proto.Marshal / proto.Unmarshal. It does not implement gRPC. User question that prompted this spike (paraphrased): "Can celeris speak gRPC in all four communication patterns via the protobuf middleware?" — the honest answer is no, and four concrete gaps block all four patterns. Spike the design for closing them in v1.5.0.

The gaps

Per-pattern matrix of what celeris supports today:

Pattern Unary Server streaming Client streaming Bidirectional
HTTP/2 transport
Write response body ✅ (StreamWriter)
Read request body incrementally n/a n/a
Write response trailers
gRPC length-prefix framing

Gap 1 — Outbound H2 trailers (blocks all four patterns)

gRPC terminates every call with a HEADERS frame carrying grpc-status and optionally grpc-message, sent after the DATA frames. Celeris' H2 parser correctly ingests inbound trailers (see protocol/h2/stream/processor.go:701-729) but there is no public API to emit outbound trailers. No Context.SetTrailer(...), no StreamWriter.WriteTrailer(...), no engine-side hook that writes a post-body HEADERS frame with END_STREAM.

Required work:

  • New public API: StreamWriter.WriteTrailer(headers [][2]string) error (+ Context helper).
  • Engine wiring for both native engines (epoll, iouring) to flush DATA then emit a trailing HEADERS frame with END_STREAM, preserving strict ordering on the write path.
  • engine/std bridge: map to http.ResponseWriter trailer protocol (pre-declare in Trailer header then set entries on the trailer map — stdlib's quirk).

Gap 2 — Incremental request-body reads (blocks client/bidirectional streaming)

Every current code path buffers the full request body before invoking the handler:

  • protocol/h2/stream/processor.go:817-826 dispatches the handler only when the client's END_STREAM flag arrives.
  • engine/std/bridge.go:59-61 calls io.ReadAll(r.Body).
  • stdlib.go:48 mirrors the same.
  • internal/conn/h1.go:260-304 accumulates the full H1 body before dispatch (correct for REST, fatal for streaming).

For server/unary streaming this is fine. For client and bidirectional streaming the handler must be able to pull messages off the stream as they arrive. Required work:

  • Streaming-request mode opt-in (route-scoped or handler-scoped — decide in spike).
  • H2 processor: dispatch handler on initial HEADERS (no END_STREAM) when route is streaming; deliver DATA frames to an io.Reader / channel that the handler drains.
  • Back-pressure via existing flow-control window (don't advance the WINDOW_UPDATE until the handler consumes).
  • std-engine bridge: stop io.ReadAll; expose r.Body directly. Flag the "body retained post-handler" policy for the zero-copy rawBody interaction (v1.5.0 already lands that).
  • Per-stream cancellation when the client half-closes while the handler is still writing.

Gap 3 — gRPC framing

Each gRPC message on the wire is [1B compressed][4B length BE][payload]. Trivial. Can live in the new middleware/grpc package as a helper pair:

func WriteMessage(w io.Writer, msg proto.Message) error
func ReadMessage(r io.Reader, msg proto.Message) error

No engine work needed — celeris' existing DATA-frame path already handles the byte-level shape.

Gap 4 — Protocol ergonomics

  • content-type: application/grpc and grpc-encoding, grpc-accept-encoding, grpc-timeout handling.
  • Status-code mapping (celeris errorgrpc-status + grpc-message).
  • TE: trailers requirement (gRPC clients send TE: trailers; servers MUST honor trailers when present).

All middleware-layer. Lives in the new middleware/grpc package.

Proposal

Option A: First-class in core. Add StreamWriter.WriteTrailer + streaming request mode to the core engines and ship middleware/grpc as a thin layer on top. Maximises performance (zero framework-to-gRPC adapter overhead), breaks no API, but forces trailer + streaming-body work into the core schedule.

Option B: Separate middleware/grpc submodule with its own H2 hijack. grpc middleware grabs the underlying conn via Context.Detach (WS/SSE pattern) and speaks gRPC directly, bypassing celeris' H2 stream handling. Clean isolation but loses route-level middleware composition.

Option C: Hybrid. Core gets WriteTrailer + streaming-body mode (narrow additions). middleware/grpc is a standalone package that uses those hooks. Simpler than A, cleaner than B.

Spike should converge on C unless a concrete issue kills it.

Exit criteria

  1. Decision doc: chosen design option + API sketch committed to middleware/grpc/DESIGN.md.
  2. Prototype serving unary end-to-end (trailer write path exercised) against the official grpc-go client.
  3. Prototype serving server streaming (validates StreamWriter + trailer ordering).
  4. Prototype serving client streaming (validates incremental body reads + backpressure).
  5. Prototype serving bidirectional streaming (validates both halves concurrently, including cancellation on client half-close).
  6. Interop pass: run a subset of grpc-go's conformance suite (interop_client) against celeris.
  7. Benchmark: unary and server-streaming gRPC RPS vs google.golang.org/grpc's default transport on msr1.
  8. Memory characterization: goroutines + RSS per concurrent bidirectional stream at 1 k / 10 k streams.
  9. Decision: ship the streaming-body + trailer-write work in v1.5.0 core, or stage into v1.6.0 if the surface is bigger than size/L.

Critical files (likely touched)

  • protocol/h2/stream/stream.goWriteTrailer on the response-writer interface.
  • protocol/h2/stream/processor.go:817-826 + :947-957 + :1012 — dispatch timing for streaming bodies.
  • internal/conn/h2.go — server preface / trailers emit.
  • internal/conn/response.go (H1 adapter — ensure WriteTrailer fails cleanly on H1 conns).
  • engine/std/bridge.go:59-76 + stdlib.go:47-61 — stop ReadAll; expose body reader; wire trailer-header protocol.
  • New: middleware/grpc/ — framing, status/error mapping, content-type checks.

Related

  • Spike (v1.5.0): async dispatch for HTTP/2 connections #242 Spike: async dispatch for HTTP/2 — streaming body + trailer work intersects with the per-stream dispatch shape; align before implementation.
  • Spike: per-route AsyncHandler control #239 Spike: per-route AsyncHandler control — same per-route opt-in surface likely carries the streaming-body mode flag.
  • v1.5.0 H1 body-path refactor (bodyBuf + NextRecvBuf) establishes the "handler needs an incremental reader" mental model on the H1 side; H2 streaming mode should align.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/apiPublic-facing API surfacearea/engineEngine interface or implementationarea/protocolProtocol parsing / detectionenhancementNew feature or requestprotocol/h2HTTP/2 protocolsize/L~3-5 days of work

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions