Skip to content

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.