ADR-000006: RPC as a Correlated Protocol — partisan_erpc as the Primary Surface, rex Kept for What Defines It
View Source
- Status: ACCEPTED
- Decision: Stop routing
callthrough thepartisan_rpc_backendserver. RPC becomes an explicit correlated request/response over the Partisan transport: each request carries its own correlation reference and is executed by a worker process spawned per request, replying directly to the caller.partisan_erpcis re-based on that protocol and becomes the primary surface — theerpcAPI in full, including the OTP 25+ request-id collections the vendored snapshot had silently drifted behind — whilepartisan_rpcis re-based as a thin shim over it, mirroring therpc-on-erpcsplit OTP itself has used since OTP 23.partisan_rpc_backendis kept permanently as the counterpart of OTP'srex, demoted from "all RPC" to the four operations whose semantics require a server. Inbound concurrency is bounded, and every surface gains per-call transport options so a caller can put RPC on a channel of its own.
Context
partisan_rpc_backend is a single gen_server registered per node. Before this
record, every inbound partisan_rpc:call/4,5 arrived as
{call, M, F, A, Timeout, {origin, Caller}} and was applied inline in
handle_info/2. Three consequences followed from that one line:
- Every RPC arriving at a node was serialised through one process. A single
slow, blocking or hung
M:F(A)stalled every unrelated RPC behind it in that server's mailbox. This is a throughput property, not a fairness nicety: the node's entire RPC capacity was one process deep. - Replies were uncorrelated. The framing carries no request id, so a reply that arrives after its caller has timed out can be consumed by an unrelated later call in the same process. A caller with several requests outstanding could not reliably tell them apart at all.
- A crashing RPC was a supervision event. Applying user code in the callback put an arbitrary function's failure modes in the same process as the server.
Applying user code inline is not itself the defect. block_call's documented
semantic is "execute on the server itself, serialised with every other
block_call, blocking it" — that is what distinguishes it from call. Inline
application is therefore correct for block_call and wrong for call, and
the defect is that call was routed to the block_call machinery. The fix is a
routing change, not a rewrite.
A second, independent problem sat alongside it. partisan_erpc was a vendored
copy of an OTP 23/24-era erpc that still reached peers with the auto-imported
spawn_request/5 BIF and received results as the exit reason of a distributed
monitor. Both are distribution-protocol mechanisms: they ride disterl, not
the Partisan transport. So the module that modern OTP code actually calls — rpc
has been informally deprecated in favour of erpc for years, and OTP's own rpc
source steers readers to erpc inline — did not work over Partisan at all, and
no test had ever loaded it. It had also drifted eight functions behind upstream:
the entire OTP 25+ request-id collection API was missing, so idiomatic modern
fan-out code failed with undef.
The decision
1. A correlated request/response protocol
A request is {?ERPC_REQUEST, Res, Origin, M, F, A} where Res is a
correlation reference that is also the reply address. The reply is
{?ERPC_REPLY, Res, Payload} sent directly from the worker to Origin; the
server is not in the reply path.
Res is an encoded process alias (erlang:alias([explicit_unalias])). That
choice does two jobs with one term: it correlates the reply, and it gives the
runtime a way to drop a late reply — abandoning a request deactivates the
alias, so a reply arriving afterwards is discarded rather than accumulating in an
abandoned caller's mailbox. Upstream erpc gets the same guarantee from
demonitor(_, [flush]), because there the result travels as a monitor DOWN.
A monitor is still taken, but only to detect that the target became
unreachable — deliberately not as the value channel. In Partisan every remote
DOWN is relayed through the monitoring node's partisan_monitor server, so
carrying return values on exit reasons would funnel every RPC result on a node
through that single process, reintroducing the bottleneck this record removes in
a less obvious place.
2. Worker per request, never inline
The receiver correlates and spawns; it never applies user code in its callback.
The worker traps and translates exceptions at the boundary, so a crashing RPC
cannot reach the server. The reply payload deliberately reuses the same tuple
shape partisan_erpc:execute_call/4 builds — {Res, return | throw | exit, V}
or {Res, error, E, Stack} — so result/4 translates it into the exception
semantics erpc specifies with no modification.
3. Vendor the pure logic; write the transport natively
The line is drawn between erpc's pure logic and its transport:
- Vendored verbatim and kept diffable: error translation,
trim_stack/4,is_arg_error/4,result/4, response matching. This is the part OTP-compat actually depends on. - Written natively: everything that reaches a peer.
spawn_request/5plus exit-reason delivery is pure disterl mechanics and none of it transfers.
-compile({no_auto_import, [spawn_request/5, spawn_request_abandon/1]}) makes
any missed call site a compile error rather than a silent fallback to
disterl. This is the highest-value safety measure in the change and cost one
line.
OTP validates this exact split independently: rex itself calls
erpc:execute_call/3, erpc:execute_cast/3, erpc:is_arg_error/4 and
erpc:trim_stack/4. OTP draws the vendor-the-pure-logic line in the same place.
4. partisan_rpc_backend is rex, permanently
It keeps exactly the operations whose semantics require a server, which are
exactly the ones OTP still routes through rex:
Over partisan_erpc — pure shim | Still on the backend — needs a server |
|---|---|
call/4,5 | block_call/4,5 |
cast/4,5 | sbcast/2,3 |
multicall/3,4,5 | abcast/2,3 |
async_call/4,5 + yield/1, nb_yield/1,2 | eval_everywhere/3,4 |
Its inline-apply behaviour is retained deliberately for block_call and
documented as intentional. partisan_rpc is not deprecated: OTP has not
formally deprecated rpc, and it remains the target of the
rpc => partisan_rpc rewrite that existing code depends on. It is documented as
the legacy surface, with new code steered to partisan_erpc — mirroring the
guidance OTP carries in its own source rather than inventing a stricter policy.
5. Inbound concurrency is bounded
Spawning per request without a bound converts a throughput bug into a
denial-of-service vector. OTP tolerates the unbounded form because the
distribution buffer applies backpressure; Partisan has no equivalent, so the
bound is enforced at the receiver: a per-node in-flight cap
(rpc_max_concurrency, registered in partisan_config's defaults), surfaced via
telemetry, releasing each slot on the worker's DOWN.
Over-cap requests are rejected outright rather than queued. A queue with no
credit scheme is an unbounded mailbox with extra steps; rejection is a bound.
The rejection is shaped so the caller raises error({partisan_erpc, overloaded})
and partisan_rpc translates it to {badrpc, {'EXIT', overloaded}} through the
same path as any other failure.
6. Per-call transport options on every surface
Upstream erpc has no notion of channels, so on its API every request would ride
the globally configured forward_options with no way to override it. Since RPC
traffic is exactly the kind that benefits from a channel of its own — a slow bulk
RPC should not sit behind or ahead of membership gossip — every surface accepts
forward_opts():
call/5andmulticall/5takeforward_opts()in place of a bare timeout, which is read out of the map;send_request/5,send_request/7,cast/5andmulticast/5are additional arities that do not exist upstream, because those shapes have no argument to widen.
Export parity with erpc is unaffected: the module need only be a superset,
which a test now asserts on every OTP version in the CI matrix.
Per-call options win over the global configuration; the global value fills in
only what the caller omitted. This is stated explicitly because the previous code
had the precedence inverted — it read
partisan_config:get(forward_options, CallerOpts), which makes the caller's
options a mere fallback, so every per-call channel and partition_key was
silently discarded the moment anything set the global. One resolver
(partisan_rpc:forward_opts/1) now serves both RPC surfaces, so the precedence
cannot drift between them.
7. Rolling upgrade
The pre-6.0.0 framing is retained as a receiver only, for the whole of the 6.x series, and removed in 7.0.0:
- a 5.x caller reaching a 6.x node uses the legacy clause and gets the answer it always did — now with the concurrency bound applied, since a cap any un-upgraded caller can walk past is not a cap;
- a 6.x caller reaching a 5.x node cannot use the correlated protocol, because a
5.x server has no clause for it and discards the message; the caller observes a
timeout. Every node must be upgraded before
partisan_erpcis relied on between them.
That asymmetry is the point: the legacy clause makes a partially-upgraded cluster work for the callers that already exist, rather than making both directions work for callers that do not yet.
Rationale
Why not simply spawn inside the existing framing? It captures most of the throughput win in about fifteen lines, and fixes nothing else. The framing has no request id, so late replies stay mis-attributable and a caller still cannot hold several requests outstanding. Correlation is what the collection API needs, and it cannot be retrofitted onto a protocol with nowhere to put an id.
Why is partisan_erpc the primary surface rather than the compatibility
layer? Because that is what OTP code calls. rpc is the legacy surface in OTP
too; erpc is where new code goes. Treating partisan_erpc as an afterthought
would have left the main path broken while polishing the side path.
Why keep a server at all? Because three operations are defined in terms of
one. block_call means "serialised on the server". sbcast must report
whether a name was registered on each node. abcast and eval_everywhere are
node-wide fan-outs with no per-request reply. Deleting the server would mean
reimplementing it under another name.
Alternatives considered
- Delete
partisan_rpc_backendentirely. Rejected: it isrex, and OTP keepsrexfor the same four operations. The module is not the problem; the routing was. - Deprecate
partisan_rpc. Rejected: OTP has not deprecatedrpc, and therpc => partisan_rpcrewrite is what existing code depends on. Documenting it as legacy achieves the steer without breaking anything. - Add
erpc => partisan_erpcto the compile-time rewrite map. Rejected: the map applies only to OTP-derived modules and generated test suites, never to user code, which the user-facing transform touches only for!. The entry cannot help user code, and it breaks the generated suites, which useerpc:call/4as their own scaffolding against peer nodes that never start the Partisan application. Code wanting Partisan-transportederpccallspartisan_erpcdirectly. - Carry results on monitor exit reasons, as upstream does. Rejected: in
Partisan every remote
DOWNis relayed through onepartisan_monitorprocess per node, so this would replace a single-process bottleneck with a less-visible one. - Queue over-cap requests instead of rejecting them. Rejected until real backpressure exists. A queue with no credit scheme is an unbounded mailbox.
Consequences
- A node's RPC capacity is no longer one process deep. A slow RPC delays only itself.
- The head-of-line result is measured. With a 60-second RPC held open on the
target for a whole run, the fast callers' p99 sits between the unobstructed
one-caller and eight-caller figures — the slow call is invisible in the tail.
Throughput also scales with callers rather than flattening. Both are recorded
in
bench/BASELINE.md. Every other claim here is structural: work that was serialised through one process no longer is. - A crashing RPC is no longer a supervision event, and late replies are dropped rather than mis-attributed on both the single and collection surfaces.
partisan_erpcis exercised for the first time. It had never been loaded by a test; it now has local coverage plus a cross-node case, all withconnect_disterldisabled so nothing can pass by falling back to distribution.- An un-upgraded peer's RPCs are bounded too, which is a behaviour change for
5.x callers against a saturated 6.x node: they receive
{badrpc, overloaded}where previously the node would have kept spawning. - The surface is wider than
erpc's by four arities and two overloads — a maintenance cost paid deliberately, since without it the primary RPC API cannot select a channel.
Related records
- ADR-000002 — established the pluggable-behaviour pattern and, more
relevantly here, the practice of extracting a seam only where the existing
implementation already justifies it. The
rex/erpcsplit in §4 is the same judgement applied to a boundary OTP had already drawn. - ADR-000003 — removed a different single-process fan-out bottleneck (the
gen_eventmembership bus). The reasoning is the same shape: a process that serialises work with no semantic reason to serialise it.
References
- OTP
kernel/src/rpc.erl— therpc-on-erpcarrangement mirrored in §4, including the?RPCIFYmacro andrpcify_exception/2vendored verbatim, and the inline guidance steering readers toerpc. - OTP
kernel/src/erpc.erl— the pure logic vendored in §3, and the API surface §6 must remain a superset of. erlang:alias/1anderlang:unalias/1— one-shot reply addresses, the basis for the correlation reference in §1.