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
3 changes: 3 additions & 0 deletions cmd/allocator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ func parseEnvFlags() config {
runtime.Must(viper.BindEnv(maxListItemsFlag))
runtime.Must(viper.BindEnv(readinessShutdownDuration))
runtime.Must(viper.BindEnv(httpUnallocatedStatusCode))
runtime.Must(viper.BindEnv(processorGRPCAddress))
runtime.Must(viper.BindEnv(processorGRPCPort))
runtime.Must(viper.BindEnv(processorMaxBatchSize))
runtime.Must(viper.BindPFlags(pflag.CommandLine))
runtime.Must(runtime.FeaturesBindEnv())

Expand Down
3 changes: 3 additions & 0 deletions cmd/extensions/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,9 @@ func parseEnvFlags() config {
runtime.Must(viper.BindEnv(webhookPort))
runtime.Must(viper.BindEnv(allocationBatchWaitTime))
runtime.Must(viper.BindEnv(maxListItemsFlag))
runtime.Must(viper.BindEnv(processorGRPCAddress))
runtime.Must(viper.BindEnv(processorGRPCPort))
runtime.Must(viper.BindEnv(processorMaxBatchSize))
runtime.Must(viper.BindPFlags(pflag.CommandLine))
runtime.Must(viper.BindEnv(readinessShutdownDuration))
runtime.Must(cloudproduct.BindEnv())
Expand Down
9 changes: 9 additions & 0 deletions install/helm/agones/templates/extensions-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,15 @@ spec:
value: {{ .Values.agones.extensions.webhooks.port | quote }}
- name: HTTP_PORT
value: {{ .Values.agones.extensions.http.port | quote }}
{{- $featureGates := include "agones.featureGates" . | fromYaml }}
{{- if $featureGates.ProcessorAllocator }}
- name: PROCESSOR_MAX_BATCH_SIZE
value: {{ .Values.agones.allocator.processor.maxBatchSize | quote }}
- name: PROCESSOR_GRPC_ADDRESS
value: {{ .Values.agones.allocator.processor.grpc.address | quote }}
- name: PROCESSOR_GRPC_PORT
value: {{ .Values.agones.allocator.processor.grpc.port | quote }}
{{- end }}
{{- if .Values.agones.extensions.env }}
{{- toYaml .Values.agones.extensions.env | nindent 8 }}
{{- end }}
Expand Down
9 changes: 7 additions & 2 deletions pkg/gameserverallocations/allocator.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,13 @@ func (c *Allocator) allocate(ctx context.Context, gsa *allocationv1.GameServerAl
return nil, ErrTotalTimeoutExceeded
}

// this pushes the request into the batching process
c.pendingRequests <- req
// this pushes the request into the batching process, giving up if the context is done while
// the queue is full
select {
case c.pendingRequests <- req:
case <-ctx.Done():
return nil, ErrTotalTimeoutExceeded
}

select {
case res := <-req.response: // wait for the batch to be completed
Expand Down
14 changes: 14 additions & 0 deletions pkg/gameserverallocations/allocator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1115,3 +1115,17 @@ func newFakeAllocator() (*Allocator, agtesting.Mocks) {

return a, m
}

func TestAllocatorAllocateStopsWaitingOnFullQueue(t *testing.T) {
t.Parallel()

// a full queue that nothing reads from, as when allocation is stalled
allocator := &Allocator{pendingRequests: make(chan request, 1)}
allocator.pendingRequests <- request{}

ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
defer cancel()

_, err := allocator.allocate(ctx, &allocationv1.GameServerAllocation{})
assert.ErrorIs(t, err, ErrTotalTimeoutExceeded)
}
108 changes: 86 additions & 22 deletions pkg/gameserverallocations/processor/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ import (

var handlerLogger = runtime.NewLoggerWithSource("processorHandler")

// maxInFlightBatches is the maximum number of batches being processed at once for a single client stream
// Once reached, the handler stops receiving batches until one completes
const maxInFlightBatches = 10

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does this number come from? Why 10?


// GameServerAllocator represents the interface to allocate game servers.
type GameServerAllocator interface {
Allocate(ctx context.Context, gsa *allocationv1.GameServerAllocation) (k8sruntime.Object, error)
Expand All @@ -53,6 +57,35 @@ type allocationResult struct {
error *rpcstatus.Status
}

// lockedStream serialises the sends on a stream. gRPC does not allow concurrent Send calls on
// the same stream, but the pull request ticker and the batch goroutines (which each send their
// response once processed) can send at the same time, so the sends must take turns
type lockedStream struct {
allocationpb.Processor_StreamBatchesServer
// inFlight holds one token per batch being processed, which bounds the work queued for the stream
inFlight chan struct{}
mu sync.Mutex
Comment on lines +66 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: conventionally, mu is above what it protects.

}

// Send sends the message, one at a time
func (s *lockedStream) Send(msg *allocationpb.ProcessorMessage) error {
s.mu.Lock()
defer s.mu.Unlock()

return s.Processor_StreamBatchesServer.Send(msg)
}

// sendPull sends a pull request, unless the stream is at capacity or busy sending something else, as a
// pull is not needed then and must not hold up the pulls to the other clients.
func (s *lockedStream) sendPull(msg *allocationpb.ProcessorMessage) error {

@markmandel markmandel Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A skipped pull returns nil, so removeClient at handler:253 is never reached for a stream that is at capacity or stuck in Send.

Scenario: the client stops reading, and a response Send blocks on HTTP/2 flow control while holding s.mu. Every later tick fails TryLock and returns nil, so the client stays registered forever. Before this change, the unconditional pull Send would fail and the client would be removed.

This feels like this goes back to the itmeout in previous review comments.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm true indeed, I'm gonna have a think about it, I might have an idea of how to catch that and also make things simple around this "lockedStream" part, might gonna create another file to handle all this part to keep things simpler on the handler.go, Will poke you back once I have something that fixes that and a bit cleaner - got something locally, but want to improve it 👌🏼
Also, pretty good catch !

if len(s.inFlight) == cap(s.inFlight) || !s.mu.TryLock() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The client only sends batches after it gets a pull. If a tick lands while a batch response holds s.mu, the pull is skipped. Pending requests then wait at least one more pullInterval (200ms by default).

I assume this is by design to allow time for each batch to process?

return nil
}
defer s.mu.Unlock()

return s.Processor_StreamBatchesServer.Send(msg)
}

// Handler is the gRPC server for processing allocation requests.
type Handler struct {
allocationpb.UnimplementedProcessorServer
Expand Down Expand Up @@ -93,8 +126,20 @@ func (h *Handler) StreamBatches(stream allocationpb.Processor_StreamBatchesServe
return status.Error(codes.InvalidArgument, "clientID is required")
}

// batch responses and pull requests are sent from different goroutines, and a gRPC stream
// does not allow concurrent sends
locked := &lockedStream{
Processor_StreamBatchesServer: stream,
inFlight: make(chan struct{}, maxInFlightBatches),
}
stream = locked

h.addClient(clientID, stream)
defer h.removeClient(clientID)
defer h.removeClient(clientID, stream)

// sends must be over before the handler returns
var batches sync.WaitGroup
defer batches.Wait()

@markmandel markmandel Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stream.Context() is only cancelled after the handler returns. So once the client half-closes (Recv returns io.EOF at handler.go:147-154, the deferred Wait blocks on batch goroutines whose context will never be cancelled.

May we worth the effort then to also make the context cancellable as well for this reason to ensure it finishes when the system shuts down.

handlerLogger.WithField(logFieldClientID, clientID).Debug("Client registered")

// Main loop: handle incoming messages
Expand Down Expand Up @@ -131,24 +176,36 @@ func (h *Handler) StreamBatches(stream allocationpb.Processor_StreamBatchesServe
logFieldRequestCount: len(requestWrappers),
}).Debug("Received batch request")

// Submit batch for processing
response := h.submitBatch(stream.Context(), batchID, requestWrappers)

respMsg := &allocationpb.ProcessorMessage{
ClientId: clientID,
Payload: &allocationpb.ProcessorMessage_BatchResponse{
BatchResponse: response,
},
// Wait for capacity before starting more work, so that a stalled allocator applies backpressure
// to the client instead of piling up goroutines and memory
select {
case locked.inFlight <- struct{}{}:
case <-stream.Context().Done():
return stream.Context().Err()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

grpc-go turns an error that isn't a status into codes.Unknown. So if a client cancels while 10 batches are in flight, it sees Unknown instead of Canceled or DeadlineExceeded, and retry or backoff logic that checks the code treats it as an unknown server failure.

Suggestion: return status.FromContextError(stream.Context().Err()).Err().

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oooh, that's a good catch

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also gonna add a unit test for that 👌🏼

}

if err := stream.Send(respMsg); err != nil {
handlerLogger.WithFields(logrus.Fields{
logFieldClientID: clientID,
logFieldBatchID: batchID,
logFieldRequestCount: len(requestWrappers),
}).WithError(err).Error("Failed to send response")
continue
}
// Process the batch on its own, so that a slow request only delays the response of its own batch,
// and not the receipt and processing of the next ones.
batches.Go(func() {
defer func() { <-locked.inFlight }()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this lock if nothing is in this channel at the end of the batch processing?


response := h.submitBatch(stream.Context(), batchID, requestWrappers)
Comment thread
Copilot marked this conversation as resolved.
Resolved
Comment on lines +189 to +192

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each batch runs under stream.Context(), and that context has no deadline. It only ends when the stream closes.

Should this be something like:

Suggested change
batches.Go(func() {
response := h.submitBatch(stream.Context(), batchID, requestWrappers)
batches.Go(func() {
defer func() { <-locked.inFlight }()
response := h.submitBatch(context.WithTimeout(stream.Context(), maxTimeout), batchID, requestWrappers)

or similar?


respMsg := &allocationpb.ProcessorMessage{
ClientId: clientID,
Payload: &allocationpb.ProcessorMessage_BatchResponse{
BatchResponse: response,
},
}

if err := stream.Send(respMsg); err != nil {
handlerLogger.WithFields(logrus.Fields{
logFieldClientID: clientID,
logFieldBatchID: batchID,
logFieldRequestCount: len(requestWrappers),
}).WithError(err).Error("Failed to send response")
}
})
}
}

Expand Down Expand Up @@ -184,12 +241,16 @@ func (h *Handler) sendPullRequestsToClients() {
Pull: &allocationpb.PullRequest{Message: "pull"},
},
}
if err := stream.Send(pullMsg); err != nil {
send := stream.Send
if ls, ok := stream.(*lockedStream); ok {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this ever not be a lockedStream?

Should h.clients be map[string]*lockedStream ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gonna update that, it definitively should, way cleaner

send = ls.sendPull
}
if err := send(pullMsg); err != nil {
handlerLogger.WithFields(logrus.Fields{
logFieldClientID: clientID,
"error": err,
}).Warn("Failed to send pull request, removing client")
h.removeClient(clientID)
h.removeClient(clientID, stream)
}
}
}
Expand Down Expand Up @@ -314,10 +375,13 @@ func (h *Handler) addClient(clientID string, stream allocationpb.Processor_Strea
h.clients[clientID] = stream
}

// removeClient unregisters a client from streaming allocation responses.
func (h *Handler) removeClient(clientID string) {
// removeClient unregisters the stream of a client from streaming allocation responses. It is a no-op if the
// client has registered another stream since, as a client reconnects with the same ID.
func (h *Handler) removeClient(clientID string, stream allocationpb.Processor_StreamBatchesServer) {
h.mu.Lock()
defer h.mu.Unlock()

delete(h.clients, clientID)
if h.clients[clientID] == stream {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comparing interface values with == panics at runtime if the dynamic type can't be compared, for example a struct value holding a slice, map or func field. The panic would happen in deferred cleanup and crash the processor. The old delete(h.clients, id) could not panic. Item 6's typed map also fixes this, since *lockedStream pointers can always be compared.

delete(h.clients, clientID)
}
}
Loading
Loading