Liking cljdoc? Tell your friends :D

clj-grpc — design

gRPC for Clojure, layered on clj-protobuf: services and clients built dynamically from what protoc-gen-clojure already emits, with plain functions as handlers and protobuf Messages on the wire. No stub generation, no macros: a generated file's entire service surface is

(def Greeter (rts/service file-descriptor "Greeter"))
(def greeter-methods (rts/methods-map Greeter))

and everything else — method names, streaming shapes, marshallers, grpc-java MethodDescriptors — is derived from the FileDescriptor at load time.

The service value

service returns a record of Method records, one per RPC in declaration order: proto name, kebab key, streaming type (:unary / :server-streaming / :client-streaming / :bidi), request/response prototypes, and a delayed io.grpc.MethodDescriptor — delayed so building a Service never constructs grpc machinery a caller that only wanted the shapes will not use. methods-map keys them by kebab keyword; that map is the bridge both the server and the client consume, and the only coupling between them.

Pool discipline, inherited

clj-protobuf's rule — never mix descriptor pools — lands here with force: a marshaller's prototype decides which pool parsed requests live in, and handlers hand those requests straight to the generated proto->X fns, whose handles live in the namespace's pool. So service chooses nothing: it hands each method's request and response Descriptor — owned by the same FileDescriptor instance the namespace uses — to clj-protobuf.runtime/prototype (0.2.1), which derives the Java class hint by the emitter's own rule and returns the arm rt/message gave the namespace for that message: the generated class when it resolves and describes the same message, else the compiled codec, else DynamicMessage under -Dclj-protobuf.codec=dynamic. The marshaller's prototype is therefore the namespace's arm by construction, so inbound parsing lands on the codec proto->X reads instead of on DynamicMessage's FieldSet path beside a compiled namespace — and clj-grpc carries no copy of the hint rule to drift (its old copy did, on nest_in_file_class = YES). Both arms align: with the generated classes present, everything is the generated pool; without them, everything is the embedded pool on one codec. The service tests pin the property on the hinted arm, the codec tests on both fallback arms; the e2e suite exercises all three over the wire.

Non-shaded Netty, deliberately

grpc-netty-shaded hides the version-alignment problem but rewrites package names, making netty-transport-native-epoll unreachable — and epoll is how both Unix domain sockets and the fast path work. So this library takes the alignment problem on, explicitly:

  • grpc-java's SECURITY.md pairing table is the authority (grpc 1.84.x ↔ Netty 4.2.16.Final); deps.edn pins every netty artifact top-level, because tools.deps gives top-level pins absolute precedence over grpc's pom ranges. Worth knowing that the table's rows are per grpc minor and move most releases: 1.83.x pairs 4.2.15, 1.84.x pairs 4.2.16, 1.85.x pairs 4.2.17.
  • netty_alignment_test asserts every loaded netty jar reports the pinned version and that NettyChannelBuilder links. It caught real drift before the first commit — grpc-netty 1.83.1's own pom pulls netty-codec-socks and netty-handler-proxy at 4.2.15 — which is precisely the failure class non-shaded Netty threatens, converted into a CI failure forever.
  • Bumping grpc is therefore a ritual: consult the pairing table, bump the whole pin block, let the test judge.

One knock-on: the lockfile records no dependency edges for top-level-pinned artifacts (rules_clj's :dependents inversion has nothing at the root), so src/BUILD.bazel lists every netty jar explicitly rather than trusting transitivity. A rules_clj follow-up may remove the need.

Transport

Netty 4.2's event-loop API (MultiThreadIoEventLoopGroup over an IoHandler factory), epoll preferred, NIO fallback, :transport :auto|:epoll|:nio with eager, diagnosed failure when epoll is demanded but absent — and eager rejection of UDS-on-NIO, because a bind-time surprise is worse than an analysis-time one.

Event-loop threads are daemon, like grpc-java's own defaults. The alternative pins any JVM that does not end in System/exit — every REPL, every clojure -X — and surfaced exactly that way: tests green, process immortal, caught by the plain-clj CI leg on its first run.

The groups are ours, so releasing them is ours too. grpc-netty shuts down only a group it created itself; one passed to NettyChannelBuilder or to NettyServerBuilder's boss/worker setters stays running after ManagedChannel.shutdown or Server.shutdown. The library passes its own in all three places — it has to, because it forces the channel class for epoll and UDS and the two must agree — which makes ownership a decision rather than a detail, and it is answered differently on each side:

  • Servers own theirs. :worker-threads sizes a server's worker group, so it cannot be shared, and a server's loops should die with it. The groups ride in the server map next to :owned-executor and shutdown releases them last, gracefully, after the listener has closed and the executor is down.
  • Channels share one group per transport, process-lifetime, which is the shape grpc-netty's own default has (one group behind SharedResourceHolder). There is no last-channel-closed moment to release on that the next channel call would not immediately invalidate, and the cost of not sharing is measured below. What sharing costs instead: a blocking :executor :direct callback now stalls other channels' I/O, documented where that option is.

What it was costing, measured on a 20-core host over six channel lifetimes against one server — main at the time, then the same script with the fix:

groups after six channelsopen fdsafter server/shutdown
before3 → 8, one per channel303 → 9038 groups, 900 fds, unchanged
after3, constant301, flat1 group, 177 fds

~120 descriptors per channel, because EpollIoHandler opens an epoll, an event and a timer fd per loop and a group holds 2 × cores of them. Ten channels is EMFILE against the usual 1024 limit — the descriptors, not the threads, are the part that takes a process down.

Daemon threads are why none of it was visible: nothing leaked in a suite, because every JVM still exited. The subtler reason is why event_loops_test counts groups and descriptors, never threads: a group starts its loops lazily, one per loop first given work, so each new channel started ~5 more threads inside whichever group it used — identically whether that group was shared or fresh. A thread-count assertion would have passed before the fix. Groups are identified by the pool id Netty's DefaultThreadFactory writes into every thread name.

Unix domain sockets work in both directions ({:unix path} / "unix:///path"), epoll-only, with the socket-path length limit (~108 bytes) respected in tests by binding under /tmp — hermetic per action in Bazel's Linux sandbox — never TEST_TMPDIR.

Server

A ServerServiceDefinition is built dynamically from the methods map; each handler fn is adapted per streaming shape via ServerCalls (unary (fn [req] resp); server-streaming (fn [req send!]); client-streaming (fn [respond!]) -> observer-map; bidi (fn [send! close!]) -> observer-map). Methods without handlers are simply omitted and answer UNIMPLEMENTED — gRPC's own semantics, not a reimplementation. Thrown exceptions become Status/INTERNAL with the message attached; throwing a StatusRuntimeException controls the status. Health (HealthStatusManager, default on) and reflection (v1, opt-in) are wired as grpc-services instances, not reimplemented.

Executor choice is measured, not asserted — and the measurement depends on the core budget, which is why there are two of them. On loopback with cores to spare, :executor :direct (the Netty event loop) cuts unary latency ~29% and loses ~9% throughput at 32-way concurrency; the load mode of the benchmark exists precisely because the sequential lens inverts under load. On a 1-CPU pod that inversion is gone: the on-cluster ladder puts :direct ahead on CPU per request (15–27% on unary), on streaming capacity (~25%) and on p50 (roughly half), because there VT's per-mount cost buys parallelism the pod has no cores to use. So the argument for :direct is tail latency on a small pod rather than throughput anywhere, the argument against it is unchanged and absolute — a blocking handler stalls the loop — and grpc-java's position that :direct will not be optimised further settles the default: virtual threads, which is also the ladder's baseline. The per-call machinery floor (~190 µs) still anchors the strongest guidance this library can give: one stream beats N small unaries by two orders of magnitude, before any tuning.

Handlers run on virtual threads by default: Clojure handlers block — that is the model — and grpc's default shared pool is sized for handlers that never do. :executor overrides; a server-owned executor is closed on shutdown.

Keepalive is two-sided and the sides must agree: gRPC servers reject pings more frequent than permitKeepAliveTime (default five minutes) with GOAWAY too_many_pings, so :permit-keepalive is exposed and the Knative presets pair the client's 30-second pings with a matching server permit — a pairing pinned by its own test, because presets that fight each other are worse than no presets. Shutdown enters the health service's terminal NOT_SERVING state before the listener closes, the drain order rollouts assume; clj-grpc.health exposes status transitions as functions.

Plaintext (h2c) is the default and TLS the option — the reverse of grpc-java's posture, because the deployment target is a mesh/Knative world where the platform owns transport security and h2c is what the ingress speaks.

Client

Channels take "host:port", dns:/// targets, or UDS forms; calls come from the same methods map — invoke per call or client for the whole service as a map of fns. Blocking shapes block (unary → response, server-streaming → seq); streaming-in shapes return {:send! :close! :error! :ready? :request!} controls plus a promise (client-streaming) or deliver into the caller's observer map (bidi). Deadlines and wait-for-ready ride per-call opts.

Flow control, both directions, mirroring the server. send! answers whether the transport wants more, :ready? asks without sending, and :on-ready — in the observer map for bidi, in opts for client-streaming, since there is no map there to put it in — waits instead of polling, under the same rule as the server's: grpc serializes a call's callbacks, so the thread running one must not wait for another. A :bidi caller that wants to pace what it receives passes :inbound-credits n and asks for more with :request!.

Reaching any of this needs one specific mechanism. grpc exposes it on ClientCallStreamObserver, but ClientCalls returns the request side typed as a plain StreamObserver and freezes it the moment the call starts — setOnReadyHandler and disableAutoRequestWithInitial both throw IllegalStateException afterwards, and their messages say to use ClientResponseObserver. So the client always passes one: its beforeStart is called from the listener adapter's constructor, before the call starts and therefore before ClientCalls returns, and its argument is the documented way to reach the observer rather than casting the return value. (Verified from grpc-java 1.83.1 bytecode, like the ordering facts under Interceptors.)

One hazard that is the API's, not ours: a call's first send! usually answers false, because the stream is not up when invoke returns. That is not backpressure, and a producer that stops on it stalls on its own first message. flow_control_test pins the distinction — the first answer need only be a boolean; truth is asserted after :on-ready.

Interceptors

An interceptor is a function of the call: (fn [call next] ...), the same word on both sides. It receives a map describing the call — method key, headers, peer, authority, deadline, the grpc objects underneath — and either hands off with (next call'), possibly enriched, or on the server returns (reject status description trailers). Underneath, each fn becomes one io.grpc.ServerInterceptor or ClientInterceptor — grpc's own extension point, wrapped rather than reinvented, the same way health and reflection are grpc-services instances. Raw grpc interceptors go in the same :interceptors vector and run in the same order.

Handlers read the call through clj-grpc.context with no signature change, because grpc already attaches a per-call io.grpc.Context around every listener callback — on the virtual-thread executor and on :direct alike — and Contexts/interceptCall lets the adapter hang the enriched map on it. The map exists whenever at least one Clojure interceptor is in the chain; with none, nothing is built and nothing is registered, so an empty vector costs what it did before there were interceptors: nothing — measured in performance.md, which also prices opting in at ~0.5–0.7 µs per unary call and nothing per streamed message. The context is a ThreadLocal: a thread the handler starts itself must be wrapped with (.wrap (Context/current) f) to see it, which the docstring and a test say.

Two rules made explicit because grpc's own are surprising. Order: [a b c] runs a outermost on every path — the builders run the last-registered interceptor outermost, so registration reverses the vector, while per-call client interceptors use interceptForward, which does not. Rejection is a value, not an exception: the auth-failure path is the common one and should not pay for a stack trace; throwing still works as it does in handlers.

Response headers and trailers are two keys on the map given to next, merged into what the handler's response carries. Client-side headers are declared in the map and written when the call starts, because grpc creates the outgoing Metadata after interceptors run; that is also why an inner interceptor can see what an outer one declared, and why headers a raw interceptor adds at start are invisible to Clojure ones.

The wrappers this needs — a no-op listener, forwarding calls and listeners — are abstract classes in grpc-api with no public concrete subclass, so they are proxy classes. Clojure 1.12's proxy expands to a direct new of a class AOT writes to disk, with no Class.forName, so the native image needs no reflection entries for them; the example server wires one interceptor by default precisely so the CI native job proves that on every change.

Deliberately not sugared: hooks per message (that is the handler's shape), registration per method (ServerInterceptors/intercept on a service definition is one call away), and trailers set from inside a handler — the error path carries them already, via a StatusRuntimeException built with trailers.

Knative

clj-grpc.knative is presets, not machinery: the server preset is h2c on $PORT with health and reflection on (and the README documents naming the container port h2c); the client preset is plaintext + wait-for-ready + keepalives — the activator-in-path, scale-from-zero posture where the first request must tolerate a pod that is still being summoned.

Cold start, and the Netty leaf

Native-image works, and the design that unblocked it is now load-bearing API structure. The collision was precise: Clojure-compiled code initializes referenced classes eagerly (fn-class <clinit> → RT.classForName initialize=true) at namespace load — build time under --initialize-at-build-time — while Netty's shipped native-image metadata mandates run-time init for its native/Unsafe-touching classes, correctly. The resolution: every Netty and grpc-netty construction lives in one leaf namespace, clj-grpc.impl.netty, which transport/server/client reach only through requiring-resolve at first construction. The leaf's classes are marked initialize-at-run-time (package clj_grpc.impl) and its init class is registered for reflection so the runtime require can find it; the class values it resolves (the six channel classes) are the only other reflection entries needed, because the leaf deliberately avoids :import — imports intern through Class.forName, fully-qualified interop compiles to direct method references. The jar ships this as auto-discovered META-INF/native-image config, which also fills two upstream gaps grpc-netty leaves open (it publishes no metadata; only grpc-netty-shaded does): io.grpc.netty wholesale, because NettyServerBuilder.<clinit> probes Epoll.<clinit> — a JNI load — at class init; and io.netty.handler.ssl, because JdkSslServerContext.<clinit> parses a PEM and allocates ByteBufs.

lazy_netty_test pins both halves of the contract on the JVM: requiring the API namespaces must not load the leaf, and no source file outside the leaf may mention a Netty package. On the JVM the indirection is behaviorally inert — the leaf loads at first construction, event loops, UDS, everything as before.

Measured with the //bench:coldstart harness (spawn-to-first-success, warm prober, fresh channel per probe — a reused channel's reconnect backoff quantizes the reading, which is also why two earlier numbers moved): plain jar ~1.75 s, AppCDS ~1.71 s, native image ~79 ms — 22×, and the binary serves Unix domain sockets through the embedded epoll JNI transport. The honest prober withdrew the earlier −57% AppCDS claim: this startup is dominated by running Clojure's class initializers, which CDS cannot skip. Native-image caveats for consumers: AOT everything (no compiler in the image), and prefer the embedded-descriptor arm of generated code — the class-hinted arm rides protobuf-java's reflection, which wants per-message registration the library does not ship.

The steady-state half of the trade is measured too (//bench:steady, same two servers, warm JVM client, 20k-call warmup): once the JIT is warm the JVM serves unary p50 ~247 µs and ~29k calls/s at 32-way against the native image's ~315 µs and ~18k calls/s. Native buys the start, the JIT buys the cruise; performance.md carries both tables so a consumer can pick by workload instead of by ideology.

Measured, not asserted

Two benchmarks, both with always-on smoke tests so they cannot rot: clj-protobuf's serialization corpus, and this repo's RPC benchmark — full round trips on loopback against the ordinary Clojure REST stack (Pedestal on Jetty, jsonista both sides, JDK HttpClient), identical echo semantics asserted before anything is timed. ~2.8× at every payload size, framing-dominated through bytes-dominated; performance.md carries the table, and the provenance caveat that qualifies its largest payload.

Build and release

Identical discipline to clj-protobuf: Bazel + rules_clj primary with the bazel 8/9 × plain-clj CI matrix under one aggregate check, deps.edn as the single dependency source, source-only Clojars jar and upload via //src:clojars (rules_clj's clj_maven_export), version-consistency and formatting as ordinary tests, tag-gated release environment, and a release workflow that refuses tags not on main or disagreeing with version.edn.

Non-goals

  • Stub/interface generation. The methods map plus plain fns is the API; anything more is sugar someone can build on top.
  • An async/blocking abstraction layer. Blocking where gRPC blocks, observer-shaped where gRPC streams; core.async/manifold adapters belong in wrapper libraries with their own dependency choices.
  • TLS machinery. :tls covers cert/key; tcnative/OpenSSL tuning is documented as an add-on, not depended on — h2c deployments need none of it.
  • Shaded-Netty compatibility. The entire point is the native transports; a consumer who wants grpc-netty-shaded wants a different library.
  • grpc-web, xDS, retries-as-policy. Grow on demand, behind the same alignment discipline.
  • Built-in interceptors. The mechanism ships; logging, auth and metrics are eight lines each and belong to the application (the examples carry one of each shape).

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close