The repository contains two main components located in cmd/protoc-gen-gorums:
- dev: Contains static code and code generated by templates.
- gengorums: Contains the code for the
protoc-gen-gorumscompiler, as well as templates.
-
Template Changes: Changes to generated code in the
dev/zorums_*_gorums.pb.gofiles should be done by editing the various templates in the correspondinggengorums/template_*.gofile. Making changes to thezorums*files will be overwritten. Note that there is not a one-to-one correspondence for all files. -
Static Code File Changes: Any
.gofile indev/whose name does not start withzorumsand does not end in_test.gois treated as static code and bundled intotemplate_static.go. Currently,aliases.goholds the only static declarations, but additional files can be added to thedev/directory and they will be included automatically.
The generator writes the static code of the dev/ package verbatim into one file per Go package, named gorums.pb.go.
Every protoc run that generates Gorums code for a Go package writes this file.
The generator reports an error if protoc-gen-go writes a file with the same path for a .proto file of the package, such as gorums.proto in the same directory.
It goes in the output directory of the first file of the package that the run generates Gorums code for, in protoc's file order.
The path is path.Dir(prefix) + "/gorums.pb.go", where prefix is that file's GeneratedFilenamePrefix, so the output options of protoc, such as paths=source_relative and module=, apply to it.
The content depends only on the Go package name and the versions of protoc-gen-gorums and protoc.
Thus, separate runs for the files of one Go package write the same file.
The _gorums.pb.go files hold no static code.
In dev mode, the generator writes no gorums.pb.go file, because dev/aliases.go declares the static code.
The static code currently consists of four type aliases from dev/aliases.go:
type (
Config = gorums.Config
ID = gorums.ID
Node = gorums.Node
Correctable[Resp any] = gorums.Correctable[Resp]
)These aliases make the most commonly used Gorums types available as pb.Config, pb.ID, *pb.Node, and *pb.Correctable[*pb.X].
Application code and the generated clients use them.
The generated client constructors take a Config or a *Node, and the client structs hold these values.
Thus, a _gorums.pb.go file depends on the aliases in the gorums.pb.go file of its package.
The other generated code refers to the gorums types directly, such as *gorums.Call and *gorums.OnewayCall.
Because the static code appears once per Go package, it may declare package-level names.
Each exported package-level name of the static code becomes a reserved identifier.
The bundler (gengorums/bundle.go) discovers these names by inspecting TypesInfo.Defs for all exported, package-scope declarations in the dev package.
Thus, declaring an exported top-level name in aliases.go is sufficient to reserve that name.
The Gorums generator rejects a proto file whose messages, enums, enum values, or oneof wrappers use a reserved identifier as their Go name.
Such a name collides with the generated alias.
The check includes nested messages and enums, and every file of the Go package.
For a oneof wrapper, the check tests both the name of the open API and the name of the opaque API, which starts with a lowercase letter.
goPackage.checkReservedIdents in gengorums/package.go runs the check, and shouldGenerate in gengorums/generate.go calls it for each file with Gorums methods.
RPC methods are methods of the generated client interfaces, so they do not collide with the aliases; Generated Client Interfaces describes the name checks for the generated client and server names.
The .proto files of a Go package may be compiled in one protoc run or in separate runs.
Separate runs write the same gorums.pb.go, so the static code is declared once.
The _gorums.pb.go files declare only the names of their own services.
The TestBundleReservedIdentifiers test in gengorums/bundle_test.go pins the expected set.
If you add or remove a static declaration, update that test to match.
The $(static_file) rule of the Makefile runs the bundler when a static file changes.
The rule first copies template_static.go to template_static.go.bak.
It then runs protoc-gen-gorums --bundle=<file>, which calls GenerateBundleFile in gengorums/bundle.go.
The bundler loads the static files of dev/ and writes three variables into template_static.go:
staticCodeholds the text of the static declarations, without the package and import clauses.reservedIdentsholds the reserved identifiers.pkgIdentMapmaps the import path of each package that the static code uses to one exported identifier of that package.
generatePackageFile in gengorums/generate.go writes staticCode into gorums.pb.go.
It then qualifies each identifier of pkgIdentMap, so the file gets the imports that the static code needs.
If the bundle changes, the bundler prints the diff for inspection and a command to revert the change.
A Go package may hold several services, in one .proto file or in several.
For each service S, the generator emits up to two client interfaces, as gRPC does.
The call type of each RPC method decides which client the method belongs to:
| Client | Constructor | Implementing struct | Call types |
|---|---|---|---|
SClient |
NewSClient(config Config) SClient |
sClient |
quorum call, multicast |
SNodeClient |
NewSNodeClient(node *Node) SNodeClient |
sNodeClient |
remote call, unicast |
A service with only one kind of call gets only that client.
The struct name is an unexported variant of the interface name.
sClient holds a Config, and sNodeClient holds a *Node.
Config and Node are the package's own aliases from gorums.pb.go.
The structs have value receivers.
The clients have no helper methods.
Every method has the same shape (ctx, in), and calls one runtime constructor:
| Call type | Result | Runtime constructor |
|---|---|---|
| quorum call | *gorums.Call |
gorumsimpl.QuorumCall(ctx, c.config, in, method) |
quorum call with a stream response |
*gorums.Call |
gorumsimpl.QuorumCallStream(ctx, c.config, in, method) |
| multicast | *gorums.OnewayCall |
gorumsimpl.Multicast(ctx, c.config, in, method) |
| remote call | (*Out, error) |
gorumsimpl.RemoteCall(ctx, c.node, in, method) |
| unicast | *gorums.OnewayCall |
gorumsimpl.Unicast(ctx, c.node, in, method) |
flowchart LR
gen["generated method<br/>c.ReadQC(ctx, in)"] --> rt["runtime/gorumsimpl<br/>QuorumCall(ctx, c.config, in, method)"]
rt --> impl["internal/impl<br/>QuorumCall(ctx, config, in, method)"]
A method signature is written once, for both the interface and the struct. godoc shows the doc comments of the interface methods.
The server interface SServer and the function RegisterSServer come from gengorums/template_server.go.
In dev mode, the generator writes one dev/zorums_{name}_gorums.pb.go file for the client interfaces, for the server interface, and for each entry of gorumsCallTypesInfo in gengorums/calltype.go:
| Dev file | Template | Contents |
|---|---|---|
zorums_client_gorums.pb.go |
template_client.go |
client interfaces, constructors, and structs |
zorums_quorumcall_gorums.pb.go |
template_quorumcall.go |
quorum call and streaming quorum call methods |
zorums_multicast_gorums.pb.go |
template_multicast.go |
multicast methods |
zorums_remotecall_gorums.pb.go |
template_remotecall.go |
remote call methods |
zorums_unicast_gorums.pb.go |
template_unicast.go |
unicast methods |
zorums_server_gorums.pb.go |
template_server.go |
server interface and handler registration |
The client file holds the interface declarations of all call types. Each call type file holds the implementing methods of that call type.
Outside dev mode, all of this code goes into a single _gorums.pb.go file, and the static code goes into the package's gorums.pb.go file.
Any changes to templates or static code requires the invocation of make in order to:
- Bundle the static code into
template_static.go. - Rebuild the
protoc-gen-gorumscompiler. - Update the
zorums_*_gorums.pb.gofiles.
A compile error in a generated zorums* file does not stop the bundler.
The bundler reads the static files only.
make dev can regenerate the generated files after such an error.
A compile error in a static file still stops the bundle.
The bundle is the text of those files, so they have to compile.
See the Makefile for more details.
To force compile, e.g. following a protoc update, you can use:
make -BTesting the protoc-gen-gorums compiler itself can be done by running the command below.
These tests verify that the code generated by the Gorums compiler is correct and stable.
By default, tests use in-memory bufconn connections:
make testFor end-to-end validation with real TCP connections:
make integrationtestOr directly:
go test -tags=integration ./...The tests in internal/testprotos run protoc-gen-go and protoc-gen-gorums in process, through the internal/gentest harness.
They test the generator in this tree, not a protoc-gen-gorums binary installed on PATH, so they need no make installgorums.
Only parsing uses protoc.
The tests write nothing into the tree: gentest.Compile type-checks the generated code from memory.
| Test | Checks |
|---|---|
TestFailingProtoFiles |
Proto files written inline in the test fail with the expected generator error. |
TestFailingProtoFilesPackageFile |
A proto file whose .pb.go path is the package's gorums.pb.go path fails. |
TestGenerateProtoFiles |
The code generated for each fixture under internal/testprotos compiles. |
TestGenerateImportedPackageNames |
Generated code compiles with message packages named c, ctx, in, or node. |
TestGoldenProtoFiles |
The Gorums code for each fixture equals its golden file in testdata. |
TestGeneratePackageFiles |
Several files of one Go package compile, with one gorums.pb.go per package. |
TestGeneratePackageFilesSeparateRuns |
Separate runs for one Go package write the same gorums.pb.go and compile. |
TestStabilityZorums |
Two runs on dev/zorums.proto produce identical output. |
TestStabilityDevFilesUpToDate |
The committed dev/zorums*.pb.go files equal the output of the generator. |
To add a failing case, add a table entry to TestFailingProtoFiles.
To add a fixture, add a .proto file in a new directory under internal/testprotos, with a go_package that matches the directory, and create its golden files as shown below.
Each fixture directory with Gorums output has two golden files: X_gorums.pb.go.golden and gorums.pb.go.golden.
After an intended change to a template, refresh the golden files and the dev files, and review both diffs:
make golden
make devGolden files replace the tool versions in the header with VERSION, so a release or a new protoc does not change them.
An error string describes the operation that was attempted, not the failure.
Wrapped errors then read like prose: dialing node 3: creating stream: connection refused.
- Do not start an error with
failed to,could not,unable to, orerror. Writecreating node %d: %w, notfailed to create node %d: %w. - An error that wraps nothing names the condition:
no nodes in configuration. - A precondition has the form
cannot X without Y. - Do not add an annotation that only says something failed; return the error unchanged.
- Use a lowercase first letter, no trailing punctuation, and no newlines.
Sentinel errors (
ErrXxx) name the condition. - Only the layer that creates a Gorums error adds the
gorums:prefix. A caller that wraps such an error does not add it again. - Messages that an application end user reads directly, such as
protoc-gen-gorumsand command-line diagnostics, may keep explicit failure wording.
TestErrorStringsDescribeOperation in internal/lint rejects the banned prefixes in errors.New and fmt.Errorf calls.
See benchmarking.md
Below is a description of the current Makefile targets.
The Makefile itself also serves as documentation; inspect it for details.
| Target | Description |
|---|---|
all |
Builds dev, benchmark, and compiles tests (default target). |
dev |
Updates template_static.go and regenerates generated files from templates. |
genproto |
Force-regenerates all protobuf and Gorums files across the repo (dev, benchmark, tests, examples). |
benchmark |
Compiles the benchmark tool. |
benchkit |
Regenerates the protobuf and Gorums files of the benchkit module. |
sweep |
Builds the sweep command in benchkit/cmd/sweep. |
compiletests |
Compiles test protos in internal/tests. |
tools |
Installs required tools (protoc-gen-go, protoc-gen-go-grpc, stress, etc.) via go install tool. |
installgorums |
Reinstalls the protoc-gen-gorums plugin. |
bootstrapgorums |
Bootstraps the plugin when it is not yet installed. |
test |
Runs all tests (using bufconn by default). |
integrationtest |
Runs integration tests with real TCP connections. |
testrace |
Runs tests with the race detector enabled. |
benchtest |
Validates that benchmarks compile and run without error (short runs). |
bench |
Runs benchmarks with proper measurement for performance analysis. |
golden |
Rewrites the generator golden files in internal/testprotos/testdata after a template change. |
stresstest |
Runs the ordering tests for a fixed duration (test flag: -stress) for thorough testing. |
stressdev |
Runs the tests of the dev package repeatedly with the stress tool. |
modernize |
Applies go fix and x/tools/modernize@latest across all workspace modules. |
goplscheck |
Fails on gopls diagnostics, including hint-level suggestions, in non-generated Go source. |
lint |
Runs golangci-lint with .golangci.yml on all workspace modules, as CI does. |
deadcode |
Lists functions unreachable from any main or test; advisory, and run by lint. |
Releases are made with the gorums-release program in internal/cmd/gorums-release.
The release guide describes how to install and use it.
The runtime is split into layers, each in its own package. The diagrams below show the main types of each layer and how they connect. They omit helper types and most fields and methods; the package doc comments and the source are authoritative.
Generated code reaches the call engine through runtime/gorumsimpl, and application code uses the types that the root gorums package re-exports as aliases.
Each internal package depends only on the packages below it.
flowchart TD
app["Application code"]
gen["Generated code<br/>(service clients, *_gorums.pb.go)"]
gorums["gorums<br/>(Server, type aliases)"]
gorumsimpl["runtime/gorumsimpl<br/>(call constructors)"]
impl["internal/impl<br/>(call engine)"]
conn["internal/conn<br/>(nodes, configurations, connectivity)"]
stream["internal/stream<br/>(channels, sessions, wire messages)"]
grpc["gRPC NodeStream<br/>(bidirectional stream)"]
app --> gen
app --> gorums
gen --> gorumsimpl
gen --> gorums
gorumsimpl --> impl
gorums -. "type aliases" .-> impl
gorums -. "type aliases" .-> conn
gorums --> stream
impl --> conn
impl --> stream
conn --> stream
stream --> grpc
A Transport is everything a call needs to reach one node.
It holds a channelRef, an atomically replaceable reference to the node's current Channel.
There are three channel kinds:
OutboundChannelruns over streams this node dials; it opens a newsessionfor each stream and requeues pending calls when a stream is lost, as far as the send queue has space. A stream that the server refuses is not lost: it ends with a refusal code (Unauthenticated,PermissionDenied,InvalidArgument, orUnimplemented) before the server accepted it. Its pending two-way and streaming calls fail with the refusal, as does a one-way call whose send fails on it. The channel then backs off before it opens the next stream, failing requests at once with the refusal until then. A stream that cannot be opened because of a refusal code is handled the same way, for example when the connection failed the dial-side identity check. A stream opened after a refusal is suspect for health reporting: its sends do not clearLastErruntil the server accepts it. The channel sends on a new stream at once, but the stream counts as up only once the server accepts it: when thegorums-acceptedresponse header or the first frame arrives. The receive goroutine of each session waits for the response headers before it starts receiving. A refused stream ends without thegorums-acceptedheader, although a server interceptor may have sent other headers. Only the channel's live session can bring the stream up, so a late acceptance of a session that a GOAWAY replaced has no effect.InboundChannelruns over oneInboundStreamthat the server accepted; its singlesessionends with the stream. AnInboundStreamis aBidiStreamthat can also send response headers, as a server stream can and a client stream cannot. Its send loop first sends thegorums-acceptedheader, before any message. A stream sends its response headers only once, so the header fails if a gRPC stream interceptor on the server has already sent them. The send loop then sends an empty acceptance frame instead. Message IDs start at 1, so the dialer drops that frame after it counts it as the server's first frame.LocalChannelserves requests in-process through the handler, with no stream.
The two stream channels embed an endpoint with the send queue, the request dispatcher, the optional RequestHandler, and the Latency estimate.
A session sends queued requests on its BidiStream, records two-way calls in pendingCalls, and routes received frames either to a pending call or to the dispatcher.
The dispatcher runs handlers one at a time, in arrival order.
A handler that returns without calling Release hands its goroutine to the next queued handler, so a busy stream keeps one goroutine and the stack it has grown; an early Release starts the next handler on a new goroutine.
On the server side, Server.NodeStream asks a PeerAcceptor for the InboundChannel of each accepted stream.
The stream layer does not check identities; the connection layer below does.
classDiagram
direction LR
class Channel {
<<interface>>
Enqueue(Request)
StreamUp() bool
PendingCount() int
Close() error
}
class RequestHandler {
<<interface>>
HandleRequest(ctx, senderID, msg, release, send)
}
class BidiStream {
<<interface>>
Send(msg) error
Recv() msg, error
}
class PeerAcceptor {
<<interface>>
AcceptPeer(ctx, stream) InboundChannel
}
class Transport {
id ID
shared bool
Enqueue(Request)
StoreChannel(Channel)
NextMsgID() uint64
}
class session {
sendLoop()
receive()
handle(msg)
}
class Request {
Ctx context.Context
Msg Message
ResponseChan
}
Transport --> channelRef : channel
Transport --> Latency
channelRef --> Channel : current
Channel <|.. OutboundChannel
Channel <|.. InboundChannel
Channel <|.. LocalChannel
OutboundChannel *-- endpoint : embeds
InboundChannel *-- endpoint : embeds
OutboundChannel o-- "0..*" session : one per dialed stream
InboundChannel *-- "1" session
LocalChannel --> RequestHandler
LocalChannel *-- dispatcher
session --> endpoint
session --> BidiStream
session *-- pendingCalls
endpoint *-- sendQueue
endpoint *-- dispatcher
endpoint --> RequestHandler
endpoint --> Latency
sendQueue o-- Request
pendingCalls o-- Request
Request --> Message
Server --> PeerAcceptor
PeerAcceptor ..> InboundChannel : returns
Only the receive loop of an outbound session learns whether the server refused its stream: when the server ends a stream, a concurrent Send returns io.EOF, and only Recv returns the server's status.
So a send that fails with io.EOF before the server accepted the stream only cancels the stream, and the receive loop disposes of the pending calls.
The session's handoff state ensures that exactly one goroutine disposes of them.
The session's verdict channel closes when the server accepts the stream or receiving ends; the run loop waits for it after a session ends, and then reads whether the server refused the stream.
A one-way send that fails this way also waits for the verdict, so that it fails with the refusal too.
If the acceptance header or a frame arrives after the failed send, the stream was accepted: receiving delivers the frames that the stream buffered, and retries the calls still pending when Recv fails.
stateDiagram-v2
[*] --> Fresh: stream opened
Fresh --> Accepted: acceptance header or first frame arrives
Fresh --> SendFailed: a send fails with io.EOF
Fresh --> Closed: Recv fails
SendFailed --> Accepted: a buffered header or frame arrives, so deliver the buffered frames
SendFailed --> Closed: Recv fails
Accepted --> Closed: Recv fails
Closed --> [*]
note right of Closed
From Fresh or SendFailed with a refusal code:
fail the pending calls with the refusal.
Otherwise: retry them.
end note
A Config is a slice of *Node, and each Node has a fixed stream.Transport.
An outboundManager creates and owns the nodes of the configurations built by NewConfig and Config.Extend.
NewConfig returns the manager's Close method as the close function; Config has no Close method, so only the creator of a pool can close it.
The manager's Close closes all its nodes and logs any node's close error, since a caller cannot act on it.
Config.Extend fails on a closed manager before it builds any node.
A closed manager also rejects new nodes under the same lock that guards its node list, so a node that races with Close cannot outlive it.
A server's InboundManager owns the nodes for its known peers and for the peer-capable clients that connect to it, and maintains the connected-peer and connected-client configurations.
It implements stream.PeerAcceptor, attaching each accepted stream to the matching node.
If a PeerVerifier is set, InboundManager checks each claimed peer ID.
It refuses unknown IDs.
For a known ID, it attaches the stream only after verification succeeds.
The outbound manager uses the same verifier for the configured peers that it dials.
It wraps the node's transport credentials in verifiedCredentials, which run the verifier right after each TLS handshake, with the handshake's AuthInfo in the context.
A failed check closes the connection before it carries any stream.
The handshake error carries codes.Unauthenticated, and gRPC passes that code on to the stream that the OutboundChannel tries to open.
The channel treats it as a refusal and waits before it opens the next stream.
gRPC's own connection backoff, which WithBackoff sets, decides when the next handshake runs.
A node with a verifier needs transport credentials for verifiedCredentials to wrap.
WithTransportCredentials sets them in DialOptions.Credentials.
Both managers implement nodeRegistry, which a NodeSource uses to build a configuration.
WithNodes captures each node's address and optional TLSIdentity together.
When a server uses that source through WithPeers, conn.NewPeerVerifier validates the identity set and creates the internal TLS verifier.
nodeMap.newConfig also builds a verifier and passes it to nodeRegistry.newNode, so NewConfig and Config.Extend check the servers they dial.
The InboundManager ignores that verifier and uses its own for its known peers.
Address-only sources yield no verifier, while mixed, empty, invalid, or duplicate identities are configuration errors.
classDiagram
direction LR
class Config {
<<slice>>
NodeIDs() []ID
Size() int
}
class Node {
id ID
addr string
IsShared() bool
}
class NodeSource {
<<interface>>
}
class nodeRegistry {
<<interface>>
Nodes() []*Node
}
class InboundManager {
AcceptPeer(ctx, stream)
ConnectedPeers() Config
ConnectedClients() Config
}
class StreamTransport["stream.Transport"]
class StreamInboundChannel["stream.InboundChannel"]
class StreamPeerAcceptor["stream.PeerAcceptor"] {
<<interface>>
}
class StreamRequestHandler["stream.RequestHandler"] {
<<interface>>
}
Config o-- "*" Node
Node --> StreamTransport : transport
Node --> outboundManager : mgr
Node o-- "0..*" StreamInboundChannel : liveChannels
outboundManager o-- "*" Node : nodes
outboundManager *-- DialOptions : opts
DialOptions --> InboundManager : InboundManager
DialOptions --> StreamRequestHandler : Handler
InboundManager o-- "*" Node : knownNodes, clientNodes
InboundManager --> Config : peer, connected, client configs
InboundManager --> StreamRequestHandler : handler
StreamPeerAcceptor <|.. InboundManager
nodeRegistry <|.. outboundManager
nodeRegistry <|.. InboundManager
NodeSource ..> nodeRegistry : builds Config with
A node's kind is set by the transport and channel it is created with.
Calls only ever see the node's transport; the channel behind the transport's channelRef may change as streams come and go.
- An outbound node dials its peer and owns an
OutboundChannel. - A local node is the server's own node in its peer configuration; it owns a
LocalChannelthat calls the server's handler in-process. - An inbound node is a known peer or a client of the server;
InboundManager.AcceptPeerstores anInboundChannelin its transport when the peer's stream arrives, and clears it when the last live stream ends. - A shared node is the higher-ID peer's outbound node under stream deduplication; its shared transport borrows the inbound peer node's
channelRef,Latency, and message-ID generator.
flowchart LR
subgraph outbound["Outbound node"]
N1[Node] --> T1[Transport] --> R1[channelRef] --> C1[OutboundChannel]
end
subgraph local["Local node"]
N2[Node] --> T2[Transport] --> R2[channelRef] --> C2[LocalChannel]
end
subgraph inbound["Inbound node"]
N3[Node] --> T3[Transport] --> R3[channelRef] --> C3[InboundChannel]
N3 -. liveChannels .-> C3
end
subgraph shared["Shared node (stream dedup)"]
N4[Node] --> T4["Transport<br/>(shared)"]
end
T4 -- borrows --> R3
A call constructor takes the call's context.Context and its target directly: a Config for quorum calls and multicasts, and a *Node for remote calls and unicasts.
On an empty Config, the CallContext holds ErrEmptyConfig: a quorum call sends nothing, its terminal methods return the error, and its response iterator yields one response that holds the error; Multicast returns a OnewayCall that sends nothing and returns the error from Send and Wait.
On a nil *Node, RemoteCall returns ErrNoNode, and Unicast returns a OnewayCall whose CallContext holds ErrNoNode and that sends nothing.
A quorum call constructor builds a CallContext that holds the request, the target Config, the response channel, and the response iterator (ResponseSeq).
Multicast and Unicast build a CallContext that holds the request and the target Config, but no response iterator.
A unicast's CallContext holds a one-node Config.
A one-way CallContext gets its response channel when Send or Async dispatches the call; the channel carries the local send confirmations.
RemoteCall builds no CallContext; it enqueues one request on the node's transport and waits for the response.
A quorum call returns a Call, which embeds Responses and its terminal methods (First, Majority, All, Threshold, Aggregate, Reduce, Matching), as well as the Correctable future.
Call.Async sends the requests at once and returns the dispatched Responses, so any terminal method collects the replies later.
A one-way call (Multicast, Unicast) returns a OnewayCall, whose Send waits for local send confirmations and whose Async returns a OnewayAsync.
A ClientInterceptor wraps the response iterator and may register per-node request transformations before the call is dispatched.
CallContext reaches each node's send path through conn.NodeTransport.
classDiagram
direction LR
class CallContext~Req, Resp~ {
request Req
method string
responseChan
sendNow()
}
class Call~Req, Resp~ {
Intercept(interceptors) Call
Async() Responses
}
class Responses~Resp~ {
First() Resp, error
Majority() Resp, error
All() Resp, error
Threshold(n) Resp, error
Aggregate(fn) Out, error
Reduce(n, initial, add) Acc, error
Matching(n, key) Agreement, error
Results() ResponseSeq
}
class OnewayCall~Req~ {
Send() error
Async() OnewayAsync
}
class OnewayAsync {
Wait() error
}
class ResponseSeq~Resp~ {
<<iterator>>
}
class ClientInterceptor~Req, Resp~ {
<<func>>
}
class Correctable~Resp~ {
Get() Resp, int, error
Watch(level)
}
class starter {
<<interface>>
sendNow()
markDispatched()
}
class ConnConfig["conn.Config"]
class StreamTransport["stream.Transport"]
Call *-- Responses : embeds
Call --> CallContext : ctx
OnewayCall --> CallContext : ctx
OnewayCall ..> OnewayAsync : Async()
Responses --> ResponseSeq : seq
Responses --> starter : start
starter <|.. CallContext
Call ..> Responses : Async()
Responses ..> Correctable : Correctable(n)
CallContext --> ConnConfig : config
CallContext --> ResponseSeq : responseSeq
ClientInterceptor ..> CallContext : wraps ResponseSeq
CallContext ..> StreamTransport : Enqueue via conn.NodeTransport
A quorum call is dispatched lazily, on the first iteration of its response iterator, so that interceptors can be registered first. Each node's channel sends on its own goroutine, and responses are matched to pending calls by message ID. The fan-out starts at a random node of the configuration and wraps around, so no node is always enqueued last. With a fixed order, the last node would wait longest on every call, and its measured latency would be higher than that of the other nodes. Each node still receives a caller's requests in call order.
sequenceDiagram
participant Gen as Generated method
participant Call as impl.Call
participant CC as impl.CallContext
participant T as stream.Transport
participant Ch as stream.OutboundChannel
participant S as stream.session
participant G as gRPC stream
Gen->>Call: QuorumCall(ctx, config, req, method)
Call->>CC: newQuorumCallContext
Gen->>Call: Majority()
Call->>CC: iterate ResponseSeq (sendNow)
loop each node in Config, from a random start
CC->>T: conn.NodeTransport(n).Enqueue(Request)
T->>Ch: Channel.Enqueue: push on sendQueue
end
Note over Ch,S: channel goroutine
S->>Ch: sendLoop takes Request from sendQueue
S->>S: pendingCalls.add(msgID, Request)
S->>G: Send(Message)
G-->>S: Recv() response Message
S->>S: handle: pendingCalls.take(msgID)
S-->>CC: Request.deliver to responseChan
CC-->>Call: ResponseSeq yields NodeResponse
Call-->>Gen: majority response or QuorumCallError
The root gorums.Server ties the layers together on the server side.
It registers a stream.Server with its gRPC server, uses its conn.InboundManager as the server's stream.PeerAcceptor, and serves as the stream.RequestHandler for inbound streams, for its local node, and for back-channel requests on its outbound peer configuration.
Server.HandleRequest creates a ServerContext per request, records the sender ID that the channel passes in, and runs the registered Handler, wrapped by any ServerInterceptors.
A gorums.Message wraps a request's stream.Message together with its decoded proto payload.
How a handler delivers its responses depends on the call type:
- A unary handler returns its response as a
proto.Message. - A streaming handler calls its
send func(proto.Message)callback once for each response, and returnsnil, nil. - A one-way handler returns
nil, nil, and the server sends no response.
Server.HandleRequest builds the response envelope for the returned response and for each sent response, so handlers never touch envelopes.
classDiagram
direction LR
class Server {
handlers map[string]Handler
RegisterHandler(method, Handler)
HandleRequest(ctx, senderID, msg, release, send)
ConnectedPeers() Config
ConnectedClients() Config
}
class ServerContext {
SenderID() ID
Release()
PeerConfig() Config
ConnectedClients() Config
}
class Handler {
<<func(ServerContext, *Message, func(proto.Message)) (proto.Message, error)>>
}
class ServerInterceptor {
<<func>>
}
class Message {
Proto proto.Message
}
class GRPCServer["grpc.Server"]
class StreamServer["stream.Server"]
class StreamMessage["stream.Message"]
class InboundManager["conn.InboundManager"]
class ConnConfig["conn.Config"]
class StreamRequestHandler["stream.RequestHandler"] {
<<interface>>
}
Server --> GRPCServer : grpcServer
Server --> StreamServer : srv
Server --> InboundManager : im
Server --> ConnConfig : outbound, with WithPeers
Server o-- "*" Handler : handlers
Server o-- "*" ServerInterceptor : interceptors
StreamRequestHandler <|.. Server
StreamServer --> InboundManager : acceptor
InboundManager --> Server : handler
Server ..> ServerContext : creates per request
ServerContext --> Server : srv
Message *-- StreamMessage : embeds
Requests that arrive on one stream run through the handler one at a time until the handler calls ServerContext.Release or returns.
A handler's reply is queued on the stream's own channel and sent by its send loop.
sequenceDiagram
participant P as Remote peer
participant SS as stream.Server
participant IM as conn.InboundManager
participant N as conn.Node
participant IC as stream.InboundChannel
participant D as dispatcher
participant H as gorums.Server
P->>SS: open NodeStream
SS->>IM: AcceptPeer(ctx, stream)
IM->>IM: verify(ctx, claimed ID) if a PeerVerifier is set
IM->>N: attachStream: NewInboundChannel
N->>N: Transport.StoreChannel(ch)
IM-->>SS: InboundChannel, cleanup
SS->>IC: Serve(): receive loop
P->>IC: request Message
IC->>D: push handler job
D->>H: HandleRequest(ctx, senderID, msg, release, send)
H->>H: Handler(ServerContext, Message, send)
H->>IC: send(reply): push on sendQueue
IC->>P: Send(reply) on send loop
Symmetric stream deduplication makes the lower-ID peer of each pair the only dialer.
The higher-ID peer's outbound node is born shared: it borrows the matching inbound transport when the configuration is created, before the peer has connected.
The borrower cannot dial through a shared transport, so its calls fail with gorums.ErrStreamDown while the dialer's stream is unavailable; callers match it with errors.Is.
The dialer re-establishes a lost stream eagerly with capped backoff from its channel's goroutine, independently of its own sends.
Client-initiated message IDs use the low 63 bits, while server-initiated and shared-call IDs have bit 63 set. A session uses this bit to route each received frame. A frame in the peer's ID space is a request for the handler. A frame in the session's own ID space is a response to one of its pending calls. A shared call travels on the stream that the lower-ID peer dialed, as a server-initiated request. So it takes its ID from the generator of the inbound peer node, which sets bit 63, and the dialer's session passes it to the handler. The dialer's own calls on that stream keep their client-initiated IDs, so the calls of the two peers never collide on one stream. These IDs encode as ten-byte protobuf varints because the high bit is set; the small wire-cost increase is intentional and avoids a second stream.
Each session owns the pendingCalls of its stream, so ending a session affects only the calls sent on that stream.
When an outbound session ends, it requeues its pending two-way calls on the channel's send queue for the next session.
A call fails instead if the server refused the stream, if the stream ended with ResourceExhausted, if the send queue is full, or if the channel is closed.
A pending streaming call is not requeued; it fails with ErrStreamDown, or with ErrNodeClosed if the channel is closed.
An inbound session has no next session, so its pending calls fail with ErrStreamDown when it ends.
These include the calls of a shared node that borrows the inbound stream.
Closing a channel ends its sessions and fails the requests in its send queue.
Node identity and its transport are fixed at construction; only the channel behind the transport's channelRef changes as streams come and go.
A call therefore reads the node's message-ID generator and current channel directly from that fixed transport, with no per-call binding step.
Every session handles a received request the same way, on inbound and outbound streams alike.
It appends the message's metadata to the incoming context and pushes the handler call to the endpoint's dispatcher, which runs handlers one at a time, in arrival order.
So a back-channel request on a stream that this node dialed is served like a request on an inbound stream.