From cbf56a2f68723cebc98621ea32f94c9df5ed756a Mon Sep 17 00:00:00 2001 From: Erik Hortsch Date: Wed, 19 Aug 2026 16:01:01 -0700 Subject: [PATCH 1/5] server: let a server skip the claim for every queue rpc it handles WithClientSkipClaim only reaches clients built through ClientParams.Options(). Constructors that hand-pick options off the params struct drop it silently, and some of those live in other repos, so the caller-side switch cannot cover the fleet. The skipped round trip is server->client->server before the handler runs, so the server already holds the decision; the request bit is only authorization. Let the server elect it too, for every queue rpc it handles. Queue is still re-checked, and the outcome is still observed as ClaimSkipped. Takes func() bool rather than bool to keep the same runtime revocability as the client option. Co-Authored-By: Claude Opus 5 --- internal/test/skipclaim_test.go | 96 +++++++++++++++++++++++++++++++++ pkg/server/rpc.go | 9 ++-- server.go | 13 +++++ 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/internal/test/skipclaim_test.go b/internal/test/skipclaim_test.go index 4005fd6..969a05b 100644 --- a/internal/test/skipclaim_test.go +++ b/internal/test/skipclaim_test.go @@ -248,3 +248,99 @@ func TestSkipClaimRevokedAtRuntime(t *testing.T) { psrpc.ClaimSkipped, psrpc.ClaimGranted, psrpc.ClaimSkipped, }, claims) } + +// The server-side gate, for callers that build their clients without passing +// options through. Same property, decided at the other end: the client here is +// plain, exactly as an un-upgraded caller would be. +func TestServerSkipClaim(t *testing.T) { + bustest.TestAll(t, func(t *testing.T, newBus func(t testing.TB) bus.MessageBus) { + const queued, broadcast = "server_skip_queued", "server_skip_broadcast" + + obs := &recordingObserver{} + b := newBus(t) + + s := server.NewRPCServer(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, + psrpc.WithServerObserver(obs), psrpc.WithServerSkipClaim(enabled)) + t.Cleanup(func() { s.Close(true) }) + c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b) + require.NoError(t, err) + t.Cleanup(func() { c.Close() }) + + for _, rpc := range []string{queued, broadcast} { + queue := rpc == queued + s.RegisterMethod(rpc, false, false, true, queue) + c.RegisterMethod(rpc, false, false, true, queue) + } + + var queuedCalls, broadcastCalls atomic.Int32 + require.NoError(t, server.RegisterHandler(s, queued, nil, + func(context.Context, *internal.Request) (*internal.Response, error) { + queuedCalls.Add(1) + return &internal.Response{}, nil + }, nil)) + require.NoError(t, server.RegisterHandler(s, broadcast, nil, + func(context.Context, *internal.Request) (*internal.Response, error) { + broadcastCalls.Add(1) + return &internal.Response{}, nil + }, nil)) + + // The redis bus reconciles subscriptions asynchronously; publishing now races. + time.Sleep(time.Second) + + _, err = client.RequestSingle[*internal.Response](context.Background(), c, queued, nil, &internal.Request{}) + require.NoError(t, err, "a client that never opted in must still complete") + + received, claims := obs.snapshot() + require.Equal(t, 1, received) + require.Equal(t, []psrpc.ClaimOutcome{psrpc.ClaimSkipped}, claims) + require.EqualValues(t, 1, queuedCalls.Load(), "handler must run exactly once") + + _, err = client.RequestSingle[*internal.Response](context.Background(), c, broadcast, nil, &internal.Request{}) + require.NoError(t, err) + + received, claims = obs.snapshot() + require.Equal(t, 2, received) + require.Equal(t, []psrpc.ClaimOutcome{psrpc.ClaimSkipped, psrpc.ClaimGranted}, claims, + "broadcast RPC must still claim") + require.EqualValues(t, 1, broadcastCalls.Load()) + }) +} + +// The kill switch has to work from the server end too, since that is the only +// end some deployments can reach. +func TestServerSkipClaimRevokedAtRuntime(t *testing.T) { + obs := &recordingObserver{} + b := bus.NewLocalMessageBus() + var on atomic.Bool + on.Store(true) + + s := server.NewRPCServer(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, + psrpc.WithServerObserver(obs), psrpc.WithServerSkipClaim(on.Load)) + t.Cleanup(func() { s.Close(true) }) + c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b) + require.NoError(t, err) + t.Cleanup(func() { c.Close() }) + + s.RegisterMethod("queued", false, false, true, true) + c.RegisterMethod("queued", false, false, true, true) + require.NoError(t, server.RegisterHandler(s, "queued", nil, + func(context.Context, *internal.Request) (*internal.Response, error) { + return &internal.Response{}, nil + }, nil)) + + send := func() { + _, err := client.RequestSingle[*internal.Response](context.Background(), c, "queued", nil, &internal.Request{}) + require.NoError(t, err) + } + + send() + on.Store(false) + send() + on.Store(true) + send() + + _, claims := obs.snapshot() + require.Equal(t, []psrpc.ClaimOutcome{ + psrpc.ClaimSkipped, psrpc.ClaimGranted, psrpc.ClaimSkipped, + }, claims) +} diff --git a/pkg/server/rpc.go b/pkg/server/rpc.go index cdeb29b..b8da14b 100644 --- a/pkg/server/rpc.go +++ b/pkg/server/rpc.go @@ -220,9 +220,12 @@ func (h *rpcHandlerImpl[RequestType, ResponseType]) claimRequest( } // A queue subscription already chose this server, so the claim is announced - // rather than negotiated. Queue is re-checked because honoring SkipClaim on a - // broadcast rpc would let every server run the handler. - handling := ir.SkipClaim && h.i.Queue + // rather than negotiated. Either side may elect to skip: the caller per + // request, or this server for everything it handles. Queue is re-checked + // because honoring a skip on a broadcast rpc would let every server run the + // handler. + serverSkip := s.SkipClaim != nil && s.SkipClaim() + handling := (ir.SkipClaim || serverSkip) && h.i.Queue var claimResponseChan chan *internal.ClaimResponse if !handling { diff --git a/server.go b/server.go index 05c8cba..5329c78 100644 --- a/server.go +++ b/server.go @@ -33,6 +33,19 @@ type ServerOpts struct { StreamInterceptors []StreamInterceptor ChainedInterceptor ServerRPCInterceptor RequestObserver RequestObserver + SkipClaim func() bool +} + +// WithServerSkipClaim lets this server announce that it is handling a queue +// rpc rather than negotiating a claim, for every request it receives. Mirrors +// WithClientSkipClaim for callers that cannot set the option per client, and +// carries the same requirement that the bus deliver a queue subscription to +// exactly one subscriber. Consulted per request so it can be revoked at +// runtime without a redeploy, and disabled when unset. +func WithServerSkipClaim(enabled func() bool) ServerOption { + return func(o *ServerOpts) { + o.SkipClaim = enabled + } } func WithServerID(id string) ServerOption { From 77f507dc60544173eff3ef552947289a86d60524 Mon Sep 17 00:00:00 2001 From: Erik Hortsch Date: Thu, 20 Aug 2026 09:24:50 -0700 Subject: [PATCH 2/5] client: advertise the skip instead of electing it Two switches for one decision left the caller able to force an announcement at a server that never opted in, and put the compatibility risk on the wrong end: a server electing to skip would announce to callers predating the field. Make the request bit an advertisement -- always set on a queue rpc, since a caller built from this version can always accept an announcement -- and let the server alone decide whether to make one. Both ends must now agree, so an older caller is never sent an announcement it cannot read, and WithClientSkipClaim has nothing left to configure. Co-Authored-By: Claude Opus 5 --- client.go | 11 ---- internal/internal.proto | 4 +- internal/test/skipclaim_test.go | 112 ++++++++------------------------ pkg/client/rpc.go | 6 +- pkg/server/rpc.go | 12 ++-- server.go | 11 ++-- 6 files changed, 44 insertions(+), 112 deletions(-) diff --git a/client.go b/client.go index b4410e6..1af124a 100644 --- a/client.go +++ b/client.go @@ -40,17 +40,6 @@ type ClientOpts struct { RpcInterceptors []ClientRPCInterceptor MultiRPCInterceptors []ClientMultiRPCInterceptor StreamInterceptors []StreamInterceptor - SkipClaim func() bool -} - -// WithClientSkipClaim lets a queue rpc bypass the claim handshake while enabled, -// which is only sound if the bus delivers a queue subscription to exactly one -// subscriber. Consulted per request so it can be revoked at runtime without a -// redeploy, and disabled when unset. -func WithClientSkipClaim(enabled func() bool) ClientOption { - return func(o *ClientOpts) { - o.SkipClaim = enabled - } } func WithClientID(id string) ClientOption { diff --git a/internal/internal.proto b/internal/internal.proto index fdbd375..f5da46d 100644 --- a/internal/internal.proto +++ b/internal/internal.proto @@ -38,7 +38,9 @@ message Request { google.protobuf.Any request = 6; map metadata = 7; bytes raw_request = 8; - // Advisory: the caller still answers a claim, so older servers are unaffected. + // Advertises that the caller can accept an announcement in place of a claim. + // The server decides whether to make one, so a caller too old to set this is + // never sent one. bool skip_claim = 9; } diff --git a/internal/test/skipclaim_test.go b/internal/test/skipclaim_test.go index 969a05b..3c29765 100644 --- a/internal/test/skipclaim_test.go +++ b/internal/test/skipclaim_test.go @@ -45,10 +45,9 @@ func TestSkipClaim(t *testing.T) { b := newBus(t) s := server.NewRPCServer(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, - psrpc.WithServerObserver(obs)) + psrpc.WithServerObserver(obs), psrpc.WithServerSkipClaim(enabled)) t.Cleanup(func() { s.Close(true) }) - c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, - psrpc.WithClientSkipClaim(enabled)) + c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b) require.NoError(t, err) t.Cleanup(func() { c.Close() }) @@ -162,10 +161,10 @@ func TestSkipClaimSlowHandler(t *testing.T) { } })) - s := server.NewRPCServer(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b) + s := server.NewRPCServer(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, + psrpc.WithServerSkipClaim(enabled)) t.Cleanup(func() { s.Close(true) }) - c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, - psrpc.WithClientSkipClaim(enabled)) + c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b) require.NoError(t, err) t.Cleanup(func() { c.Close() }) @@ -218,10 +217,9 @@ func TestSkipClaimRevokedAtRuntime(t *testing.T) { on.Store(true) s := server.NewRPCServer(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, - psrpc.WithServerObserver(obs)) + psrpc.WithServerObserver(obs), psrpc.WithServerSkipClaim(on.Load)) t.Cleanup(func() { s.Close(true) }) - c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, - psrpc.WithClientSkipClaim(on.Load)) + c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b) require.NoError(t, err) t.Cleanup(func() { c.Close() }) @@ -249,73 +247,24 @@ func TestSkipClaimRevokedAtRuntime(t *testing.T) { }, claims) } -// The server-side gate, for callers that build their clients without passing -// options through. Same property, decided at the other end: the client here is -// plain, exactly as an un-upgraded caller would be. -func TestServerSkipClaim(t *testing.T) { - bustest.TestAll(t, func(t *testing.T, newBus func(t testing.TB) bus.MessageBus) { - const queued, broadcast = "server_skip_queued", "server_skip_broadcast" - - obs := &recordingObserver{} - b := newBus(t) - - s := server.NewRPCServer(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, - psrpc.WithServerObserver(obs), psrpc.WithServerSkipClaim(enabled)) - t.Cleanup(func() { s.Close(true) }) - c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b) - require.NoError(t, err) - t.Cleanup(func() { c.Close() }) - - for _, rpc := range []string{queued, broadcast} { - queue := rpc == queued - s.RegisterMethod(rpc, false, false, true, queue) - c.RegisterMethod(rpc, false, false, true, queue) - } - - var queuedCalls, broadcastCalls atomic.Int32 - require.NoError(t, server.RegisterHandler(s, queued, nil, - func(context.Context, *internal.Request) (*internal.Response, error) { - queuedCalls.Add(1) - return &internal.Response{}, nil - }, nil)) - require.NoError(t, server.RegisterHandler(s, broadcast, nil, - func(context.Context, *internal.Request) (*internal.Response, error) { - broadcastCalls.Add(1) - return &internal.Response{}, nil - }, nil)) - - // The redis bus reconciles subscriptions asynchronously; publishing now races. - time.Sleep(time.Second) - - _, err = client.RequestSingle[*internal.Response](context.Background(), c, queued, nil, &internal.Request{}) - require.NoError(t, err, "a client that never opted in must still complete") - - received, claims := obs.snapshot() - require.Equal(t, 1, received) - require.Equal(t, []psrpc.ClaimOutcome{psrpc.ClaimSkipped}, claims) - require.EqualValues(t, 1, queuedCalls.Load(), "handler must run exactly once") - - _, err = client.RequestSingle[*internal.Response](context.Background(), c, broadcast, nil, &internal.Request{}) - require.NoError(t, err) - - received, claims = obs.snapshot() - require.Equal(t, 2, received) - require.Equal(t, []psrpc.ClaimOutcome{psrpc.ClaimSkipped, psrpc.ClaimGranted}, claims, - "broadcast RPC must still claim") - require.EqualValues(t, 1, broadcastCalls.Load()) - }) -} - -// The kill switch has to work from the server end too, since that is the only -// end some deployments can reach. -func TestServerSkipClaimRevokedAtRuntime(t *testing.T) { +// A caller too old to advertise must still be granted, even against a server +// that has elected to skip. This is the property that makes a mixed-version +// fleet safe: an announcement only ever reaches a caller that asked for one. +func TestSkipClaimCallerDoesNotAdvertise(t *testing.T) { obs := &recordingObserver{} - b := bus.NewLocalMessageBus() - var on atomic.Bool - on.Store(true) + b := testutils.NewTestBus(bus.NewLocalMessageBus(), + testutils.WithPublishInterceptor(func(next testutils.PublishHandler) testutils.PublishHandler { + return func(ctx context.Context, channel testutils.Channel, msg proto.Message) error { + if req, ok := msg.(*internal.Request); ok { + // As a client predating the field would leave it. + req.SkipClaim = false + } + return next(ctx, channel, msg) + } + })) s := server.NewRPCServer(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, - psrpc.WithServerObserver(obs), psrpc.WithServerSkipClaim(on.Load)) + psrpc.WithServerObserver(obs), psrpc.WithServerSkipClaim(enabled)) t.Cleanup(func() { s.Close(true) }) c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b) require.NoError(t, err) @@ -328,19 +277,10 @@ func TestServerSkipClaimRevokedAtRuntime(t *testing.T) { return &internal.Response{}, nil }, nil)) - send := func() { - _, err := client.RequestSingle[*internal.Response](context.Background(), c, "queued", nil, &internal.Request{}) - require.NoError(t, err) - } - - send() - on.Store(false) - send() - on.Store(true) - send() + _, err = client.RequestSingle[*internal.Response](context.Background(), c, "queued", nil, &internal.Request{}) + require.NoError(t, err) _, claims := obs.snapshot() - require.Equal(t, []psrpc.ClaimOutcome{ - psrpc.ClaimSkipped, psrpc.ClaimGranted, psrpc.ClaimSkipped, - }, claims) + require.Equal(t, []psrpc.ClaimOutcome{psrpc.ClaimGranted}, claims, + "a caller that did not advertise must be negotiated with") } diff --git a/pkg/client/rpc.go b/pkg/client/rpc.go index 6f4b7da..ab33fb7 100644 --- a/pkg/client/rpc.go +++ b/pkg/client/rpc.go @@ -109,8 +109,10 @@ func newRPC[ResponseType proto.Message](c *RPCClient, i *info.RequestInfo) psrpc Multi: false, RawRequest: b, Metadata: metadata.OutgoingContextMetadata(ctx), - // The queue already chose the server; the claim only ratifies it. - SkipClaim: i.Queue && c.SkipClaim != nil && c.SkipClaim(), + // Advertises that this caller can accept an announcement in place of + // a claim, which it always can. Whether one is made is the server's + // call; a caller too old to advertise is never sent one. + SkipClaim: i.Queue, } var claimChan chan *internal.ClaimRequest diff --git a/pkg/server/rpc.go b/pkg/server/rpc.go index b8da14b..4337a9d 100644 --- a/pkg/server/rpc.go +++ b/pkg/server/rpc.go @@ -219,13 +219,13 @@ func (h *rpcHandlerImpl[RequestType, ResponseType]) claimRequest( affinity = 1 } - // A queue subscription already chose this server, so the claim is announced - // rather than negotiated. Either side may elect to skip: the caller per - // request, or this server for everything it handles. Queue is re-checked - // because honoring a skip on a broadcast rpc would let every server run the - // handler. + // A queue subscription already chose this server, so the claim can be + // announced rather than negotiated. Both ends have to agree: the caller + // advertises that it can accept an announcement, and this server elects to + // make one. Queue is re-checked because announcing on a broadcast rpc would + // let every server run the handler. serverSkip := s.SkipClaim != nil && s.SkipClaim() - handling := (ir.SkipClaim || serverSkip) && h.i.Queue + handling := ir.SkipClaim && serverSkip && h.i.Queue var claimResponseChan chan *internal.ClaimResponse if !handling { diff --git a/server.go b/server.go index 5329c78..ee8c1c5 100644 --- a/server.go +++ b/server.go @@ -36,12 +36,11 @@ type ServerOpts struct { SkipClaim func() bool } -// WithServerSkipClaim lets this server announce that it is handling a queue -// rpc rather than negotiating a claim, for every request it receives. Mirrors -// WithClientSkipClaim for callers that cannot set the option per client, and -// carries the same requirement that the bus deliver a queue subscription to -// exactly one subscriber. Consulted per request so it can be revoked at -// runtime without a redeploy, and disabled when unset. +// WithServerSkipClaim lets this server announce that it is handling a queue rpc +// rather than negotiating a claim, for every request whose caller advertised +// that it can accept one. Only sound if the bus delivers a queue subscription to +// exactly one subscriber. Consulted per request so it can be revoked at runtime +// without a redeploy, and disabled when unset. func WithServerSkipClaim(enabled func() bool) ServerOption { return func(o *ServerOpts) { o.SkipClaim = enabled From 75f841f8905ebec8909b16039a0271a80f11aa36 Mon Sep 17 00:00:00 2001 From: Erik Hortsch Date: Fri, 21 Aug 2026 11:36:53 -0700 Subject: [PATCH 3/5] client: a queue response is the answer, even when it is an error CS-1992: a handler that errors on receipt publishes its response right behind the announcement, on a different channel, and nothing orders their delivery. When the response won the race, selection stashed it as a fallback and the announcement then sent the caller off to wait on a channel already drained, turning an instant error into a request timeout. The stash exists for broadcast, where an early error is one server rejecting a request it could not read and another may yet bid. On a queue rpc the responder is the only server that received the request, so nothing else can ever arrive: return the response the moment it appears. This also stops a queue caller from waiting out the selection timeout to surface a malformed-request rejection, the case with no announcement at all. Reported in #123, which surfaced the stashed error once the announcement arrived; resolving it at the response site keeps selection from consuming the answer in the first place. Co-Authored-By: Claude Opus 5 --- internal/test/skipclaim_test.go | 52 +++++++++++++++++++++++++++++++++ pkg/client/rpc.go | 14 +++++---- pkg/client/stream.go | 3 +- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/internal/test/skipclaim_test.go b/internal/test/skipclaim_test.go index 3c29765..a90420f 100644 --- a/internal/test/skipclaim_test.go +++ b/internal/test/skipclaim_test.go @@ -284,3 +284,55 @@ func TestSkipClaimCallerDoesNotAdvertise(t *testing.T) { require.Equal(t, []psrpc.ClaimOutcome{psrpc.ClaimGranted}, claims, "a caller that did not advertise must be negotiated with") } + +// CS-1992: a handler that errors on receipt publishes its response right +// behind the announcement, on a different channel, and nothing orders their +// delivery. When the response won the race it was stashed as a fallback while +// the announcement sent the caller off to wait on a channel already drained, +// turning an instant error into a request timeout. On a queue rpc the +// responder is the only server that received the request, so any response is +// the answer. Losing the race depends on scheduling, so the bus holds every +// announcement back to force it. +func TestSkipClaimFastFailingHandler(t *testing.T) { + b := testutils.NewTestBus(bus.NewLocalMessageBus(), + testutils.WithPublishInterceptor(func(next testutils.PublishHandler) testutils.PublishHandler { + return func(ctx context.Context, channel testutils.Channel, msg proto.Message) error { + if _, ok := msg.(*internal.ClaimRequest); ok { + go func() { + time.Sleep(50 * time.Millisecond) + _ = next(ctx, channel, msg) + }() + return nil + } + return next(ctx, channel, msg) + } + })) + + s := server.NewRPCServer(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b, + psrpc.WithServerSkipClaim(enabled)) + t.Cleanup(func() { s.Close(true) }) + c, err := client.NewRPCClient(&info.ServiceDefinition{Name: "test", ID: rand.NewString()}, b) + require.NoError(t, err) + t.Cleanup(func() { c.Close() }) + + s.RegisterMethod("queued", false, false, true, true) + c.RegisterMethod("queued", false, false, true, true) + require.NoError(t, server.RegisterHandler(s, "queued", nil, + func(context.Context, *internal.Request) (*internal.Response, error) { + return nil, psrpc.NewErrorf(psrpc.NotFound, "requested room does not exist") + }, nil)) + + const timeout = time.Second + + start := time.Now() + _, err = client.RequestSingle[*internal.Response](context.Background(), c, "queued", nil, + &internal.Request{}, psrpc.WithRequestTimeout(timeout)) + + require.Error(t, err) + require.NotErrorIs(t, err, psrpc.ErrRequestTimedOut, + "the handler answered, so the caller must not time out") + code, ok := psrpc.GetErrorCode(err) + require.True(t, ok) + require.Equal(t, psrpc.NotFound, code, "the handler's error must reach the caller") + require.Less(t, time.Since(start), timeout/2, "the answer must not wait out the timeout") +} diff --git a/pkg/client/rpc.go b/pkg/client/rpc.go index ab33fb7..abb9f90 100644 --- a/pkg/client/rpc.go +++ b/pkg/client/rpc.go @@ -146,7 +146,7 @@ func newRPC[ResponseType proto.Message](c *RPCClient, i *info.RequestInfo) psrpc var res *internal.Response if i.RequireClaim { - sel, err := selectServer(ctx, claimChan, resChan, o.SelectionOpts) + sel, err := selectServer(ctx, claimChan, resChan, o.SelectionOpts, i.Queue) if err != nil { return nil, err } @@ -210,6 +210,7 @@ func selectServer( claimChan chan *internal.ClaimRequest, resChan chan *internal.Response, opts psrpc.SelectionOpts, + queue bool, ) (selection, error) { ctx, cancel := context.WithCancel(ctx) @@ -270,12 +271,15 @@ func selectServer( } case res := <-resChan: - if res.Error == "" { - // Only a server that never waited to be granted answers this early, - // and consuming it here would strand the response. + // Only a server that never waited to be granted answers this early, and + // consuming it here would strand the response. On a queue rpc that + // server is the only one that received the request, so even an error is + // the request's answer -- an announcement racing behind it selects + // nothing. Held back only on broadcast, where an early error is one + // server rejecting a request it could not read and another may yet bid. + if res.Error == "" || queue { return selection{res: res}, nil } - // otherwise a malformed request, which is answered before any claim resErr = psrpc.NewErrorFromResponse(res.Code, res.Error, res.ErrorDetails...) } } diff --git a/pkg/client/stream.go b/pkg/client/stream.go index 37611df..42f5a64 100644 --- a/pkg/client/stream.go +++ b/pkg/client/stream.go @@ -95,7 +95,8 @@ func OpenStream[SendType, RecvType proto.Message]( } if i.RequireClaim { - sel, err := selectServer(ctx, claimChan, nil, o.SelectionOpts) + // Streams negotiate on a nil resChan, so queue-ness cannot matter here. + sel, err := selectServer(ctx, claimChan, nil, o.SelectionOpts, false) if err != nil { _ = cs.Close(err) return nil, err From 96e856449a9f034eca3c7d42080755bcaab1ba94 Mon Sep 17 00:00:00 2001 From: Erik Hortsch Date: Fri, 21 Aug 2026 12:30:51 -0700 Subject: [PATCH 4/5] proto: move the advertisement off the election's field number Field 9 carried the caller's election of the skip, which pre-election servers honor unconditionally. A caller that advertises on the same number would switch those servers on for every queue rpc, with no opt-in and no kill switch, since they have no server-side election to revoke. At 10, the advertisement is invisible to every server that predates it: they see field 9 absent and negotiate. The reverse skew already degraded safely -- an old caller electing on 9 is simply negotiated with. Field 9 is reserved so it cannot come back meaning something else. Also catches pkg/client tests up to selectServer's queue parameter, which the previous commit missed, and covers the queue/broadcast split directly. Co-Authored-By: Claude Opus 5 --- internal/internal.pb.go | 158 +++++++++++++++++++------------------- internal/internal.proto | 7 +- pkg/client/client_test.go | 39 +++++++++- 3 files changed, 121 insertions(+), 83 deletions(-) diff --git a/internal/internal.pb.go b/internal/internal.pb.go index e16a730..fa68425 100644 --- a/internal/internal.pb.go +++ b/internal/internal.pb.go @@ -150,8 +150,10 @@ type Request struct { Request *anypb.Any `protobuf:"bytes,6,opt,name=request,proto3" json:"request,omitempty"` Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` RawRequest []byte `protobuf:"bytes,8,opt,name=raw_request,json=rawRequest,proto3" json:"raw_request,omitempty"` - // Advisory: the caller still answers a claim, so older servers are unaffected. - SkipClaim bool `protobuf:"varint,9,opt,name=skip_claim,json=skipClaim,proto3" json:"skip_claim,omitempty"` + // Advertises that the caller can accept an announcement in place of a claim. + // The server decides whether to make one, so a caller too old to set this is + // never sent one. + SkipClaim bool `protobuf:"varint,10,opt,name=skip_claim,json=skipClaim,proto3" json:"skip_claim,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -822,7 +824,7 @@ var file_internal_proto_rawDesc = string([]byte{ 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x22, 0x23, 0x0a, 0x07, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x22, 0xf6, 0x02, 0x0a, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x22, 0xfc, 0x02, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, @@ -841,86 +843,86 @@ var file_internal_proto_rawDesc = string([]byte{ 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x61, 0x77, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x72, 0x61, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x6b, 0x69, - 0x70, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, + 0x70, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x6b, 0x69, 0x70, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x99, 0x02, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, - 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, - 0x0a, 0x07, 0x73, 0x65, 0x6e, 0x74, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x06, 0x73, 0x65, 0x6e, 0x74, 0x41, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x99, 0x02, 0x0a, 0x08, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x65, 0x6e, 0x74, 0x5f, 0x61, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x73, 0x65, 0x6e, 0x74, 0x41, 0x74, 0x12, 0x30, 0x0a, + 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x61, 0x77, + 0x5f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x0b, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39, 0x0a, 0x0d, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x08, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x22, 0x82, 0x01, 0x0a, 0x0c, 0x43, 0x6c, 0x61, 0x69, + 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x66, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x79, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x52, 0x08, 0x61, 0x66, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x79, + 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x61, 0x6e, 0x64, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x08, 0x68, 0x61, 0x6e, 0x64, 0x6c, 0x69, 0x6e, 0x67, 0x22, 0x4b, 0x0a, 0x0d, + 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, + 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x22, 0xb6, 0x02, 0x0a, 0x06, 0x53, 0x74, + 0x72, 0x65, 0x61, 0x6d, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, + 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, + 0x12, 0x17, 0x0a, 0x07, 0x73, 0x65, 0x6e, 0x74, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x06, 0x73, 0x65, 0x6e, 0x74, 0x41, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x78, 0x70, + 0x69, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x65, 0x78, 0x70, 0x69, 0x72, + 0x79, 0x12, 0x2a, 0x0a, 0x04, 0x6f, 0x70, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x14, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x4f, 0x70, 0x65, 0x6e, 0x48, 0x00, 0x52, 0x04, 0x6f, 0x70, 0x65, 0x6e, 0x12, 0x33, 0x0a, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, + 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x27, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x13, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x12, 0x2d, 0x0a, 0x05, 0x63, + 0x6c, 0x6f, 0x73, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x69, 0x6e, 0x74, + 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x43, 0x6c, 0x6f, 0x73, + 0x65, 0x48, 0x00, 0x52, 0x05, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x62, 0x6f, + 0x64, 0x79, 0x22, 0xa2, 0x01, 0x0a, 0x0a, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4f, 0x70, 0x65, + 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x6e, 0x6f, 0x64, 0x65, 0x49, 0x64, 0x12, 0x3e, 0x0a, 0x08, 0x6d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x69, + 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4f, 0x70, + 0x65, 0x6e, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x60, 0x0a, 0x0d, 0x53, 0x74, 0x72, 0x65, 0x61, + 0x6d, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, - 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, - 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, - 0x6f, 0x64, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x61, 0x77, 0x5f, 0x72, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x72, 0x61, 0x77, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, - 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x41, 0x6e, 0x79, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, - 0x73, 0x22, 0x82, 0x01, 0x0a, 0x0c, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, - 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1a, - 0x0a, 0x08, 0x61, 0x66, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, - 0x52, 0x08, 0x61, 0x66, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x61, - 0x6e, 0x64, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x68, 0x61, - 0x6e, 0x64, 0x6c, 0x69, 0x6e, 0x67, 0x22, 0x4b, 0x0a, 0x0d, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x49, 0x64, 0x22, 0xb6, 0x02, 0x0a, 0x06, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x1b, - 0x0a, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x72, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x65, - 0x6e, 0x74, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x73, 0x65, 0x6e, - 0x74, 0x41, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x06, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x12, 0x2a, 0x0a, 0x04, 0x6f, - 0x70, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x69, 0x6e, 0x74, 0x65, - 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4f, 0x70, 0x65, 0x6e, 0x48, - 0x00, 0x52, 0x04, 0x6f, 0x70, 0x65, 0x6e, 0x12, 0x33, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, - 0x6e, 0x61, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x27, 0x0a, 0x03, - 0x61, 0x63, 0x6b, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x69, 0x6e, 0x74, 0x65, - 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x41, 0x63, 0x6b, 0x48, 0x00, - 0x52, 0x03, 0x61, 0x63, 0x6b, 0x12, 0x2d, 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x18, 0x09, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, - 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x48, 0x00, 0x52, 0x05, 0x63, - 0x6c, 0x6f, 0x73, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xa2, 0x01, 0x0a, - 0x0a, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4f, 0x70, 0x65, 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x6e, - 0x6f, 0x64, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6e, 0x6f, - 0x64, 0x65, 0x49, 0x64, 0x12, 0x3e, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, - 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4f, 0x70, 0x65, 0x6e, 0x2e, 0x4d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x60, 0x0a, 0x0d, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x61, 0x77, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x72, 0x61, 0x77, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x41, 0x63, 0x6b, - 0x22, 0x37, 0x0a, 0x0b, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x12, - 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x42, 0x23, 0x5a, 0x21, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x69, 0x76, 0x65, 0x6b, 0x69, 0x74, 0x2f, - 0x70, 0x73, 0x72, 0x70, 0x63, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x61, 0x77, 0x5f, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x72, + 0x61, 0x77, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x53, 0x74, 0x72, + 0x65, 0x61, 0x6d, 0x41, 0x63, 0x6b, 0x22, 0x37, 0x0a, 0x0b, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, + 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, + 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x42, + 0x23, 0x5a, 0x21, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x69, + 0x76, 0x65, 0x6b, 0x69, 0x74, 0x2f, 0x70, 0x73, 0x72, 0x70, 0x63, 0x2f, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x6e, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( diff --git a/internal/internal.proto b/internal/internal.proto index f5da46d..6ae957a 100644 --- a/internal/internal.proto +++ b/internal/internal.proto @@ -38,10 +38,15 @@ message Request { google.protobuf.Any request = 6; map metadata = 7; bytes raw_request = 8; + // Field 9 carried the caller's *election* of the skip, which pre-election + // servers honored unconditionally. Setting the advertisement there would + // switch those servers on with no opt-in, so it lives at 10, which they + // never read. + reserved 9; // Advertises that the caller can accept an announcement in place of a claim. // The server decides whether to make one, so a caller too old to set this is // never sent one. - bool skip_claim = 9; + bool skip_claim = 10; } message Response { diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 28e5972..19741d8 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -88,7 +88,7 @@ func testAffinity(t *testing.T, opts psrpc.SelectionOpts, expectedID string) { Affinity: 0.9, } }() - sel, err := selectServer(context.Background(), c, nil, opts) + sel, err := selectServer(context.Background(), c, nil, opts, false) require.NoError(t, err) require.Equal(t, expectedID, sel.serverID) } @@ -99,7 +99,7 @@ func TestSelectServerGrantsABid(t *testing.T) { claims <- &internal.ClaimRequest{RequestId: "1", ServerId: "2", Affinity: 1} sel, err := selectServer(context.Background(), claims, make(chan *internal.Response, 1), - psrpc.SelectionOpts{AcceptFirstAvailable: true}) + psrpc.SelectionOpts{AcceptFirstAvailable: true}, true) require.NoError(t, err) require.Equal(t, "2", sel.serverID) require.False(t, sel.handling, "a bid still needs granting") @@ -113,7 +113,7 @@ func TestSelectServerHonorsAnnouncement(t *testing.T) { claims <- &internal.ClaimRequest{RequestId: "1", ServerId: "2", Affinity: 1, Handling: true} sel, err := selectServer(context.Background(), claims, make(chan *internal.Response, 1), - psrpc.SelectionOpts{MinimumAffinity: 2, AffinityTimeout: time.Second}) + psrpc.SelectionOpts{MinimumAffinity: 2, AffinityTimeout: time.Second}, true) require.NoError(t, err) require.Equal(t, "2", sel.serverID) require.True(t, sel.handling) @@ -127,9 +127,40 @@ func TestSelectServerReturnsEarlyResponse(t *testing.T) { responses <- &internal.Response{RequestId: "1", ServerId: "2"} sel, err := selectServer(context.Background(), make(chan *internal.ClaimRequest, 1), responses, - psrpc.SelectionOpts{AcceptFirstAvailable: true}) + psrpc.SelectionOpts{AcceptFirstAvailable: true}, true) require.NoError(t, err) require.NotNil(t, sel.res) require.Equal(t, "2", sel.res.ServerId) require.Empty(t, sel.serverID) } + +// On a queue rpc an error response is the answer: the responder is the only +// server that received the request, so nothing else can arrive (CS-1992). +func TestSelectServerReturnsQueueError(t *testing.T) { + responses := make(chan *internal.Response, 1) + responses <- &internal.Response{RequestId: "1", ServerId: "2", Error: "not found", Code: "not_found"} + + sel, err := selectServer(context.Background(), make(chan *internal.ClaimRequest, 1), responses, + psrpc.SelectionOpts{AcceptFirstAvailable: true}, true) + require.NoError(t, err) + require.NotNil(t, sel.res, "the error response is the answer, not a fallback") +} + +// On broadcast the same early error is one server rejecting a request it could +// not read; another may yet bid, so it is held as the fallback answer. +func TestSelectServerStashesBroadcastError(t *testing.T) { + responses := make(chan *internal.Response, 1) + responses <- &internal.Response{RequestId: "1", ServerId: "2", Error: "boom", Code: "internal"} + claims := make(chan *internal.ClaimRequest, 1) + + go func() { + time.Sleep(50 * time.Millisecond) + claims <- &internal.ClaimRequest{RequestId: "1", ServerId: "3", Affinity: 1} + }() + + sel, err := selectServer(context.Background(), claims, responses, + psrpc.SelectionOpts{AcceptFirstAvailable: true, AffinityTimeout: time.Second}, false) + require.NoError(t, err) + require.Equal(t, "3", sel.serverID, "a healthy bid must win over a stashed rejection") + require.Nil(t, sel.res) +} From d5787e1ce93786c173b1724ede3efd705bdad4da Mon Sep 17 00:00:00 2001 From: Erik Hortsch Date: Fri, 21 Aug 2026 14:09:51 -0700 Subject: [PATCH 5/5] docs: cut every comment down to the fact it carries reserved 9 stays: protoc rejects any later field reusing the number, so it is enforcement, not documentation. Co-Authored-By: Claude Opus 5 --- internal/internal.pb.go | 4 +--- internal/internal.proto | 9 ++------- internal/test/skipclaim_test.go | 15 ++++----------- pkg/client/client_test.go | 6 ++---- pkg/client/rpc.go | 12 +++--------- pkg/client/stream.go | 2 +- pkg/server/rpc.go | 6 +----- server.go | 7 ++----- 8 files changed, 16 insertions(+), 45 deletions(-) diff --git a/internal/internal.pb.go b/internal/internal.pb.go index fa68425..27bb1ec 100644 --- a/internal/internal.pb.go +++ b/internal/internal.pb.go @@ -150,9 +150,7 @@ type Request struct { Request *anypb.Any `protobuf:"bytes,6,opt,name=request,proto3" json:"request,omitempty"` Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` RawRequest []byte `protobuf:"bytes,8,opt,name=raw_request,json=rawRequest,proto3" json:"raw_request,omitempty"` - // Advertises that the caller can accept an announcement in place of a claim. - // The server decides whether to make one, so a caller too old to set this is - // never sent one. + // Advertises that an announcement may replace the claim; the server decides. SkipClaim bool `protobuf:"varint,10,opt,name=skip_claim,json=skipClaim,proto3" json:"skip_claim,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/internal/internal.proto b/internal/internal.proto index 6ae957a..2a00654 100644 --- a/internal/internal.proto +++ b/internal/internal.proto @@ -38,14 +38,9 @@ message Request { google.protobuf.Any request = 6; map metadata = 7; bytes raw_request = 8; - // Field 9 carried the caller's *election* of the skip, which pre-election - // servers honored unconditionally. Setting the advertisement there would - // switch those servers on with no opt-in, so it lives at 10, which they - // never read. + // 9 was the caller-elected skip, which older servers honor unconditionally. reserved 9; - // Advertises that the caller can accept an announcement in place of a claim. - // The server decides whether to make one, so a caller too old to set this is - // never sent one. + // Advertises that an announcement may replace the claim; the server decides. bool skip_claim = 10; } diff --git a/internal/test/skipclaim_test.go b/internal/test/skipclaim_test.go index a90420f..4d71818 100644 --- a/internal/test/skipclaim_test.go +++ b/internal/test/skipclaim_test.go @@ -247,9 +247,8 @@ func TestSkipClaimRevokedAtRuntime(t *testing.T) { }, claims) } -// A caller too old to advertise must still be granted, even against a server -// that has elected to skip. This is the property that makes a mixed-version -// fleet safe: an announcement only ever reaches a caller that asked for one. +// A caller that does not advertise must be negotiated with, even by a server +// that elected to skip. func TestSkipClaimCallerDoesNotAdvertise(t *testing.T) { obs := &recordingObserver{} b := testutils.NewTestBus(bus.NewLocalMessageBus(), @@ -285,14 +284,8 @@ func TestSkipClaimCallerDoesNotAdvertise(t *testing.T) { "a caller that did not advertise must be negotiated with") } -// CS-1992: a handler that errors on receipt publishes its response right -// behind the announcement, on a different channel, and nothing orders their -// delivery. When the response won the race it was stashed as a fallback while -// the announcement sent the caller off to wait on a channel already drained, -// turning an instant error into a request timeout. On a queue rpc the -// responder is the only server that received the request, so any response is -// the answer. Losing the race depends on scheduling, so the bus holds every -// announcement back to force it. +// CS-1992: an error response that beat the announcement was stashed while the +// caller waited out the timeout. The bus delays announcements to force that order. func TestSkipClaimFastFailingHandler(t *testing.T) { b := testutils.NewTestBus(bus.NewLocalMessageBus(), testutils.WithPublishInterceptor(func(next testutils.PublishHandler) testutils.PublishHandler { diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 19741d8..661b2a0 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -134,8 +134,7 @@ func TestSelectServerReturnsEarlyResponse(t *testing.T) { require.Empty(t, sel.serverID) } -// On a queue rpc an error response is the answer: the responder is the only -// server that received the request, so nothing else can arrive (CS-1992). +// CS-1992: on queue an error response is the answer, not a fallback. func TestSelectServerReturnsQueueError(t *testing.T) { responses := make(chan *internal.Response, 1) responses <- &internal.Response{RequestId: "1", ServerId: "2", Error: "not found", Code: "not_found"} @@ -146,8 +145,7 @@ func TestSelectServerReturnsQueueError(t *testing.T) { require.NotNil(t, sel.res, "the error response is the answer, not a fallback") } -// On broadcast the same early error is one server rejecting a request it could -// not read; another may yet bid, so it is held as the fallback answer. +// On broadcast an early error is held back so a healthy bid can win. func TestSelectServerStashesBroadcastError(t *testing.T) { responses := make(chan *internal.Response, 1) responses <- &internal.Response{RequestId: "1", ServerId: "2", Error: "boom", Code: "internal"} diff --git a/pkg/client/rpc.go b/pkg/client/rpc.go index abb9f90..c2ec993 100644 --- a/pkg/client/rpc.go +++ b/pkg/client/rpc.go @@ -109,9 +109,7 @@ func newRPC[ResponseType proto.Message](c *RPCClient, i *info.RequestInfo) psrpc Multi: false, RawRequest: b, Metadata: metadata.OutgoingContextMetadata(ctx), - // Advertises that this caller can accept an announcement in place of - // a claim, which it always can. Whether one is made is the server's - // call; a caller too old to advertise is never sent one. + // Advertises that an announcement may replace the claim; making one is the server's call. SkipClaim: i.Queue, } @@ -271,12 +269,8 @@ func selectServer( } case res := <-resChan: - // Only a server that never waited to be granted answers this early, and - // consuming it here would strand the response. On a queue rpc that - // server is the only one that received the request, so even an error is - // the request's answer -- an announcement racing behind it selects - // nothing. Held back only on broadcast, where an early error is one - // server rejecting a request it could not read and another may yet bid. + // On queue the sole responder's answer is final, error or not; on + // broadcast an early error may yet be outbid, so it is held back. if res.Error == "" || queue { return selection{res: res}, nil } diff --git a/pkg/client/stream.go b/pkg/client/stream.go index 42f5a64..e9ee498 100644 --- a/pkg/client/stream.go +++ b/pkg/client/stream.go @@ -95,7 +95,7 @@ func OpenStream[SendType, RecvType proto.Message]( } if i.RequireClaim { - // Streams negotiate on a nil resChan, so queue-ness cannot matter here. + // nil resChan, so queue-ness is moot sel, err := selectServer(ctx, claimChan, nil, o.SelectionOpts, false) if err != nil { _ = cs.Close(err) diff --git a/pkg/server/rpc.go b/pkg/server/rpc.go index 4337a9d..296f9cf 100644 --- a/pkg/server/rpc.go +++ b/pkg/server/rpc.go @@ -219,11 +219,7 @@ func (h *rpcHandlerImpl[RequestType, ResponseType]) claimRequest( affinity = 1 } - // A queue subscription already chose this server, so the claim can be - // announced rather than negotiated. Both ends have to agree: the caller - // advertises that it can accept an announcement, and this server elects to - // make one. Queue is re-checked because announcing on a broadcast rpc would - // let every server run the handler. + // The queue re-check keeps a broadcast rpc from running on every server. serverSkip := s.SkipClaim != nil && s.SkipClaim() handling := ir.SkipClaim && serverSkip && h.i.Queue diff --git a/server.go b/server.go index ee8c1c5..bc41621 100644 --- a/server.go +++ b/server.go @@ -36,11 +36,8 @@ type ServerOpts struct { SkipClaim func() bool } -// WithServerSkipClaim lets this server announce that it is handling a queue rpc -// rather than negotiating a claim, for every request whose caller advertised -// that it can accept one. Only sound if the bus delivers a queue subscription to -// exactly one subscriber. Consulted per request so it can be revoked at runtime -// without a redeploy, and disabled when unset. +// WithServerSkipClaim answers advertised queue rpcs with an announcement rather +// than a claim. Read per request, so revocable at runtime; off when unset. func WithServerSkipClaim(enabled func() bool) ServerOption { return func(o *ServerOpts) { o.SkipClaim = enabled