Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ Use integration mode for performance benchmarking and network-specific validatio
Gorums provides custom protobuf options defined in `gorums.proto`:

- Method-level options for quorum call types
- Configuration options for RPC behavior
- Config options for RPC behavior
- See `doc/user-guide.md` for details

## Documentation
Expand Down
File renamed without changes.
12 changes: 6 additions & 6 deletions client_interceptor_test.go → call_client_interceptor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (

// LoggingInterceptor is a custom interceptor that logs each response.
func LoggingInterceptor[Req, Resp proto.Message](
ctx *gorums.ClientCtx[Req, Resp],
ctx *gorums.CallContext[Req, Resp],
next gorums.ResponseSeq[Resp],
) gorums.ResponseSeq[Resp] {
_ = ctx.Method() // Access method name (could be used for logging)
Expand All @@ -31,8 +31,8 @@ func LoggingInterceptor[Req, Resp proto.Message](
// FilterInterceptor returns an interceptor that filters responses based on a predicate.
func FilterInterceptor[Req, Resp proto.Message](
keep func(resp gorums.NodeResponse[Resp]) bool,
) gorums.QuorumInterceptor[Req, Resp] {
return func(ctx *gorums.ClientCtx[Req, Resp], next gorums.ResponseSeq[Resp]) gorums.ResponseSeq[Resp] {
) gorums.ClientInterceptor[Req, Resp] {
return func(ctx *gorums.CallContext[Req, Resp], next gorums.ResponseSeq[Resp]) gorums.ResponseSeq[Resp] {
_ = ctx.Method() // Access method name (could be used for filtering)
return func(yield func(gorums.NodeResponse[Resp]) bool) {
for resp := range next {
Expand All @@ -49,8 +49,8 @@ func FilterInterceptor[Req, Resp proto.Message](
// CountingInterceptor counts the number of responses passing through.
func CountingInterceptor[Req, Resp proto.Message](
counter *int,
) gorums.QuorumInterceptor[Req, Resp] {
return func(_ *gorums.ClientCtx[Req, Resp], next gorums.ResponseSeq[Resp]) gorums.ResponseSeq[Resp] {
) gorums.ClientInterceptor[Req, Resp] {
return func(_ *gorums.CallContext[Req, Resp], next gorums.ResponseSeq[Resp]) gorums.ResponseSeq[Resp] {
return func(yield func(gorums.NodeResponse[Resp]) bool) {
for resp := range next {
*counter++
Expand Down Expand Up @@ -161,7 +161,7 @@ func TestCustomInterceptorWithMapRequest(t *testing.T) {
CountingInterceptor[*pb.StringValue, *pb.StringValue](&count),
// Built-in: transform request (identity transform for this test)
gorums.MapRequest[*pb.StringValue, *pb.StringValue](
func(req *pb.StringValue, node *gorums.Node) *pb.StringValue {
func(req *pb.StringValue, _ *gorums.Node) *pb.StringValue {
return req
},
),
Expand Down
80 changes: 40 additions & 40 deletions client_interceptor.go → call_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,21 @@ import (
"google.golang.org/protobuf/types/known/emptypb"
)

// QuorumInterceptor intercepts and processes quorum calls, allowing modification of
// ClientInterceptor intercepts and processes quorum calls, allowing modification of
// requests, responses, and aggregation logic. Interceptors can be chained together.
//
// Type parameters:
// - Req: The request message type sent to nodes
// - Resp: The response message type from individual nodes
//
// The interceptor receives the ClientCtx for metadata access, the current response
// The interceptor receives the CallContext for metadata access, the current response
// iterator (next), and returns a new response iterator. This pattern allows
// interceptors to wrap the response stream with custom logic.
//
// Custom interceptors can be created like this:
//
// func LoggingInterceptor[Req, Resp proto.Message](
// ctx *gorums.ClientCtx[Req, Resp],
// ctx *gorums.CallContext[Req, Resp],
// next gorums.ResponseSeq[Resp],
// ) gorums.ResponseSeq[Resp] {
// return func(yield func(gorums.NodeResponse[Resp]) bool) {
Expand All @@ -34,13 +34,13 @@ import (
// }
// }
// }
type QuorumInterceptor[Req, Resp msg] func(ctx *ClientCtx[Req, Resp], next ResponseSeq[Resp]) ResponseSeq[Resp]
type ClientInterceptor[Req, Resp msg] func(ctx *CallContext[Req, Resp], next ResponseSeq[Resp]) ResponseSeq[Resp]

// ClientCtx provides context and access to the quorum call state for interceptors.
// CallContext provides context and access to the quorum call state for interceptors.
// It exposes the request, configuration, metadata about the call, and the response iterator.
type ClientCtx[Req, Resp msg] struct {
type CallContext[Req, Resp msg] struct {
context.Context
config Configuration
config Config
request Req
method string
msgID uint64
Expand All @@ -66,25 +66,25 @@ type ClientCtx[Req, Resp msg] struct {
}

// sendNow triggers request dispatch exactly once.
func (c *ClientCtx[Req, Resp]) sendNow() {
func (c *CallContext[Req, Resp]) sendNow() {
c.sendOnce.Do(c.send)
}

// newQuorumCallClientCtx constructs a ClientCtx for quorum calls (two-way, always returns responses).
// newQuorumCallContext constructs a CallContext for quorum calls (two-way, always returns responses).
// A reply channel is always created; streaming controls both its buffer size and the response iterator type.
func newQuorumCallClientCtx[Req, Resp msg](
func newQuorumCallContext[Req, Resp msg](
ctx *ConfigContext,
req Req,
method string,
streaming bool,
interceptors []any,
) *ClientCtx[Req, Resp] {
config := ctx.Configuration()
) *CallContext[Req, Resp] {
config := ctx.Config()
n := config.Size()
if streaming {
n *= 10
}
clientCtx := &ClientCtx[Req, Resp]{
clientCtx := &CallContext[Req, Resp]{
Context: ctx,
config: config,
request: req,
Expand All @@ -102,22 +102,22 @@ func newQuorumCallClientCtx[Req, Resp msg](
return clientCtx
}

// newMulticastClientCtx constructs a ClientCtx for multicast (one-way, no responses).
// newMulticastCallContext constructs a CallContext for multicast (one-way, no responses).
// A reply channel is created only when waitForSend=true (blocking send); fire-and-forget
// calls receive a nil channel, meaning no router entry is registered.
func newMulticastClientCtx[Req msg](
func newMulticastCallContext[Req msg](
ctx *ConfigContext,
req Req,
method string,
waitForSend bool,
interceptors []any,
) *ClientCtx[Req, *emptypb.Empty] {
config := ctx.Configuration()
) *CallContext[Req, *emptypb.Empty] {
config := ctx.Config()
var replyChan chan NodeResponse[*stream.Message]
if waitForSend {
replyChan = make(chan NodeResponse[*stream.Message], config.Size())
}
clientCtx := &ClientCtx[Req, *emptypb.Empty]{
clientCtx := &CallContext[Req, *emptypb.Empty]{
Context: ctx,
config: config,
request: req,
Expand All @@ -132,31 +132,31 @@ func newMulticastClientCtx[Req msg](
}

// -------------------------------------------------------------------------
// ClientCtx Methods
// CallContext Methods
// -------------------------------------------------------------------------

// Request returns the original request message for this quorum call.
func (c *ClientCtx[Req, Resp]) Request() Req {
func (c *CallContext[Req, Resp]) Request() Req {
return c.request
}

// Config returns the configuration (set of nodes) for this quorum call.
func (c *ClientCtx[Req, Resp]) Config() Configuration {
func (c *CallContext[Req, Resp]) Config() Config {
return c.config
}

// Method returns the name of the RPC method being called.
func (c *ClientCtx[Req, Resp]) Method() string {
func (c *CallContext[Req, Resp]) Method() string {
return c.method
}

// Nodes returns the slice of nodes in this configuration.
func (c *ClientCtx[Req, Resp]) Nodes() []*Node {
func (c *CallContext[Req, Resp]) Nodes() []*Node {
return c.config.Nodes()
}

// Node returns the node with the given ID.
func (c *ClientCtx[Req, Resp]) Node(id uint32) *Node {
func (c *CallContext[Req, Resp]) Node(id uint32) *Node {
nodes := c.config.Nodes()
index := slices.IndexFunc(nodes, func(n *Node) bool {
return n.ID() == id
Expand All @@ -168,21 +168,21 @@ func (c *ClientCtx[Req, Resp]) Node(id uint32) *Node {
}

// Size returns the number of nodes in this configuration.
func (c *ClientCtx[Req, Resp]) Size() int {
func (c *CallContext[Req, Resp]) Size() int {
return c.config.Size()
}

// reportNodeError sends an error response for the given node to replyChan.
// It is a no-op for fire-and-forget calls where replyChan is nil.
func (c *ClientCtx[Req, Resp]) reportNodeError(nodeID uint32, err error) {
func (c *CallContext[Req, Resp]) reportNodeError(nodeID uint32, err error) {
if c.replyChan != nil {
c.replyChan <- NodeResponse[*stream.Message]{NodeID: nodeID, Err: err}
}
}

// enqueue sends a stream.Request to the given node, populating the shared
// fields from ClientCtx so call sites only need to supply the message.
func (c *ClientCtx[Req, Resp]) enqueue(n *Node, msg *stream.Message) {
// fields from CallContext so call sites only need to supply the message.
func (c *CallContext[Req, Resp]) enqueue(n *Node, msg *stream.Message) {
n.Enqueue(stream.Request{
Ctx: c.Context,
Msg: msg,
Expand All @@ -195,10 +195,10 @@ func (c *ClientCtx[Req, Resp]) enqueue(n *Node, msg *stream.Message) {
// applyInterceptors chains the given interceptors, wrapping the response sequence.
// Each interceptor receives the current response sequence and returns a new one.
// Interceptors are applied in order, with each wrapping the previous result.
func (c *ClientCtx[Req, Resp]) applyInterceptors(interceptors []any) {
func (c *CallContext[Req, Resp]) applyInterceptors(interceptors []any) {
responseSeq := c.responseSeq
for _, ic := range interceptors {
interceptor := ic.(QuorumInterceptor[Req, Resp])
interceptor := ic.(ClientInterceptor[Req, Resp])
responseSeq = interceptor(c, responseSeq)
}
c.responseSeq = responseSeq
Expand All @@ -207,7 +207,7 @@ func (c *ClientCtx[Req, Resp]) applyInterceptors(interceptors []any) {
// send dispatches requests to all nodes. It delegates to sendWithPerNodeTransformation
// if any per-node request transformations are registered. Otherwise, it uses sendShared
// to marshal the request once and send the same message to all nodes.
func (c *ClientCtx[Req, Resp]) send() {
func (c *CallContext[Req, Resp]) send() {
if len(c.reqTransforms) == 0 {
c.sendShared()
} else {
Expand All @@ -217,7 +217,7 @@ func (c *ClientCtx[Req, Resp]) send() {

// sendShared marshals the request once and enqueues the shared message to all nodes.
// On marshal error, it reports the error to every node and returns early.
func (c *ClientCtx[Req, Resp]) sendShared() {
func (c *CallContext[Req, Resp]) sendShared() {
sharedMsg, err := stream.NewMessage(c.Context, c.msgID, c.method, c.request)
if err != nil {
// Marshaling fails identically for all nodes; report and return.
Expand All @@ -233,7 +233,7 @@ func (c *ClientCtx[Req, Resp]) sendShared() {

// sendWithPerNodeTransformation applies per-node request transformations before
// marshaling and enqueues each individually transformed message to its node.
func (c *ClientCtx[Req, Resp]) sendWithPerNodeTransformation() {
func (c *CallContext[Req, Resp]) sendWithPerNodeTransformation() {
for _, n := range c.config {
streamMsg := c.transformAndMarshal(n)
if streamMsg == nil {
Expand All @@ -246,7 +246,7 @@ func (c *ClientCtx[Req, Resp]) sendWithPerNodeTransformation() {
// transformAndMarshal applies transformations to the request for the given node,
// then marshals it into a stream.Message. Returns nil if transformation fails
// or marshaling fails (in which case the error is reported via reportNodeError).
func (c *ClientCtx[Req, Resp]) transformAndMarshal(n *Node) *stream.Message {
func (c *CallContext[Req, Resp]) transformAndMarshal(n *Node) *stream.Message {
transformedRequest := c.request
for _, transform := range c.reqTransforms {
transformedRequest = transform(transformedRequest, n)
Expand All @@ -266,7 +266,7 @@ func (c *ClientCtx[Req, Resp]) transformAndMarshal(n *Node) *stream.Message {

// defaultResponseSeq returns an iterator that yields at most c.expectedReplies responses
// from nodes until the context is canceled or all expected responses are received.
func (c *ClientCtx[Req, Resp]) defaultResponseSeq() ResponseSeq[Resp] {
func (c *CallContext[Req, Resp]) defaultResponseSeq() ResponseSeq[Resp] {
return func(yield func(NodeResponse[Resp]) bool) {
// Trigger sending on first iteration
c.sendNow()
Expand All @@ -286,7 +286,7 @@ func (c *ClientCtx[Req, Resp]) defaultResponseSeq() ResponseSeq[Resp] {

// streamingResponseSeq returns an iterator that yields responses as they arrive
// from nodes until the context is canceled or breaking from the range loop.
func (c *ClientCtx[Req, Resp]) streamingResponseSeq() ResponseSeq[Resp] {
func (c *CallContext[Req, Resp]) streamingResponseSeq() ResponseSeq[Resp] {
return func(yield func(NodeResponse[Resp]) bool) {
// Trigger sending on first iteration
c.sendNow()
Expand Down Expand Up @@ -314,8 +314,8 @@ func (c *ClientCtx[Req, Resp]) streamingResponseSeq() ResponseSeq[Resp] {
// The fn receives the original request and a node, and returns the transformed
// request to send to that node. If the function returns an invalid message or nil,
// an ErrSkipNode error is sent for that node, indicating it was skipped.
func MapRequest[Req, Resp msg](fn func(Req, *Node) Req) QuorumInterceptor[Req, Resp] {
return func(ctx *ClientCtx[Req, Resp], next ResponseSeq[Resp]) ResponseSeq[Resp] {
func MapRequest[Req, Resp msg](fn func(Req, *Node) Req) ClientInterceptor[Req, Resp] {
return func(ctx *CallContext[Req, Resp], next ResponseSeq[Resp]) ResponseSeq[Resp] {
if fn != nil {
ctx.reqTransforms = append(ctx.reqTransforms, fn)
}
Expand All @@ -327,8 +327,8 @@ func MapRequest[Req, Resp msg](fn func(Req, *Node) Req) QuorumInterceptor[Req, R
//
// The fn receives the response from a node and the node itself, and returns the
// transformed response.
func MapResponse[Req, Resp msg](fn func(Resp, *Node) Resp) QuorumInterceptor[Req, Resp] {
return func(ctx *ClientCtx[Req, Resp], next ResponseSeq[Resp]) ResponseSeq[Resp] {
func MapResponse[Req, Resp msg](fn func(Resp, *Node) Resp) ClientInterceptor[Req, Resp] {
return func(ctx *CallContext[Req, Resp], next ResponseSeq[Resp]) ResponseSeq[Resp] {
if fn == nil {
return next
}
Expand Down
File renamed without changes.
4 changes: 2 additions & 2 deletions quorumcall_test.go → call_quorum_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ func checkQuorumCall(t *testing.T, gotErr, wantErr error, expectedNodeErrors ...
// Validate QuorumCallError details if expectedNodeErrors provided
if len(expectedNodeErrors) > 0 {
var qcErr gorums.QuorumCallError
if errors.As(gotErr, &qcErr) && qcErr.NodeErrors() != expectedNodeErrors[0] {
t.Errorf("Expected %d node errors, got %d", expectedNodeErrors[0], qcErr.NodeErrors())
if errors.As(gotErr, &qcErr) && qcErr.NumErrors() != expectedNodeErrors[0] {
t.Errorf("Expected %d node errors, got %d", expectedNodeErrors[0], qcErr.NumErrors())
return false
}
}
Expand Down
2 changes: 1 addition & 1 deletion callopts.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func IgnoreErrors() CallOption {
// resp, err := ReadQC(ctx, req,
// gorums.Interceptors(loggingInterceptor, filterInterceptor),
// ).Majority()
func Interceptors[Req, Resp proto.Message](interceptors ...QuorumInterceptor[Req, Resp]) CallOption {
func Interceptors[Req, Resp proto.Message](interceptors ...ClientInterceptor[Req, Resp]) CallOption {
return func(o *callOptions) {
for _, interceptor := range interceptors {
o.interceptors = append(o.interceptors, interceptor)
Expand Down
39 changes: 19 additions & 20 deletions callopts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,27 @@ import (
pb "google.golang.org/protobuf/types/known/wrapperspb"
)

// testSystems returns n started Gorums systems on random localhost ports.
// It is the in-package counterpart of gorumstest.Systems, which this file
// cannot use: gorumstest imports gorums, so importing it from package gorums's
// own tests would create an import cycle.
func testSystems(t testing.TB, n int) []*System {
// testLocalServers returns n started Gorums servers forming a symmetric peer
// group on random localhost ports. It is the in-package counterpart of
// gorumstest.LocalServers, which this file cannot use: gorumstest imports
// gorums, so importing it from package gorums's own tests would create an
// import cycle.
func testLocalServers(t testing.TB, n int) []*Server {
t.Helper()
if _, ok := t.(*testing.B); !ok {
t.Cleanup(func() { goleak.VerifyNone(t) })
}
systems, stop, err := NewLocalSystems(n, WithLocalDialOptions(
WithDialOptions(grpc.WithTransportCredentials(insecure.NewCredentials())),
srvs, stop, err := NewLocalServers(n, WithLocalDialOptions(
WithGRPCDialOptions(grpc.WithTransportCredentials(insecure.NewCredentials())),
))
if err != nil {
t.Fatal(err)
}
t.Cleanup(stop)
for _, sys := range systems {
go sys.Serve()
for _, srv := range srvs {
go srv.ListenAndServe()
}
return systems
return srvs
}

// testWaitUntil polls predicate until it returns true or timeout elapses.
Expand Down Expand Up @@ -79,20 +80,18 @@ func TestCallOptionsIgnoreErrors(t *testing.T) {
func TestCallOptionsIgnoreErrorsResourceLeak(t *testing.T) {
// Previously leaked because fire-and-forget multicast still registered in router.
// Now fixed: no replyChan → no ResponseChan → no Register.
systems := testSystems(t, 3)
for _, sys := range systems {
sys.RegisterService(nil, func(srv *Server) {
srv.RegisterHandler(mock.TestMethod, func(_ ServerCtx, _ *Message) (*Message, error) {
return nil, nil
})
servers := testLocalServers(t, 3)
for _, srv := range servers {
srv.RegisterHandler(mock.TestMethod, func(_ ServerContext, _ *Message) (*Message, error) {
return nil, nil
})
}
for _, sys := range systems {
sys.WaitForPeers(t.Context(), func(cfg Configuration) bool {
for _, srv := range servers {
srv.WaitForPeers(t.Context(), func(cfg Config) bool {
return cfg.Size() == 3
})
}
cfg := systems[0].OutboundConfig()
cfg := servers[0].PeerConfig()
ctx := testTimeoutContext(t, 5*time.Second)
for i := range 1000 {
Multicast(cfg.Context(ctx), pb.String(fmt.Sprintf("mc-%d", i)), mock.TestMethod, IgnoreErrors())
Expand All @@ -114,7 +113,7 @@ func TestCallOptionsIgnoreErrorsResourceLeak(t *testing.T) {
}

func BenchmarkGetCallOptions(b *testing.B) {
interceptor := func(_ *ClientCtx[msg, msg], next ResponseSeq[msg]) ResponseSeq[msg] { return next }
interceptor := func(_ *CallContext[msg, msg], next ResponseSeq[msg]) ResponseSeq[msg] { return next }
tests := []struct {
numOpts int
}{
Expand Down
Loading
Loading