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 resolves method
prototypes exactly the way the emitter's hints do: derive the Java class name
protoc would generate (only for java_multiple_files or edition-2024
top-level classes — the same subset the plugin hints), verify it describes the
same message, fall back silently to DynamicMessage over the same
FileDescriptor instance the namespace uses. Both arms align: with the
generated classes present, everything is the generated pool; without them,
everything is the embedded pool. The service tests pin the property; the e2e
suite exercises it 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.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.
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: :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. The per-call machinery floor (~190 µs)
also 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!} controls plus a
promise (client-streaming) or deliver into the caller's observer map (bidi).
Deadlines and wait-for-ready ride per-call opts.
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.
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; the README carries the table.
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 via build.clj,
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 |