docs: reorder & testing
This commit is contained in:
+50
-62
@@ -346,6 +346,12 @@ The current direct `Gateway -> User` self-service boundary uses that pattern:
|
||||
- `user.account.get`
|
||||
- `user.profile.update`
|
||||
- `user.settings.update`
|
||||
- `user.sessions.list`
|
||||
- `user.sessions.revoke`
|
||||
- `user.sessions.revoke_all`
|
||||
- `user.games.command`
|
||||
- `user.games.order`
|
||||
- `user.games.report`
|
||||
- external payloads and responses:
|
||||
- FlatBuffers
|
||||
- internal downstream transport:
|
||||
@@ -479,20 +485,25 @@ payload only: `user_id`, optional `device_session_id`, `event_type`,
|
||||
gateway derives `timestamp_ms`, recomputes `payload_hash`, signs the event,
|
||||
and only then forwards it to the matching `SubscribeEvents` streams.
|
||||
|
||||
Notification-owned user-facing payloads are expected to use
|
||||
`pkg/schema/fbs/notification.fbs`. The initial notification event vocabulary
|
||||
in v1 is exactly:
|
||||
Notification-owned user-facing payloads use
|
||||
`pkg/schema/fbs/notification.fbs`. Each catalog kind has a 1:1
|
||||
FlatBuffers table named with the camel-case form of the kind plus the
|
||||
`Event` suffix. The closed v1 vocabulary is exactly the 13 kinds
|
||||
defined in `backend/internal/notification/catalog.go`:
|
||||
|
||||
- `game.turn.ready`
|
||||
- `game.finished`
|
||||
- `lobby.invite.received`
|
||||
- `lobby.invite.revoked`
|
||||
- `lobby.application.submitted`
|
||||
- `lobby.membership.approved`
|
||||
- `lobby.membership.rejected`
|
||||
- `lobby.application.approved`
|
||||
- `lobby.application.rejected`
|
||||
- `lobby.membership.removed`
|
||||
- `lobby.membership.blocked`
|
||||
- `lobby.invite.created`
|
||||
- `lobby.invite.redeemed`
|
||||
- `lobby.race_name.registration_eligible`
|
||||
- `lobby.race_name.registered`
|
||||
- `lobby.race_name.pending`
|
||||
- `lobby.race_name.expired`
|
||||
- `runtime.image_pull_failed` (admin recipient)
|
||||
- `runtime.container_start_failed` (admin recipient)
|
||||
- `runtime.start_config_invalid` (admin recipient)
|
||||
|
||||
`lobby.application.submitted` is published toward `Gateway` only for the
|
||||
private-game owner flow. The public-game variant is email-only.
|
||||
@@ -589,68 +600,45 @@ Expected session fields available to the gateway:
|
||||
|
||||
### Session Cache
|
||||
|
||||
`SessionCache` provides the fast path for:
|
||||
`SessionCache` is the in-memory LRU + TTL store fronting every
|
||||
authenticated request. It serves the hot path for:
|
||||
|
||||
- session existence checks;
|
||||
- `device_session_id -> user_id`;
|
||||
- `device_session_id → user_id`;
|
||||
- access to the base64-encoded raw Ed25519 client public key used for
|
||||
signature verification;
|
||||
- revoked versus active status checks.
|
||||
- active vs revoked status checks.
|
||||
|
||||
Cache updates are event-driven.
|
||||
TTL is allowed only as a safety net and must not replace invalidation events.
|
||||
Implementation: a bounded LRU map (default 50 000 entries) wrapped by a
|
||||
safety-net TTL (default 10 minutes). On miss the cache calls
|
||||
`/api/v1/internal/sessions/{id}` against backend and seeds the entry.
|
||||
`session_invalidation` push frames flip the cached entry's status to
|
||||
`revoked` so subsequent authenticated requests are rejected at the edge
|
||||
without another backend round-trip. The TTL covers the case of a missed
|
||||
event (cursor aged out, gateway restart) by forcing a fresh backend
|
||||
lookup at most once per window.
|
||||
|
||||
The gateway keeps a process-local in-memory snapshot
|
||||
cache in front of the Redis fallback backend. Authenticated requests read the
|
||||
local snapshot first. A local miss performs one bounded Redis lookup and seeds
|
||||
the local snapshot so later requests for the same session avoid another Redis
|
||||
round-trip unless a later session event changes the cached state.
|
||||
The cache is process-local and unsynchronised across gateway instances.
|
||||
The MVP ships a single gateway instance (see
|
||||
`docs/ARCHITECTURE.md §18`); multi-instance scale-out is a later step
|
||||
that may revisit the topology.
|
||||
|
||||
The local snapshot cache intentionally has no TTL and no size-based
|
||||
eviction policy. Session lifecycle events are the authoritative mechanism for
|
||||
keeping the hot path current, while Redis fallback remains the safety net for
|
||||
cold misses and process restarts.
|
||||
Configuration:
|
||||
|
||||
The Redis fallback implementation uses `go-redis/v9`. `cmd/gateway` opens one
|
||||
shared `*redis.Client` via `pkg/redisconn` (instrumented with OpenTelemetry
|
||||
tracing and metrics), issues a single bounded `PING` on startup, and refuses
|
||||
to start when Redis is misconfigured or unavailable. The session cache,
|
||||
replay store, session-events subscriber, and client-events subscriber all
|
||||
use that shared client. See `docs/redis-config.md` for the rationale behind
|
||||
the shape and the project-wide rules in
|
||||
`ARCHITECTURE.md §Persistence Backends`.
|
||||
- `GATEWAY_SESSION_CACHE_MAX_ENTRIES` with default `50000`
|
||||
- `GATEWAY_SESSION_CACHE_TTL` with default `10m`
|
||||
|
||||
Required Redis connection variables:
|
||||
Redis is used by the gateway only for the authenticated Replay Store
|
||||
(see below). The shared client is opened via `pkg/redisconn` against
|
||||
`GATEWAY_REDIS_MASTER_ADDR` and `GATEWAY_REDIS_PASSWORD`; optional
|
||||
tuning lives under `GATEWAY_REDIS_REPLICA_ADDRS`, `GATEWAY_REDIS_DB`,
|
||||
and `GATEWAY_REDIS_OPERATION_TIMEOUT` (all documented in
|
||||
`docs/redis-config.md`).
|
||||
|
||||
- `GATEWAY_REDIS_MASTER_ADDR`
|
||||
- `GATEWAY_REDIS_PASSWORD`
|
||||
|
||||
Optional Redis connection variables:
|
||||
|
||||
- `GATEWAY_REDIS_REPLICA_ADDRS` (comma-separated; reserved for future
|
||||
read-routing — currently unused)
|
||||
- `GATEWAY_REDIS_DB` with default `0`
|
||||
- `GATEWAY_REDIS_OPERATION_TIMEOUT` with default `250ms`
|
||||
|
||||
> Removed: `GATEWAY_SESSION_CACHE_REDIS_ADDR`,
|
||||
> `GATEWAY_SESSION_CACHE_REDIS_USERNAME`,
|
||||
> `GATEWAY_SESSION_CACHE_REDIS_PASSWORD`,
|
||||
> `GATEWAY_SESSION_CACHE_REDIS_DB`,
|
||||
> `GATEWAY_SESSION_CACHE_REDIS_TLS_ENABLED`. `pkg/redisconn.LoadFromEnv`
|
||||
> rejects the deprecated `GATEWAY_REDIS_TLS_ENABLED` and
|
||||
> `GATEWAY_REDIS_USERNAME` variables at startup.
|
||||
|
||||
Per-subsystem Redis behavior variables (namespace, timeouts):
|
||||
|
||||
- `GATEWAY_REPLAY_REDIS_KEY_PREFIX` with default `gateway:replay:`
|
||||
- `GATEWAY_REPLAY_REDIS_RESERVE_TIMEOUT` with default `250ms`
|
||||
|
||||
Gateway no longer keeps a session cache projection or the two Redis
|
||||
Streams (`session_events`, `client_events`). Session lookup is a
|
||||
synchronous REST call to backend, and inbound client / session events
|
||||
arrive through the gRPC `Push.SubscribePush` consumer (see the
|
||||
**Backend Client** section below). Redis is therefore used only by
|
||||
the Replay Store.
|
||||
> Removed: the previous Redis-backed session-cache projection and its
|
||||
> environment variables (`GATEWAY_SESSION_CACHE_REDIS_*`,
|
||||
> `GATEWAY_REDIS_TLS_ENABLED`, `GATEWAY_REDIS_USERNAME`).
|
||||
> `pkg/redisconn.LoadFromEnv` rejects the deprecated names at startup.
|
||||
|
||||
### Backend Client
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// `galaxy/integration/testenv`) can reuse the canonical signing
|
||||
// input builders and the response/event verifiers without having to
|
||||
// duplicate the wire contract documented in
|
||||
// `../../ARCHITECTURE.md` §15.
|
||||
// `../../docs/ARCHITECTURE.md` §15.
|
||||
package authn
|
||||
|
||||
import (
|
||||
|
||||
@@ -153,7 +153,11 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
||||
)
|
||||
}
|
||||
|
||||
sessionCache, err := session.NewBackendCache(backend.REST())
|
||||
sessionCache, err := session.NewMemoryCache(backend.REST(), session.MemoryCacheOptions{
|
||||
MaxEntries: cfg.SessionCache.MaxEntries,
|
||||
TTL: cfg.SessionCache.TTL,
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: %w", err),
|
||||
@@ -171,20 +175,27 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
||||
|
||||
pushHub := push.NewHubWithObserver(0, telemetry.NewPushObserver(telemetryRuntime))
|
||||
|
||||
dispatcher := events.NewDispatcher(pushHub, pushHub, logger, telemetryRuntime)
|
||||
// Composite invalidator: every session_invalidation event flips the
|
||||
// cached record to revoked AND closes any active push subscription.
|
||||
invalidator := &cacheAndHubInvalidator{cache: sessionCache, hub: pushHub}
|
||||
dispatcher := events.NewDispatcher(pushHub, invalidator, logger, telemetryRuntime)
|
||||
pushClient := backend.Push().
|
||||
WithLogger(logger).
|
||||
WithHandler(dispatcher)
|
||||
|
||||
userRoutes := backendclient.UserRoutes(backend.REST())
|
||||
lobbyRoutes := backendclient.LobbyRoutes(backend.REST())
|
||||
allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes))
|
||||
gameRoutes := backendclient.GameRoutes(backend.REST())
|
||||
allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes)+len(gameRoutes))
|
||||
for k, v := range userRoutes {
|
||||
allRoutes[k] = v
|
||||
}
|
||||
for k, v := range lobbyRoutes {
|
||||
allRoutes[k] = v
|
||||
}
|
||||
for k, v := range gameRoutes {
|
||||
allRoutes[k] = v
|
||||
}
|
||||
|
||||
cleanup := func() error {
|
||||
return closeRedisClient()
|
||||
@@ -202,6 +213,40 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
||||
}, []app.Component{pushClient}, cleanup, nil
|
||||
}
|
||||
|
||||
// cacheAndHubInvalidator fans every session-invalidation push frame
|
||||
// out to both the session cache (so subsequent Lookups see the
|
||||
// session as revoked without a backend round-trip) and the push hub
|
||||
// (so any active SubscribeEvents stream bound to the session is
|
||||
// closed immediately). The shape matches `events.SessionInvalidator`.
|
||||
type cacheAndHubInvalidator struct {
|
||||
cache session.Cache
|
||||
hub *push.Hub
|
||||
}
|
||||
|
||||
func (c *cacheAndHubInvalidator) RevokeDeviceSession(deviceSessionID string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if c.cache != nil {
|
||||
c.cache.MarkRevoked(deviceSessionID)
|
||||
}
|
||||
if c.hub != nil {
|
||||
c.hub.RevokeDeviceSession(deviceSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cacheAndHubInvalidator) RevokeAllForUser(userID string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if c.cache != nil {
|
||||
c.cache.MarkAllRevokedForUser(userID)
|
||||
}
|
||||
if c.hub != nil {
|
||||
c.hub.RevokeAllForUser(userID)
|
||||
}
|
||||
}
|
||||
|
||||
// authServiceAdapter adapts backendclient.RESTClient to the
|
||||
// restapi.AuthServiceClient interface so the public REST handlers can stay
|
||||
// unchanged. The two surfaces share the same JSON wire shape; only the Go
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
ordermodel "galaxy/model/order"
|
||||
reportmodel "galaxy/model/report"
|
||||
gamerest "galaxy/model/rest"
|
||||
"galaxy/transcoder"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ExecuteGameCommand routes one authenticated `user.games.*` command
|
||||
// into backend's `/api/v1/user/games/{game_id}/*` endpoints. Command
|
||||
// and order requests transcode the typed FB-payload into the JSON
|
||||
// shape the engine expects (a `gamerest.Command` with empty actor —
|
||||
// backend rebinds the actor from the runtime player mapping). Report
|
||||
// requests transcode the response Report from JSON back to FB.
|
||||
func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return downstream.UnaryResult{}, errors.New("backendclient: execute game command: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return downstream.UnaryResult{}, errors.New("backendclient: execute game command: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(command.UserID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("backendclient: execute game command: user_id must not be empty")
|
||||
}
|
||||
|
||||
switch command.MessageType {
|
||||
case ordermodel.MessageTypeUserGamesCommand:
|
||||
req, err := transcoder.PayloadToUserGamesCommand(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUserGamesCommand(ctx, command.UserID, req)
|
||||
case ordermodel.MessageTypeUserGamesOrder:
|
||||
req, err := transcoder.PayloadToUserGamesOrder(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUserGamesOrder(ctx, command.UserID, req)
|
||||
case reportmodel.MessageTypeUserGamesReport:
|
||||
req, err := transcoder.PayloadToGameReportRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUserGamesReport(ctx, command.UserID, req)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command: unsupported message type %q", command.MessageType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeUserGamesCommand(ctx context.Context, userID string, req *ordermodel.UserGamesCommand) (downstream.UnaryResult, error) {
|
||||
if req.GameID == uuid.Nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute user.games.command: game_id must not be empty")
|
||||
}
|
||||
body, err := buildEngineCommandBody(req.Commands)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.command: %w", err)
|
||||
}
|
||||
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(req.GameID.String()) + "/commands"
|
||||
respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.command: %w", err)
|
||||
}
|
||||
return projectUserGamesAckResponse(status, respBody, transcoder.EmptyUserGamesCommandResponsePayload)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeUserGamesOrder(ctx context.Context, userID string, req *ordermodel.UserGamesOrder) (downstream.UnaryResult, error) {
|
||||
if req.GameID == uuid.Nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute user.games.order: game_id must not be empty")
|
||||
}
|
||||
body, err := buildEngineCommandBody(req.Commands)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.order: %w", err)
|
||||
}
|
||||
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(req.GameID.String()) + "/orders"
|
||||
respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.order: %w", err)
|
||||
}
|
||||
return projectUserGamesAckResponse(status, respBody, transcoder.EmptyUserGamesOrderResponsePayload)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeUserGamesReport(ctx context.Context, userID string, req *reportmodel.GameReportRequest) (downstream.UnaryResult, error) {
|
||||
if req.GameID == uuid.Nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute user.games.report: game_id must not be empty")
|
||||
}
|
||||
target := fmt.Sprintf("%s/api/v1/user/games/%s/reports/%d", c.baseURL, url.PathEscape(req.GameID.String()), req.Turn)
|
||||
respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.report: %w", err)
|
||||
}
|
||||
return projectUserGamesReportResponse(status, respBody)
|
||||
}
|
||||
|
||||
// buildEngineCommandBody serialises a slice of typed commands into the
|
||||
// JSON shape expected by backend's command/order handlers (a
|
||||
// `gamerest.Command` with the actor field left empty — backend rebinds
|
||||
// it from the runtime player mapping before forwarding to the engine).
|
||||
func buildEngineCommandBody(commands []ordermodel.DecodableCommand) (gamerest.Command, error) {
|
||||
raw := make([]json.RawMessage, len(commands))
|
||||
for i, cmd := range commands {
|
||||
encoded, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
return gamerest.Command{}, fmt.Errorf("encode command %d: %w", i, err)
|
||||
}
|
||||
raw[i] = encoded
|
||||
}
|
||||
return gamerest.Command{Actor: "", Commands: raw}, nil
|
||||
}
|
||||
|
||||
// projectUserGamesAckResponse turns a backend response for command /
|
||||
// order routes into a UnaryResult. Engine returns 204 on success, so
|
||||
// any 2xx status is treated as ok and answered with the empty typed
|
||||
// FB envelope produced by ackBuilder.
|
||||
func projectUserGamesAckResponse(statusCode int, payload []byte, ackBuilder func() []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode >= 200 && statusCode < 300:
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: userCommandResultCodeOK,
|
||||
PayloadBytes: ackBuilder(),
|
||||
}, nil
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
return projectUserBackendError(statusCode, payload)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// projectUserGamesReportResponse decodes the engine's Report JSON
|
||||
// payload (forwarded verbatim by backend) and re-encodes it as a
|
||||
// FlatBuffers Report for the signed-gRPC client.
|
||||
func projectUserGamesReportResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusOK:
|
||||
var report reportmodel.Report
|
||||
if err := json.Unmarshal(payload, &report); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode engine report: %w", err)
|
||||
}
|
||||
encoded, err := transcoder.ReportToPayload(&report)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode report payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: userCommandResultCodeOK,
|
||||
PayloadBytes: encoded,
|
||||
}, nil
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
return projectUserBackendError(statusCode, payload)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,10 @@ func TestPushClientDeliversClientEventsAndAdvancesCursor(t *testing.T) {
|
||||
require.Eventually(t, func() bool { return svc.Service.SubscriberCount() == 1 }, time.Second, 10*time.Millisecond)
|
||||
|
||||
userID := uuid.New()
|
||||
require.NoError(t, svc.Service.PublishClientEvent(context.Background(), userID, nil, "lobby.invite.received", map[string]any{"x": 1.0}, "evt-1", "req-1", "trace-1"))
|
||||
require.NoError(t, svc.Service.PublishClientEvent(context.Background(), userID, nil, backendpush.JSONEvent{
|
||||
EventKind: "lobby.invite.received",
|
||||
Payload: map[string]any{"x": 1.0},
|
||||
}, "evt-1", "req-1", "trace-1"))
|
||||
|
||||
select {
|
||||
case got := <-out:
|
||||
|
||||
@@ -98,45 +98,6 @@ func (c *RESTClient) LookupSession(ctx context.Context, deviceSessionID string)
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeSession asks backend to revoke a single device session by id.
|
||||
func (c *RESTClient) RevokeSession(ctx context.Context, deviceSessionID string) error {
|
||||
if strings.TrimSpace(deviceSessionID) == "" {
|
||||
return errors.New("backendclient: revoke session: device_session_id must not be empty")
|
||||
}
|
||||
target := c.baseURL + "/api/v1/internal/sessions/" + url.PathEscape(deviceSessionID) + "/revoke"
|
||||
_, status, err := c.do(ctx, http.MethodPost, target, "", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient: revoke session: %w", err)
|
||||
}
|
||||
if status == http.StatusOK || status == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
if status == http.StatusNotFound {
|
||||
return errSessionNotFound()
|
||||
}
|
||||
return fmt.Errorf("backendclient: revoke session: unexpected HTTP status %d", status)
|
||||
}
|
||||
|
||||
// RevokeAllSessionsForUser asks backend to revoke every active device
|
||||
// session belonging to userID.
|
||||
func (c *RESTClient) RevokeAllSessionsForUser(ctx context.Context, userID string) error {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return errors.New("backendclient: revoke-all sessions: user_id must not be empty")
|
||||
}
|
||||
target := c.baseURL + "/api/v1/internal/sessions/users/" + url.PathEscape(userID) + "/revoke-all"
|
||||
_, status, err := c.do(ctx, http.MethodPost, target, "", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient: revoke-all sessions: %w", err)
|
||||
}
|
||||
if status == http.StatusOK || status == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
if status == http.StatusNotFound {
|
||||
return errSessionNotFound()
|
||||
}
|
||||
return fmt.Errorf("backendclient: revoke-all sessions: unexpected HTTP status %d", status)
|
||||
}
|
||||
|
||||
// do executes a JSON request and reads the response body. userID, when
|
||||
// non-empty, is sent as the X-User-Id header (required for `/api/v1/user/*`).
|
||||
func (c *RESTClient) do(ctx context.Context, method, target, userID string, body any) ([]byte, int, error) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
lobbymodel "galaxy/model/lobby"
|
||||
ordermodel "galaxy/model/order"
|
||||
reportmodel "galaxy/model/report"
|
||||
usermodel "galaxy/model/user"
|
||||
)
|
||||
|
||||
@@ -18,9 +20,12 @@ func UserRoutes(client *RESTClient) map[string]downstream.Client {
|
||||
target = userCommandClient{rest: client}
|
||||
}
|
||||
return map[string]downstream.Client{
|
||||
usermodel.MessageTypeGetMyAccount: target,
|
||||
usermodel.MessageTypeUpdateMyProfile: target,
|
||||
usermodel.MessageTypeUpdateMySettings: target,
|
||||
usermodel.MessageTypeGetMyAccount: target,
|
||||
usermodel.MessageTypeUpdateMyProfile: target,
|
||||
usermodel.MessageTypeUpdateMySettings: target,
|
||||
usermodel.MessageTypeListMySessions: target,
|
||||
usermodel.MessageTypeRevokeMySession: target,
|
||||
usermodel.MessageTypeRevokeAllMySessions: target,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +43,22 @@ func LobbyRoutes(client *RESTClient) map[string]downstream.Client {
|
||||
}
|
||||
}
|
||||
|
||||
// GameRoutes returns the authenticated `user.games.*` downstream
|
||||
// routes served by backend (which in turn forwards to the running
|
||||
// game engine container). When client is nil every route resolves to
|
||||
// a dependency-unavailable client.
|
||||
func GameRoutes(client *RESTClient) map[string]downstream.Client {
|
||||
target := downstream.Client(unavailableClient{})
|
||||
if client != nil {
|
||||
target = gameCommandClient{rest: client}
|
||||
}
|
||||
return map[string]downstream.Client{
|
||||
ordermodel.MessageTypeUserGamesCommand: target,
|
||||
ordermodel.MessageTypeUserGamesOrder: target,
|
||||
reportmodel.MessageTypeUserGamesReport: target,
|
||||
}
|
||||
}
|
||||
|
||||
type unavailableClient struct{}
|
||||
|
||||
func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
@@ -60,8 +81,17 @@ func (c lobbyCommandClient) ExecuteCommand(ctx context.Context, command downstre
|
||||
return c.rest.ExecuteLobbyCommand(ctx, command)
|
||||
}
|
||||
|
||||
type gameCommandClient struct {
|
||||
rest *RESTClient
|
||||
}
|
||||
|
||||
func (c gameCommandClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
return c.rest.ExecuteGameCommand(ctx, command)
|
||||
}
|
||||
|
||||
var (
|
||||
_ downstream.Client = unavailableClient{}
|
||||
_ downstream.Client = userCommandClient{}
|
||||
_ downstream.Client = lobbyCommandClient{}
|
||||
_ downstream.Client = gameCommandClient{}
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
@@ -59,6 +60,22 @@ func (c *RESTClient) ExecuteUserCommand(ctx context.Context, command downstream.
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute user command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUserAccountUpdateSettings(ctx, command.UserID, req)
|
||||
case usermodel.MessageTypeListMySessions:
|
||||
if _, err := transcoder.PayloadToListMySessionsRequest(command.PayloadBytes); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute user command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUserSessionsList(ctx, command.UserID)
|
||||
case usermodel.MessageTypeRevokeMySession:
|
||||
req, err := transcoder.PayloadToRevokeMySessionRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute user command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUserSessionsRevoke(ctx, command.UserID, req)
|
||||
case usermodel.MessageTypeRevokeAllMySessions:
|
||||
if _, err := transcoder.PayloadToRevokeAllMySessionsRequest(command.PayloadBytes); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute user command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUserSessionsRevokeAll(ctx, command.UserID)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute user command: unsupported message type %q", command.MessageType)
|
||||
}
|
||||
@@ -88,6 +105,124 @@ func (c *RESTClient) executeUserAccountUpdateSettings(ctx context.Context, userI
|
||||
return projectUserResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeUserSessionsList(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
||||
body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/sessions", userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.sessions.list: %w", err)
|
||||
}
|
||||
return projectUserSessionsListResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeUserSessionsRevoke(ctx context.Context, userID string, req *usermodel.RevokeMySessionRequest) (downstream.UnaryResult, error) {
|
||||
if strings.TrimSpace(req.DeviceSessionID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute user.sessions.revoke: device_session_id must not be empty")
|
||||
}
|
||||
target := c.baseURL + "/api/v1/user/sessions/" + url.PathEscape(req.DeviceSessionID) + "/revoke"
|
||||
body, status, err := c.do(ctx, http.MethodPost, target, userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.sessions.revoke: %w", err)
|
||||
}
|
||||
return projectUserSessionRevokeResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeUserSessionsRevokeAll(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
||||
body, status, err := c.do(ctx, http.MethodPost, c.baseURL+"/api/v1/user/sessions/revoke-all", userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user.sessions.revoke_all: %w", err)
|
||||
}
|
||||
return projectUserSessionsRevokeAllResponse(status, body)
|
||||
}
|
||||
|
||||
func projectUserSessionsListResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusOK:
|
||||
var response usermodel.ListMySessionsResponse
|
||||
if err := decodeStrictJSON(payload, &response); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
payloadBytes, err := transcoder.ListMySessionsResponseToPayload(&response)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: userCommandResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
return projectUserBackendError(statusCode, payload)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func projectUserSessionRevokeResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusOK:
|
||||
var session usermodel.DeviceSession
|
||||
if err := decodeStrictJSON(payload, &session); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
payloadBytes, err := transcoder.RevokeMySessionResponseToPayload(&usermodel.RevokeMySessionResponse{Session: session})
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: userCommandResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
return projectUserBackendError(statusCode, payload)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func projectUserSessionsRevokeAllResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusOK:
|
||||
var summary usermodel.DeviceSessionRevocationSummary
|
||||
if err := decodeStrictJSON(payload, &summary); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
payloadBytes, err := transcoder.RevokeAllMySessionsResponseToPayload(&usermodel.RevokeAllMySessionsResponse{Summary: summary})
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: userCommandResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
return projectUserBackendError(statusCode, payload)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// projectUserBackendError shares the error-projection path between every
|
||||
// user-command projector. The error envelope is identical regardless of
|
||||
// the success-path payload shape.
|
||||
func projectUserBackendError(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
errResp, err := decodeUserError(statusCode, payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err)
|
||||
}
|
||||
payloadBytes, err := transcoder.ErrorResponseToPayload(errResp)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: errResp.Error.Code,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func projectUserResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusOK:
|
||||
|
||||
@@ -166,6 +166,14 @@ const (
|
||||
// rate-limit burst.
|
||||
authenticatedGRPCMessageClassRateLimitBurstEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST"
|
||||
|
||||
// sessionCacheMaxEntriesEnvVar names the environment variable that configures
|
||||
// the in-memory session cache LRU bound (entries).
|
||||
sessionCacheMaxEntriesEnvVar = "GATEWAY_SESSION_CACHE_MAX_ENTRIES"
|
||||
|
||||
// sessionCacheTTLEnvVar names the environment variable that configures the
|
||||
// in-memory session cache safety-net TTL applied to every cached entry.
|
||||
sessionCacheTTLEnvVar = "GATEWAY_SESSION_CACHE_TTL"
|
||||
|
||||
// replayRedisKeyPrefixEnvVar names the environment variable that configures
|
||||
// the Redis key prefix used for authenticated replay reservations.
|
||||
replayRedisKeyPrefixEnvVar = "GATEWAY_REPLAY_REDIS_KEY_PREFIX"
|
||||
@@ -309,6 +317,9 @@ const (
|
||||
defaultAuthenticatedGRPCMessageClassRateLimitRequests = 60
|
||||
defaultAuthenticatedGRPCMessageClassRateLimitBurst = 20
|
||||
|
||||
defaultSessionCacheMaxEntries = 50_000
|
||||
defaultSessionCacheTTL = 10 * time.Minute
|
||||
|
||||
defaultReplayRedisKeyPrefix = "gateway:replay:"
|
||||
defaultReplayRedisReserveTimeout = 250 * time.Millisecond
|
||||
|
||||
@@ -521,6 +532,21 @@ type AuthenticatedGRPCConfig struct {
|
||||
AntiAbuse AuthenticatedGRPCAntiAbuseConfig
|
||||
}
|
||||
|
||||
// SessionCacheConfig describes the bounds of the gateway's in-memory
|
||||
// session cache. The cache fronts every authenticated request and
|
||||
// falls back to a synchronous backend lookup on miss; push-event
|
||||
// driven invalidations flip cached records to revoked status without
|
||||
// a backend roundtrip.
|
||||
type SessionCacheConfig struct {
|
||||
// MaxEntries bounds the LRU. Zero or negative values fall back to
|
||||
// the package default at construction time.
|
||||
MaxEntries int
|
||||
|
||||
// TTL is the safety-net freshness window applied to every cached
|
||||
// entry. Zero or negative values fall back to the package default.
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// ReplayRedisConfig describes the Redis namespace and timeout used for
|
||||
// authenticated replay reservations.
|
||||
type ReplayRedisConfig struct {
|
||||
@@ -577,6 +603,10 @@ type Config struct {
|
||||
// Streams; Redis is now used only for replay reservations.
|
||||
Redis redisconn.Config
|
||||
|
||||
// SessionCache configures the in-memory session cache fronting
|
||||
// every authenticated request.
|
||||
SessionCache SessionCacheConfig
|
||||
|
||||
// ReplayRedis configures the Redis-backed authenticated ReplayStore.
|
||||
ReplayRedis ReplayRedisConfig
|
||||
|
||||
@@ -699,6 +729,15 @@ func DefaultReplayRedisConfig() ReplayRedisConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultSessionCacheConfig returns the default LRU bound and safety-net TTL
|
||||
// used by the in-memory session cache.
|
||||
func DefaultSessionCacheConfig() SessionCacheConfig {
|
||||
return SessionCacheConfig{
|
||||
MaxEntries: defaultSessionCacheMaxEntries,
|
||||
TTL: defaultSessionCacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultBackendConfig returns the default backend settings used for the
|
||||
// gateway → backend HTTP and gRPC conversation. URL fields stay empty and
|
||||
// must be supplied explicitly via env vars.
|
||||
@@ -727,6 +766,7 @@ func LoadFromEnv() (Config, error) {
|
||||
AdminHTTP: DefaultAdminHTTPConfig(),
|
||||
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
|
||||
Redis: redisconn.DefaultConfig(),
|
||||
SessionCache: DefaultSessionCacheConfig(),
|
||||
ReplayRedis: DefaultReplayRedisConfig(),
|
||||
ResponseSigner: DefaultResponseSignerConfig(),
|
||||
}
|
||||
@@ -895,6 +935,18 @@ func LoadFromEnv() (Config, error) {
|
||||
}
|
||||
cfg.Redis = redisConn
|
||||
|
||||
sessionCacheMaxEntries, err := loadIntEnvWithDefault(sessionCacheMaxEntriesEnvVar, cfg.SessionCache.MaxEntries)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.SessionCache.MaxEntries = sessionCacheMaxEntries
|
||||
|
||||
sessionCacheTTL, err := loadDurationEnvWithDefault(sessionCacheTTLEnvVar, cfg.SessionCache.TTL)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.SessionCache.TTL = sessionCacheTTL
|
||||
|
||||
rawReplayRedisKeyPrefix, ok := os.LookupEnv(replayRedisKeyPrefixEnvVar)
|
||||
if ok {
|
||||
cfg.ReplayRedis.KeyPrefix = rawReplayRedisKeyPrefix
|
||||
|
||||
@@ -123,4 +123,7 @@ func (unavailableSessionCache) Lookup(context.Context, string) (session.Record,
|
||||
return session.Record{}, errors.New("session cache is unavailable")
|
||||
}
|
||||
|
||||
func (unavailableSessionCache) MarkRevoked(string) {}
|
||||
func (unavailableSessionCache) MarkAllRevokedForUser(string) {}
|
||||
|
||||
var _ gatewayv1.EdgeGatewayServer = sessionLookupService{}
|
||||
|
||||
@@ -292,3 +292,6 @@ type staticSessionCache struct {
|
||||
func (c staticSessionCache) Lookup(ctx context.Context, deviceSessionID string) (session.Record, error) {
|
||||
return c.lookupFunc(ctx, deviceSessionID)
|
||||
}
|
||||
|
||||
func (staticSessionCache) MarkRevoked(string) {}
|
||||
func (staticSessionCache) MarkAllRevokedForUser(string) {}
|
||||
|
||||
@@ -1,50 +1,12 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
import "context"
|
||||
|
||||
// BackendLookup describes the slice of `backendclient.RESTClient`
|
||||
// SessionCache depends on. The narrow interface keeps this package free
|
||||
// of any backendclient import.
|
||||
// BackendLookup is the slice of backend's REST surface that the
|
||||
// session-cache layer depends on. The narrow interface keeps this
|
||||
// package free of any backendclient import. The canonical
|
||||
// implementation is `*backendclient.RESTClient`; tests can supply a
|
||||
// fake.
|
||||
type BackendLookup interface {
|
||||
LookupSession(ctx context.Context, deviceSessionID string) (Record, error)
|
||||
}
|
||||
|
||||
// BackendCache resolves authenticated device sessions by issuing one
|
||||
// synchronous REST call to backend per request. The canonical implementation replaces the
|
||||
// previous Redis-backed projection with this thin wrapper; gateway no
|
||||
// longer keeps a process-local snapshot. See ARCHITECTURE.md §11
|
||||
// «backend (sync REST), no Redis projection».
|
||||
type BackendCache struct {
|
||||
backend BackendLookup
|
||||
}
|
||||
|
||||
// NewBackendCache constructs a Cache that delegates every Lookup to
|
||||
// backend over REST. backend must not be nil.
|
||||
func NewBackendCache(backend BackendLookup) (*BackendCache, error) {
|
||||
if backend == nil {
|
||||
return nil, errors.New("session.NewBackendCache: backend lookup must not be nil")
|
||||
}
|
||||
return &BackendCache{backend: backend}, nil
|
||||
}
|
||||
|
||||
// Lookup resolves deviceSessionID via backend. ErrNotFound is forwarded
|
||||
// unchanged so callers can keep using the existing equality check.
|
||||
func (c *BackendCache) Lookup(ctx context.Context, deviceSessionID string) (Record, error) {
|
||||
if c == nil {
|
||||
return Record{}, errors.New("session backend cache: nil cache")
|
||||
}
|
||||
if c.backend == nil {
|
||||
return Record{}, errors.New("session backend cache: nil backend lookup")
|
||||
}
|
||||
rec, err := c.backend.LookupSession(ctx, deviceSessionID)
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("session backend cache: %w", err)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
var _ Cache = (*BackendCache)(nil)
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DefaultMaxEntries is the LRU bound applied when MemoryCacheOptions
|
||||
// does not supply a positive MaxEntries. Holds well below the per-process
|
||||
// memory budget for the documented MVP scale (≤10K active accounts,
|
||||
// ≤100K device sessions).
|
||||
const DefaultMaxEntries = 50_000
|
||||
|
||||
// DefaultTTL is the safety-net freshness window applied when
|
||||
// MemoryCacheOptions does not supply a positive TTL. Push events drive
|
||||
// invalidation in the steady state; the TTL guards against missed
|
||||
// events (cursor aged out, gateway restart) by forcing a fresh backend
|
||||
// lookup at most once per window.
|
||||
const DefaultTTL = 10 * time.Minute
|
||||
|
||||
// MemoryCache is the canonical Cache implementation. Hot-path Lookup
|
||||
// reads serve from a process-local LRU + TTL map; misses delegate to
|
||||
// BackendLookup and seed the cache. session_invalidation push events
|
||||
// flip cached records to a revoked status without a backend
|
||||
// roundtrip, after which Lookup returns the revoked record straight
|
||||
// from memory and gateway rejects the request.
|
||||
//
|
||||
// MemoryCache is safe for concurrent use.
|
||||
type MemoryCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*list.Element
|
||||
byUser map[string]map[string]struct{}
|
||||
order *list.List
|
||||
max int
|
||||
ttl time.Duration
|
||||
backend BackendLookup
|
||||
now func() time.Time
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// memoryEntry is the value stored inside the LRU list. The key
|
||||
// duplication keeps Element.Value self-describing for eviction.
|
||||
type memoryEntry struct {
|
||||
key string
|
||||
record Record
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// MemoryCacheOptions tunes the cache.
|
||||
type MemoryCacheOptions struct {
|
||||
// MaxEntries bounds the number of cached records. Zero or
|
||||
// negative values default to DefaultMaxEntries.
|
||||
MaxEntries int
|
||||
// TTL bounds how long a cached entry serves the hot path before
|
||||
// a fresh backend lookup. Zero or negative values default to
|
||||
// DefaultTTL.
|
||||
TTL time.Duration
|
||||
// Now overrides time.Now for tests.
|
||||
Now func() time.Time
|
||||
// Logger is named "session.cache". A nil value uses zap.NewNop.
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewMemoryCache constructs a MemoryCache. backend must not be nil.
|
||||
func NewMemoryCache(backend BackendLookup, opts MemoryCacheOptions) (*MemoryCache, error) {
|
||||
if backend == nil {
|
||||
return nil, errors.New("session.NewMemoryCache: backend lookup must not be nil")
|
||||
}
|
||||
max := opts.MaxEntries
|
||||
if max <= 0 {
|
||||
max = DefaultMaxEntries
|
||||
}
|
||||
ttl := opts.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultTTL
|
||||
}
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &MemoryCache{
|
||||
entries: make(map[string]*list.Element, max),
|
||||
byUser: make(map[string]map[string]struct{}),
|
||||
order: list.New(),
|
||||
max: max,
|
||||
ttl: ttl,
|
||||
backend: backend,
|
||||
now: now,
|
||||
logger: logger.Named("session.cache"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Lookup serves deviceSessionID from the cache. A miss (or an entry
|
||||
// past its TTL) triggers a backend lookup and seeds the cache before
|
||||
// returning. Concurrent Lookups for the same key are not coalesced —
|
||||
// that level of optimisation is not needed at the documented MVP
|
||||
// scale.
|
||||
func (c *MemoryCache) Lookup(ctx context.Context, deviceSessionID string) (Record, error) {
|
||||
if c == nil {
|
||||
return Record{}, errors.New("session memory cache: nil cache")
|
||||
}
|
||||
if deviceSessionID == "" {
|
||||
return Record{}, ErrNotFound
|
||||
}
|
||||
now := c.now()
|
||||
c.mu.Lock()
|
||||
if elem, ok := c.entries[deviceSessionID]; ok {
|
||||
entry := elem.Value.(*memoryEntry)
|
||||
if entry.expiresAt.After(now) {
|
||||
c.order.MoveToFront(elem)
|
||||
rec := entry.record
|
||||
c.mu.Unlock()
|
||||
return rec, nil
|
||||
}
|
||||
// Expired — evict and fall through to backend.
|
||||
c.evictLocked(elem)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
rec, err := c.backend.LookupSession(ctx, deviceSessionID)
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("session memory cache: %w", err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.insertLocked(deviceSessionID, rec, now.Add(c.ttl))
|
||||
c.mu.Unlock()
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// MarkRevoked flips the cached record for deviceSessionID to a
|
||||
// revoked status. Calling on a missing entry is a no-op.
|
||||
func (c *MemoryCache) MarkRevoked(deviceSessionID string) {
|
||||
if c == nil || deviceSessionID == "" {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
elem, ok := c.entries[deviceSessionID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry := elem.Value.(*memoryEntry)
|
||||
entry.record.Status = StatusRevoked
|
||||
}
|
||||
|
||||
// MarkAllRevokedForUser flips every cached record whose UserID is
|
||||
// userID to revoked. The user index is updated in O(n) over the
|
||||
// user's session set, not the whole cache.
|
||||
func (c *MemoryCache) MarkAllRevokedForUser(userID string) {
|
||||
if c == nil || userID == "" {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
set, ok := c.byUser[userID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for id := range set {
|
||||
if elem, ok := c.entries[id]; ok {
|
||||
elem.Value.(*memoryEntry).record.Status = StatusRevoked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the current number of cached entries. Useful for
|
||||
// metrics and tests.
|
||||
func (c *MemoryCache) Len() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.order.Len()
|
||||
}
|
||||
|
||||
// insertLocked stores rec under deviceSessionID. The caller holds c.mu.
|
||||
func (c *MemoryCache) insertLocked(deviceSessionID string, rec Record, expiresAt time.Time) {
|
||||
if existing, ok := c.entries[deviceSessionID]; ok {
|
||||
existing.Value.(*memoryEntry).record = rec
|
||||
existing.Value.(*memoryEntry).expiresAt = expiresAt
|
||||
c.order.MoveToFront(existing)
|
||||
c.indexUserLocked(deviceSessionID, rec.UserID)
|
||||
return
|
||||
}
|
||||
elem := c.order.PushFront(&memoryEntry{
|
||||
key: deviceSessionID,
|
||||
record: rec,
|
||||
expiresAt: expiresAt,
|
||||
})
|
||||
c.entries[deviceSessionID] = elem
|
||||
c.indexUserLocked(deviceSessionID, rec.UserID)
|
||||
if c.order.Len() > c.max {
|
||||
oldest := c.order.Back()
|
||||
if oldest != nil {
|
||||
c.evictLocked(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictLocked removes elem from every internal index. The caller holds c.mu.
|
||||
func (c *MemoryCache) evictLocked(elem *list.Element) {
|
||||
entry := elem.Value.(*memoryEntry)
|
||||
delete(c.entries, entry.key)
|
||||
if set := c.byUser[entry.record.UserID]; set != nil {
|
||||
delete(set, entry.key)
|
||||
if len(set) == 0 {
|
||||
delete(c.byUser, entry.record.UserID)
|
||||
}
|
||||
}
|
||||
c.order.Remove(elem)
|
||||
}
|
||||
|
||||
// indexUserLocked associates deviceSessionID with userID in byUser.
|
||||
// The caller holds c.mu.
|
||||
func (c *MemoryCache) indexUserLocked(deviceSessionID, userID string) {
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
set, ok := c.byUser[userID]
|
||||
if !ok {
|
||||
set = make(map[string]struct{})
|
||||
c.byUser[userID] = set
|
||||
}
|
||||
set[deviceSessionID] = struct{}{}
|
||||
}
|
||||
|
||||
var _ Cache = (*MemoryCache)(nil)
|
||||
@@ -0,0 +1,204 @@
|
||||
package session_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gateway/internal/session"
|
||||
)
|
||||
|
||||
// stubLookup is the BackendLookup test fake. lookups counts hits;
|
||||
// records is the canonical source of truth keyed by device_session_id.
|
||||
type stubLookup struct {
|
||||
mu sync.Mutex
|
||||
records map[string]session.Record
|
||||
hits atomic.Int64
|
||||
notFound bool
|
||||
}
|
||||
|
||||
func newStubLookup() *stubLookup {
|
||||
return &stubLookup{records: make(map[string]session.Record)}
|
||||
}
|
||||
|
||||
func (s *stubLookup) put(rec session.Record) {
|
||||
s.mu.Lock()
|
||||
s.records[rec.DeviceSessionID] = rec
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *stubLookup) LookupSession(_ context.Context, deviceSessionID string) (session.Record, error) {
|
||||
s.hits.Add(1)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.notFound {
|
||||
return session.Record{}, session.ErrNotFound
|
||||
}
|
||||
rec, ok := s.records[deviceSessionID]
|
||||
if !ok {
|
||||
return session.Record{}, session.ErrNotFound
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func TestMemoryCacheLookupHitsCacheAfterFirstFetch(t *testing.T) {
|
||||
stub := newStubLookup()
|
||||
stub.put(session.Record{DeviceSessionID: "a", UserID: "u1", Status: session.StatusActive})
|
||||
|
||||
cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{
|
||||
MaxEntries: 10,
|
||||
TTL: time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMemoryCache: %v", err)
|
||||
}
|
||||
|
||||
if _, err := cache.Lookup(context.Background(), "a"); err != nil {
|
||||
t.Fatalf("first lookup: %v", err)
|
||||
}
|
||||
if _, err := cache.Lookup(context.Background(), "a"); err != nil {
|
||||
t.Fatalf("second lookup: %v", err)
|
||||
}
|
||||
if got := stub.hits.Load(); got != 1 {
|
||||
t.Fatalf("backend hits = %d, want 1 (cache should serve the second call)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCacheLookupRefreshesOnTTLExpiry(t *testing.T) {
|
||||
stub := newStubLookup()
|
||||
stub.put(session.Record{DeviceSessionID: "a", UserID: "u1", Status: session.StatusActive})
|
||||
|
||||
clock := time.Unix(1_000_000, 0)
|
||||
now := func() time.Time { return clock }
|
||||
|
||||
cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{
|
||||
MaxEntries: 10,
|
||||
TTL: 100 * time.Millisecond,
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMemoryCache: %v", err)
|
||||
}
|
||||
|
||||
if _, err := cache.Lookup(context.Background(), "a"); err != nil {
|
||||
t.Fatalf("first lookup: %v", err)
|
||||
}
|
||||
clock = clock.Add(200 * time.Millisecond)
|
||||
if _, err := cache.Lookup(context.Background(), "a"); err != nil {
|
||||
t.Fatalf("post-TTL lookup: %v", err)
|
||||
}
|
||||
if got := stub.hits.Load(); got != 2 {
|
||||
t.Fatalf("backend hits = %d, want 2 (TTL expiry should refetch)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCacheMarkRevokedFlipsCachedRecord(t *testing.T) {
|
||||
stub := newStubLookup()
|
||||
stub.put(session.Record{DeviceSessionID: "a", UserID: "u1", Status: session.StatusActive})
|
||||
|
||||
cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{MaxEntries: 10, TTL: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMemoryCache: %v", err)
|
||||
}
|
||||
|
||||
if _, err := cache.Lookup(context.Background(), "a"); err != nil {
|
||||
t.Fatalf("first lookup: %v", err)
|
||||
}
|
||||
cache.MarkRevoked("a")
|
||||
rec, err := cache.Lookup(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatalf("post-revoke lookup: %v", err)
|
||||
}
|
||||
if rec.Status != session.StatusRevoked {
|
||||
t.Fatalf("status = %q, want %q", rec.Status, session.StatusRevoked)
|
||||
}
|
||||
if got := stub.hits.Load(); got != 1 {
|
||||
t.Fatalf("backend hits = %d, want 1 (MarkRevoked must not refetch)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCacheMarkAllRevokedForUserFlipsAllSessions(t *testing.T) {
|
||||
stub := newStubLookup()
|
||||
stub.put(session.Record{DeviceSessionID: "a", UserID: "u1", Status: session.StatusActive})
|
||||
stub.put(session.Record{DeviceSessionID: "b", UserID: "u1", Status: session.StatusActive})
|
||||
stub.put(session.Record{DeviceSessionID: "c", UserID: "u2", Status: session.StatusActive})
|
||||
|
||||
cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{MaxEntries: 10, TTL: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMemoryCache: %v", err)
|
||||
}
|
||||
for _, id := range []string{"a", "b", "c"} {
|
||||
if _, err := cache.Lookup(context.Background(), id); err != nil {
|
||||
t.Fatalf("seed %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
cache.MarkAllRevokedForUser("u1")
|
||||
|
||||
for _, id := range []string{"a", "b"} {
|
||||
rec, err := cache.Lookup(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("post-revoke lookup %s: %v", id, err)
|
||||
}
|
||||
if rec.Status != session.StatusRevoked {
|
||||
t.Fatalf("session %s status = %q, want revoked", id, rec.Status)
|
||||
}
|
||||
}
|
||||
rec, err := cache.Lookup(context.Background(), "c")
|
||||
if err != nil {
|
||||
t.Fatalf("post-revoke lookup c: %v", err)
|
||||
}
|
||||
if rec.Status != session.StatusActive {
|
||||
t.Fatalf("session c status = %q, want active (other user)", rec.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCacheLRUEvictsLeastRecentlyUsed(t *testing.T) {
|
||||
stub := newStubLookup()
|
||||
stub.put(session.Record{DeviceSessionID: "a", UserID: "u1", Status: session.StatusActive})
|
||||
stub.put(session.Record{DeviceSessionID: "b", UserID: "u2", Status: session.StatusActive})
|
||||
stub.put(session.Record{DeviceSessionID: "c", UserID: "u3", Status: session.StatusActive})
|
||||
|
||||
cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{MaxEntries: 2, TTL: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMemoryCache: %v", err)
|
||||
}
|
||||
|
||||
if _, err := cache.Lookup(context.Background(), "a"); err != nil {
|
||||
t.Fatalf("seed a: %v", err)
|
||||
}
|
||||
if _, err := cache.Lookup(context.Background(), "b"); err != nil {
|
||||
t.Fatalf("seed b: %v", err)
|
||||
}
|
||||
if _, err := cache.Lookup(context.Background(), "c"); err != nil {
|
||||
t.Fatalf("seed c: %v", err)
|
||||
}
|
||||
if got := cache.Len(); got != 2 {
|
||||
t.Fatalf("Len = %d, want 2", got)
|
||||
}
|
||||
|
||||
hitsBefore := stub.hits.Load()
|
||||
if _, err := cache.Lookup(context.Background(), "a"); err != nil {
|
||||
t.Fatalf("re-lookup a: %v", err)
|
||||
}
|
||||
if got := stub.hits.Load(); got != hitsBefore+1 {
|
||||
t.Fatalf("backend hits = %d, want +1 (a was evicted)", got-hitsBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryCachePropagatesBackendNotFound(t *testing.T) {
|
||||
stub := newStubLookup()
|
||||
stub.notFound = true
|
||||
|
||||
cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{MaxEntries: 4, TTL: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMemoryCache: %v", err)
|
||||
}
|
||||
_, err = cache.Lookup(context.Background(), "missing")
|
||||
if !errors.Is(err, session.ErrNotFound) {
|
||||
t.Fatalf("Lookup error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,29 @@ var (
|
||||
)
|
||||
|
||||
// Cache resolves authenticated device-session state from the gateway
|
||||
// hot path. The implementation dropped the previous Redis projection: the only
|
||||
// implementation is *BackendCache, which calls backend's
|
||||
// `/api/v1/internal/sessions/{id}` synchronously per request.
|
||||
// hot path. The canonical implementation is *MemoryCache: a
|
||||
// process-local LRU + TTL store that falls back to backend's
|
||||
// `/api/v1/internal/sessions/{id}` on miss and listens for
|
||||
// `session_invalidation` push events from backend so revoked sessions
|
||||
// are reflected immediately without a fresh backend lookup.
|
||||
//
|
||||
// The Mark* methods are called by the push dispatcher. They flip
|
||||
// cached entries to revoked status; subsequent Lookups serve the
|
||||
// revoked record directly so authenticated traffic on those sessions
|
||||
// is rejected at the edge before reaching backend.
|
||||
type Cache interface {
|
||||
// Lookup returns the cached record for deviceSessionID. Implementations must
|
||||
// wrap ErrNotFound when the cache does not contain the requested record.
|
||||
Lookup(ctx context.Context, deviceSessionID string) (Record, error)
|
||||
|
||||
// MarkRevoked flips the cached record for deviceSessionID to a
|
||||
// revoked status. Calling on a missing entry is a no-op.
|
||||
MarkRevoked(deviceSessionID string)
|
||||
|
||||
// MarkAllRevokedForUser flips every cached record belonging to
|
||||
// userID to a revoked status. Calling on a user with no cached
|
||||
// sessions is a no-op.
|
||||
MarkAllRevokedForUser(userID string)
|
||||
}
|
||||
|
||||
// Status identifies the cached lifecycle state of a device session.
|
||||
|
||||
Reference in New Issue
Block a user