Goroutine Supervisor + Straggler Ports + Reversible Runtime Cutover¶
Date: 2026-06-22
Branch: goroutine-migration-eval
Status: Approved design — ready for implementation plan
Problem¶
The go-services/ tree already contains Go ports of 9 services (discord-bot,
recap, doctrine-worker, vibe-worker, gamehub, rmhmusic, rmhtube,
rmhbox, gateway), but the runtime (docker-compose.yml and the PR #121 Helm
chart) still runs the Node versions for everything. Two services were never
ported at all: status and bot-worker. Each Go worker is also its own OS
process today; we want the option to run the background workers as goroutines
in a single supervisor process to cut container count, share one pgx pool,
and (on a single node) share in-memory state without a Redis backplane.
Goals¶
Goroutine supervisor — one Go binary that runs the four background workers + the newly-ported
bot-workeras goroutines under anerrgroup.Port the two stragglers —
bot-worker(into the supervisor) andstatus(Go, but kept as its own isolated process).Reversible runtime cutover — point
docker-compose.ymland the Helm chart at the Go binaries / supervisor, keeping the Node path as a documented reversible fallback.
Non-goals¶
Rewriting the React SSR web tier (stays JS — prior architectural decision).
Consolidating the WebSocket hubs (
gamehub,rmhmusic,rmhtube,rmhbox) orgatewayinto the supervisor — they stay separate processes to preserve independent (Stage 2 HPA) scaling.Building the multi-node / multi-region scaling layers (Redis backplane, PgBouncer, read replicas) — tracked separately in the infra roadmap.
Decisions (locked with user 2026-06-22)¶
Question |
Decision |
|---|---|
Supervisor scope |
Background workers only: |
|
Port to Go, keep as its own process (must survive when the rest is down). |
|
Port to Go, run inside the supervisor. |
Runtime wiring |
Compose + Helm, with the Node path kept as a reversible fallback. |
Architecture¶
┌─ supervisor (1 process, errgroup) ──────────┐ ┌─ separate processes ─┐
│ discord-bot recap doctrine-worker │ │ gamehub rmhmusic │
│ vibe-worker bot-worker (newly ported) │ │ rmhtube rmhbox │
│ one db pool · one metrics registry │ │ gateway status(new) │
│ /health aggregates all · /metrics merged │ └──────────────────────┘
└──────────────────────────────────────────────┘
Foundational refactor: extract Run¶
Today each worker’s lifecycle (config load, db open, start loop, wait for
signal, shutdown) lives inline in cmd/X/main.go. We extract each worker’s
body into a reusable entrypoint in its internal/ package:
// internal/<worker>/run.go
type Deps struct {
DB *db.DB
Logger *log.Logger
Metrics *telemetry.Registry
Cfg config.Common
}
// Run blocks until ctx is cancelled or an unrecoverable error occurs.
func Run(ctx context.Context, d Deps) error { ... }
cmd/supervisor/main.goopens one db pool + one metrics registry and calls each worker’sRunundergolang.org/x/sync/errgroup(already an indirect dependency; promote to direct).The existing
cmd/discord-bot,cmd/recap,cmd/doctrine-worker,cmd/vibe-workerbecome thin wrappers that buildDepsand call the sameRun. This preserves standalone execution (per-service debugging and the reversible fallback) with zero behavior drift.
Components¶
1. cmd/supervisor/main.go¶
config.LoadCommon("supervisor"); open shareddb.DBviadb.WaitForReachable; build onetelemetry.New("supervisor")registry.Start one HTTP server on
MetricsAddrserving/healthand/metrics./healthreturnsokonly if every worker reports live (readiness map keyed by worker name);/metricsexposes the merged registry.errgroup.WithContext;g.Go(func() error { return botworker.Run(ctx, deps) })for each of the 5 workers.SIGINT/SIGTERM →
cancel()→g.Wait()bounded by a 30s shutdown deadline (mirrorshttpxgraceful-shutdown today). Non-nil group error → process exits non-zero so the orchestrator restarts the supervisor.
2. internal/botworker/ (new port of server/bot-worker/index.ts)¶
Maintains a pool of AI-generated bot users (DeepSeek-invented name/handle/bio, sourced avatar, private persona) and posts in-voice on a per-bot cadence.
Raw
pgx(matching the other ports), not Prisma.Idles harmlessly when
DEEPSEEK_API_KEYis unset (parity with Node).Exposes no client HTTP — health/metrics come from the supervisor.
3. internal/status/ + cmd/status/ (new port of server/status/index.ts)¶
Periodically probes every other service’s
/health; serves the self-contained HTML dashboard,GET /api/status(JSON snapshot + uptime history), andGET /health.Probes the web app via its public URL (
https://rmhstudios.com) so status reflects what real users hit, exactly as the Node version does.Stays its own process / container so the status page survives a stack outage.
4. Runtime wiring (reversible)¶
docker-compose.yml: the four background services +bot-workerpoint at the supervisor binary;statuspoints at the new Go binary. Nodecommand:entries preserved as a reversible fallback (commented orprofiles-gated).PR #121 Helm chart: corresponding Deployments updated to the Go images / supervisor; values flag (e.g.
runtime: go|node) toggles back to Node.
Testing¶
Existing
pkg/realtimeand worker tests stay green (no behavior drift from theRunextraction — wrappers are thin).New unit tests for
internal/botworker(persona generation paths, idle-without-key, paced posting) andinternal/status(probe aggregation, JSON snapshot, HTML render).One supervisor test: all 5
Runs start; a single worker returning an error cancels the group andWait()returns that error within the deadline.Go
e2e/harness gains a supervisor smoke check (boots supervisor, hits/health, asserts all workers live).
Deliverable: prod cutover runbook¶
After implementation, produce an ordered runbook to actually run Go in prod: build & push the Go/supervisor images, env-var parity check against the Node services, flip one service at a time behind the gateway with a health gate after each, and explicit rollback steps (the reversible Node fallback) at every stage.
Rollout sequence (implementation order)¶
Extract
Runfor the four existing background workers; convert theircmdmains to thin wrappers; keep tests green.Port
bot-worker→internal/botworker(+ standalonecmd/bot-workerwrapper).Build
cmd/supervisorover the 5Runs (errgroup, shared pool, merged health/metrics).Port
status→internal/status+cmd/status(separate process).Wire
docker-compose.yml+ Helm to the Go binaries / supervisor, Node fallback preserved.Tests + e2e smoke; write the prod cutover runbook.