Skip to content
Merged
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ coverage.html

# k6 reports
scripts/k6/reports/

# Graphify knowledge-graph output — local-only, never expose publicly
graphify-out/
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.PHONY: help build test test-integration lint cover up down k8s-build k8s-apply k8s-delete k6-leaderboard k6-matchmaking k6-websocket

SERVICES := gateway player matchmaking leaderboard websocket
SERVICES := gateway player matchmaking leaderboard websocket presence

help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
Expand Down
68 changes: 68 additions & 0 deletions cmd/presence/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// The presence service tracks where every player is (OFFLINE, ONLINE,
// IN_QUEUE, IN_MATCH, AWAY) in Redis, refreshed by WS-gateway heartbeats with a
// TTL so presence self-heals after crashes, and publishes presence transitions
// as events for the social layer (friends, parties, invites, notifications).
package main

import (
"context"

"github.com/redis/go-redis/v9"
"go.uber.org/zap"

"github.com/alpnuhoglu/gamemesh/internal/presence"
"github.com/alpnuhoglu/gamemesh/pkg/config"
"github.com/alpnuhoglu/gamemesh/pkg/events"
"github.com/alpnuhoglu/gamemesh/pkg/logger"
"github.com/alpnuhoglu/gamemesh/pkg/metrics"
"github.com/alpnuhoglu/gamemesh/pkg/server"
"github.com/alpnuhoglu/gamemesh/pkg/tracing"
)

func main() {
cfg := config.Load("presence")
log := logger.Must(cfg.ServiceName, cfg.Env)
defer func() { _ = log.Sync() }()

shutdownTracing := tracing.MustInit(context.Background(), tracing.Config{
Enabled: cfg.OTelEnabled,
ServiceName: cfg.OTelServiceName,
Endpoint: cfg.OTelEndpoint,
Env: cfg.Env,
Version: cfg.ServiceVersion,
Sampler: cfg.OTelSampler,
SamplerRatio: cfg.OTelSamplerRatio,
}, log)
defer func() { _ = shutdownTracing(context.Background()) }()

rdb := redis.NewClient(&redis.Options{
Addr: cfg.RedisAddr,
Password: cfg.RedisPassword,
DB: cfg.RedisDB,
})
if err := tracing.InstrumentRedis(rdb); err != nil {
log.Fatal("failed to instrument redis", zap.Error(err))
}

m := metrics.New(cfg.ServiceName)
bus, err := events.NewBus(events.Config{
Transport: cfg.EventBus,
DurableName: cfg.ServiceName,
Workers: cfg.EventWorkers,
}, rdb, cfg.NATSURL, m, log)
if err != nil {
log.Fatal("failed to init event bus", zap.Error(err))
}
defer func() { _ = bus.Close() }()

repo := presence.NewRepository(rdb, cfg.PresenceTTL)
svc := presence.NewService(repo, bus, m, log)
handler := presence.NewHandler(svc)

engine := server.NewEngine(cfg, log, m)
handler.RegisterRoutes(engine)

if err := server.Run(engine, cfg.HTTPPort, log); err != nil {
log.Fatal("server exited", zap.Error(err))
}
}
10 changes: 9 additions & 1 deletion cmd/websocket/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"

"github.com/alpnuhoglu/gamemesh/internal/presence"
"github.com/alpnuhoglu/gamemesh/internal/wsgateway"
"github.com/alpnuhoglu/gamemesh/pkg/auth"
"github.com/alpnuhoglu/gamemesh/pkg/config"
Expand Down Expand Up @@ -45,12 +46,19 @@ func main() {
}

m := metrics.New(cfg.ServiceName)
hub := wsgateway.NewHub(log, m)
// Feed presence over HTTP. The notifier is the only coupling between the WS
// gateway and the Presence Service; if the Presence Service is down, calls
// fail softly and presence self-heals from later heartbeats / TTL.
notifier := presence.NewHTTPNotifier(cfg.PresenceServiceURL, cfg.PresenceHeartbeatInterval)
hub := wsgateway.NewHub(log, m, notifier)
tokens := auth.NewTokenManager(cfg.JWTSecret, cfg.JWTExpiry, cfg.JWTIssuer)
handler := wsgateway.NewHandler(hub, tokens, cfg.AllowedOrigins, log)

ctx, stop := server.ShutdownContext()
defer stop()

// Refresh presence TTL for every locally-connected player on a ticker.
go hub.RunHeartbeat(ctx, cfg.PresenceHeartbeatInterval)
bus, err := events.NewBus(events.Config{
Transport: cfg.EventBus,
DurableName: cfg.ServiceName,
Expand Down
3 changes: 3 additions & 0 deletions config/prometheus/prometheus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ scrape_configs:
- job_name: outbox-relay
static_configs:
- targets: ["outbox-relay:8085"]
- job_name: presence
static_configs:
- targets: ["presence:8086"]
14 changes: 14 additions & 0 deletions deployments/docker/Dockerfile.presence
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# syntax=docker/dockerfile:1
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/service ./cmd/presence

FROM alpine:3.20
RUN adduser -D -u 10001 app
USER app
COPY --from=build /out/service /service
EXPOSE 8086
ENTRYPOINT ["/service"]
3 changes: 3 additions & 0 deletions deployments/k8s/02-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ data:
MATCHMAKING_SERVICE_URL: http://matchmaking:8082
LEADERBOARD_SERVICE_URL: http://leaderboard:8083
WEBSOCKET_SERVICE_URL: http://websocket:8084
PRESENCE_SERVICE_URL: http://presence:8086
PRESENCE_TTL: 45s
PRESENCE_HEARTBEAT_INTERVAL: 15s
RATE_LIMIT_RPS: "50"
RATE_LIMIT_BURST: "100"
MATCH_INTERVAL_SECONDS: "5"
Expand Down
59 changes: 59 additions & 0 deletions deployments/k8s/16-presence.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# The Presence Service is stateless: all presence lives in Redis keyed by
# playerID, so any replica can serve any read or update. No sticky sessions and
# no leader election — scale horizontally by raising replicas.
apiVersion: apps/v1
kind: Deployment
metadata:
name: presence
namespace: gamemesh
spec:
replicas: 2
selector:
matchLabels:
app: presence
template:
metadata:
labels:
app: presence
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8086"
prometheus.io/path: /metrics
spec:
containers:
- name: presence
image: gamemesh/presence:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8086
env:
- name: HTTP_PORT
value: "8086"
envFrom:
- configMapRef:
name: gamemesh-config
- secretRef:
name: gamemesh-secrets
readinessProbe:
httpGet: { path: /healthz, port: 8086 }
initialDelaySeconds: 3
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: 8086 }
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests: { cpu: 100m, memory: 64Mi }
limits: { cpu: 500m, memory: 128Mi }
---
apiVersion: v1
kind: Service
metadata:
name: presence
namespace: gamemesh
spec:
selector:
app: presence
ports:
- port: 8086
targetPort: 8086
29 changes: 29 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ services:
environment:
<<: *service-env
HTTP_PORT: "8084"
# Feed presence over HTTP; the heartbeat interval also paces the WS replica's
# presence-refresh ticker.
PRESENCE_SERVICE_URL: http://presence:8086
PRESENCE_HEARTBEAT_INTERVAL: ${PRESENCE_HEARTBEAT_INTERVAL:-15s}
ports:
- "8084:8084"
depends_on:
Expand All @@ -185,6 +189,31 @@ services:
<<: *svc-healthcheck
test: ["CMD", "wget", "-qO-", "http://localhost:8084/healthz"]

# Presence Service: tracks player presence (online/in-queue/in-match/away) in
# Redis with TTL-based self-healing and publishes presence transitions to NATS.
# Stateless and horizontally scalable — all state is in Redis keyed by playerID.
presence:
build:
context: .
dockerfile: deployments/docker/Dockerfile.presence
environment:
<<: *service-env
HTTP_PORT: "8086"
PRESENCE_TTL: ${PRESENCE_TTL:-45s}
PRESENCE_HEARTBEAT_INTERVAL: ${PRESENCE_HEARTBEAT_INTERVAL:-15s}
ports:
- "8086:8086"
depends_on:
redis:
condition: service_healthy
nats:
condition: service_healthy
otel-collector:
condition: service_started
healthcheck:
<<: *svc-healthcheck
test: ["CMD", "wget", "-qO-", "http://localhost:8086/healthz"]

# Transactional outbox relay: the publish side of the outbox pattern. It polls
# outbox_events (written by the player service in the same Postgres transaction
# as the business state) and publishes committed rows to NATS JetStream. A
Expand Down
Loading
Loading