Repository navigation
Fix: processor allocator handler concurrency and reconnect issues - #4789
lacroixthomas wants to merge 4 commits into
Conversation
Signed-off-by: Thomas Lacroix <thomas.lacroix@epitech.eu>
There was a problem hiding this comment.
🟡 Changes recommended
Unbounded in-flight batches can exhaust processor resources under sustained overload.
2 open issues
What changed in this PR
Prepares the processor allocator for batch integration by improving stream concurrency, reconnect handling, and configuration wiring.
Changes:
- Serializes stream sends and waits for concurrent batch responses.
- Prevents stale streams from unregistering reconnected clients, with regression coverage.
- Binds processor environment settings and exposes them through the extensions deployment.
| File | Description |
|---|---|
| pkg/gameserverallocations/processor/handler.go | Adds send locking, concurrent batches, and stream-aware cleanup. |
| pkg/gameserverallocations/processor/handler_test.go | Tests stale-stream removal after reconnect. |
| install/helm/agones/templates/extensions-deployment.yaml | Passes feature-gated processor settings. |
| cmd/extensions/main.go | Binds processor environment variables. |
| cmd/allocator/main.go | Binds processor environment variables. |
Review effort: Balanced
| batches.Go(func() { | ||
| response := h.submitBatch(stream.Context(), batchID, requestWrappers) |
There was a problem hiding this comment.
Each batch runs under stream.Context(), and that context has no deadline. It only ends when the stream closes.
Should this be something like:
| 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?
|
Build Succeeded 🥳 Build Id: b7e7292a-90c3-4569-a3fc-46c376ec4276 The following development artifacts have been built, and will exist for the next 30 days:
A preview of the website (the last 30 builds are retained): To install this version: |
Signed-off-by: Thomas Lacroix <thomas.lacroix@epitech.eu>
|
Build Failed 😭 Build Id: 6967b58e-0726-4b34-a931-42245b5df9c1 Status: FAILURE To get permission to view the Cloud Build view, join the agones-discuss Google Group. |
Signed-off-by: Thomas Lacroix <thomas.lacroix@epitech.eu>
|
Build Succeeded 🥳 Build Id: be1b3d98-0314-45d0-9aa5-ed8e58760315 The following development artifacts have been built, and will exist for the next 30 days:
A preview of the website (the last 30 builds are retained): To install this version: |
nrwiersma
left a comment
There was a problem hiding this comment.
I only have a cursory overview of how the allocator works, so take the review with a pinch of salt. That said, it looks good to me, with a single convention nitpick.
| inFlight chan struct{} | ||
| mu sync.Mutex |
There was a problem hiding this comment.
Nit: conventionally, mu is above what it protects.
| done := runStreamBatches(h, stream) | ||
|
|
||
| // pull requests race with the batch responses | ||
| for range 50 { | ||
| h.sendPullRequestsToClients() |
|
I have some review comments pending - I just need to find some time to do it! |
|
|
||
| // 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 |
There was a problem hiding this comment.
Where does this number come from? Why 10?
| batches.Go(func() { | ||
| response := h.submitBatch(stream.Context(), batchID, requestWrappers) |
There was a problem hiding this comment.
Each batch runs under stream.Context(), and that context has no deadline. It only ends when the stream closes.
Should this be something like:
| 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?
| // 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 }() |
There was a problem hiding this comment.
Could this lock if nothing is in this channel at the end of the batch processing?
| // 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 { | ||
| if len(s.inFlight) == cap(s.inFlight) || !s.mu.TryLock() { |
There was a problem hiding this comment.
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?
|
|
||
| // 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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 !
|
|
||
| // sends must be over before the handler returns | ||
| var batches sync.WaitGroup | ||
| defer batches.Wait() |
There was a problem hiding this comment.
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.
| select { | ||
| case locked.inFlight <- struct{}{}: | ||
| case <-stream.Context().Done(): | ||
| return stream.Context().Err() |
There was a problem hiding this comment.
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().
There was a problem hiding this comment.
Oooh, that's a good catch
There was a problem hiding this comment.
Also gonna add a unit test for that 👌🏼
| } | ||
| if err := stream.Send(pullMsg); err != nil { | ||
| send := stream.Send | ||
| if ls, ok := stream.(*lockedStream); ok { |
There was a problem hiding this comment.
Could this ever not be a lockedStream?
Should h.clients be map[string]*lockedStream ?
There was a problem hiding this comment.
Gonna update that, it definitively should, way cleaner
| defer h.mu.Unlock() | ||
|
|
||
| delete(h.clients, clientID) | ||
| if h.clients[clientID] == stream { |
There was a problem hiding this comment.
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.



What type of PR is this?
What this PR does / Why we need it:
Fixes issues in the processor allocator code, found while integrating the batch allocator #4559:
Serialize
Sendon the batch stream with a mutex. Batch responses and pull requests are sent from different goroutines, and gRPC doesn't allow concurrent sends on same streamWait for in-flight batch sends before the stream handler returns
Only unregister a client if its stream is still the registered one, so a stale stream can't evict a client that reconnected with the same ID
Fixing config in the allocator and extensions
main.goand the extensions Helm deploymentWhich issue(s) this PR fixes:
Part of #4190
Did you use AI tools in preparing this PR?:
Y
Special notes for your reviewer:
This is a first part of the split of #4559