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.
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.
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.
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:
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.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.
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:
: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.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 channels | open fds | after server/shutdown | |
|---|---|---|---|
| before | 3 → 8, one per channel | 303 → 903 | 8 groups, 900 fds, unchanged |
| after | 3, constant | 301, flat | 1 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.
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.
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.
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.
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.
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.
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.
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.
:tls covers cert/key; tcnative/OpenSSL tuning is
documented as an add-on, not depended on — h2c deployments need none of it.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |