Services¶
The backend ships as three binaries. Each is independently deployable. In production, Container Apps runs api-server × 2, ws-server × 3, flusher × 1.
api-server¶
File: cmd/api-server/main.go
Port: API_PORT (default 8080)
What it does: HTTP REST API, Google OAuth, trade execution, CFD positions, admin.
What breaks if it is down: All client requests fail. No trades execute. No auth.
Goroutines started at boot:
| Goroutine | Trigger / interval | Reads / writes |
|---|---|---|
googleVerifier.StartRefresh |
Every 6h | Fetches Google JWKS from https://www.googleapis.com/oauth2/v3/certs into memory |
scheduler.Run (Sportmonks) |
Loop; polls Sportmonks every 2s during live matches | Redis instrument hashes, price pub/sub, Postgres fixtures/match_scores |
runWeeklySpinner |
Boot + every Monday 00:05 UTC | Postgres spinner_results, leaderboard sorted sets |
runMarginReconnectListener |
Subscribes to user:reconnect channel |
Calls margin.ReplayLastSeen, writes position closes to Postgres |
runMarginBackstopSweeper |
Every 5 min; Redis lease lease:margin-backstop |
Queries cfd_positions WHERE closed_at IS NULL, replays each user |
positionsStopWatch.Run |
Subscribes to price:* pub/sub |
Calls position_close.lua on SL/TP breach; Redis lease lease:stop-watcher |
botEngine.Start (if BOT_ENABLED=1) |
Scheduler triggers on match start; dev mode starts immediately | Trades via trade_execute.lua |
flusher.Run (if FLUSHER_EMBEDDED=1) |
Every FLUSH_INTERVAL (default 100ms) |
Redis dirty sets → Postgres; see flusher section |
Decision: All goroutines run inside the api-server process. The only exception is flusher which ships as its own binary in production to keep the drain loop isolated from request-path failures.
ws-server¶
File: cmd/ws-server/main.go
Port: WS_PORT (default 8081)
What it does: Upgrades HTTP connections to WebSocket. Maintains a connection hub keyed by user ID and instrument index. Subscribes to Redis price:* and portfolio:* channels and fans out to connected clients.
What breaks if it is down: Clients receive no real-time price updates or portfolio pushes. Trades still execute via api-server; clients just do not see the result until they poll REST or reconnect.
Goroutines started at boot:
| Goroutine | Purpose |
|---|
| subscriber.Start | Subscribes to Redis pub/sub; routes price:{idx} to hub subscribers and portfolio:{userId} to the user's connections |
Reconnect/disconnect hooks:
- On 0→1 connection transition:
PUBLISH user:reconnect <userId>— api-server's margin listener replays offline price ticks. - On 1→0 transition:
SET last_seen:<userId> <ms> EX 24h— records the offline window start for the replayer.
Hub capacity: 20,000 concurrent connections per replica (set in ws.NewHub(20000)). Returns HTTP 503 when full.
Token validation: Clients supply a short-lived ws-token (typ=ws, 60s) as ?token= query parameter. The ws-server validates it using JWTPublicKey only — it never signs tokens.
flusher¶
File: cmd/flusher/main.go
Port: none (no HTTP listener)
What it does: Drains Redis hot state to Postgres durably.
What breaks if it is down: Redis accumulates dirty state. Postgres drifts from Redis. Trades are still accepted (outbox writes to Redis succeed). On restart the flusher replays the outbox, so no data is lost as long as Redis persists.
Goroutines started at boot:
| Goroutine | Interval | What it drains |
|---|---|---|
flusher.Run |
FLUSH_INTERVAL (default 100ms) |
dirty:instruments, dirty:wallets, dirty:positions sets → HGETALL each hash → UPDATE Postgres |
outboxDrainer.Run |
5s | trade_outbox table → retries failed INSERT INTO trades |
positionOutboxDrainer.Run |
5s | position_outbox table → retries failed INSERT/UPDATE cfd_positions |
Decision: Outbox drainers run at 5s instead of 100ms because outbox enqueues are rare (only when a synchronous Postgres write fails) and each retry hits Postgres. Spamming at 100ms on an empty table wastes connections.