refactor(game): lock-free storage, remove /command, flatten engine wrapper
Tests · Go / test (push) Successful in 2m27s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m45s
Tests · Go / test (pull_request) Successful in 3m13s
Tests · UI / test (pull_request) Successful in 3m8s

Three-stage refactor of the game-engine plumbing (game logic untouched):

Stage 1 — lock-free persistence + admin serialisation. Remove the file
lock from repo/fs (the .lock file, the Read/Write-vs-*Safe duality and the
dead ReadSafe polling) and replace the two-step rename with a single atomic
rename so concurrent reads are torn-free without a lock. Serialise the
state-mutating admin writers (init/turn/banish) with one shared router
LimitMiddleware, rewritten to block on the request context instead of a
racy shared 100ms timer.

Stage 2 — remove the obsolete immediate-command path end to end. Players
submit through PUT /api/v1/order; the legacy PUT /api/v1/command path is
deleted across game (route, handler, 24 command factories, Ctrl), backend
(Commands handler/route, engineclient.ExecuteCommands), gateway (dispatch +
executeUserGamesCommand + routing entry), the FlatBuffers/model contract
(UserGamesCommand[Response]) and transcoder, plus every affected
OpenAPI/README/FUNCTIONAL/ARCHITECTURE doc. The integration proxy test is
converted to the order path.

Stage 3 — flatten the REST->engine wrapper. Replace the executor adapter,
the controller package functions and RepoController with one concrete
controller.Service; drop the single-implementation Repo and Storage
interfaces (repo.Repo / fs.FS are now concrete). Handlers depend on a thin
handler.Engine seam and own the domain->REST projection; storage is
resolved once at startup instead of per request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-30 13:37:07 +02:00
parent e36d33482f
commit 601970b028
65 changed files with 681 additions and 2804 deletions
+88
View File
@@ -1,6 +1,7 @@
package router_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -9,6 +10,7 @@ import (
"sync"
"sync/atomic"
"testing"
"time"
"galaxy/model/rest"
@@ -38,6 +40,92 @@ func TestLimitConnections(t *testing.T) {
wg.Wait()
}
// TestLimitSharedInstanceSerialisesRoutes pins the property the engine relies
// on to serialise state mutations: a single LimitMiddleware(1) instance shared
// across several routes admits at most one request across all of them at a
// time. The handler tracks the high-water concurrency and asserts it never
// exceeds one.
func TestLimitSharedInstanceSerialisesRoutes(t *testing.T) {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
shared := router.LimitMiddleware(1)
var inFlight, maxSeen atomic.Int32
handler := func(c *gin.Context) {
n := inFlight.Add(1)
for {
cur := maxSeen.Load()
if n <= cur || maxSeen.CompareAndSwap(cur, n) {
break
}
}
time.Sleep(time.Millisecond) // widen the overlap window
inFlight.Add(-1)
c.Status(http.StatusOK)
}
r.GET("/a", shared, handler)
r.PUT("/b", shared, handler)
wg := sync.WaitGroup{}
for i := range 200 {
method, path := http.MethodGet, "/a"
if i%2 == 1 {
method, path = http.MethodPut, "/b"
}
wg.Go(func() {
w := httptest.NewRecorder()
req, _ := http.NewRequest(method, path, nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body)
})
}
wg.Wait()
assert.Equal(t, int32(1), maxSeen.Load(), "a shared limiter must serialise across every route it guards")
}
// TestLimitReleasesOnContextCancel verifies the wait path: while one request
// holds the only slot, a second request blocked on the limiter answers 503
// once its request context is cancelled, instead of hanging.
func TestLimitReleasesOnContextCancel(t *testing.T) {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
shared := router.LimitMiddleware(1)
entered := make(chan struct{})
release := make(chan struct{})
r.GET("/hold", shared, func(c *gin.Context) {
close(entered)
<-release
c.Status(http.StatusOK)
})
// First request grabs and holds the only slot.
go func() {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/hold", nil)
r.ServeHTTP(w, req)
}()
<-entered
// Second request blocks on the limiter, then loses its context.
ctx, cancel := context.WithCancel(context.Background())
w := httptest.NewRecorder()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/hold", nil)
done := make(chan struct{})
go func() {
r.ServeHTTP(w, req)
close(done)
}()
cancel()
<-done
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
close(release)
}
func asBody(body any) *strings.Reader {
commandJson, _ := json.Marshal(body)
return strings.NewReader(string(commandJson))