daemon: move the Discord gateway and scheduler out of the web process into Go #21
No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Jinemi/thermograph#21
Loading…
Reference in a new issue
No description provided.
Delete branch "go-daemon"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
web/app.pystarted two long-lived background jobs under a leader election: theDiscord gateway bot and an APScheduler. Both are stateful I/O loops — reconnect,
RESUME, heartbeat, backoff, interval timers — living inside an async web app that
also has to serve requests. This moves them into a single Go binary.
The split
Go owns only the stateful I/O. It owns no climate or grading logic. Anything
needing data calls back into Python over a new internal-only surface
(
/internal/discord/grade,/internal/jobs/warm-cities,/internal/jobs/indexnow).That boundary is the whole design. Grading depends on polars and the parquet
cache; reimplementing it in Go would let the bot's grades drift from the API's,
and the slash-command path deliberately shares one grade builder so the two can
never disagree. The grade route returns gateway-ready JSON — including the
ephemeral-flag drop
discord_bot.pyused to do — and Go relays those bytesverbatim without ever parsing the embed.
Packaging
The binary is built by a
golang:1.26stage in the backend Dockerfile andshipped in the same image, run as a second compose service off the same
tag. The daemon and backend share the
/internal/*contract, so they mustnever skew versions; one image makes that structural rather than a convention.
Its entrypoint bypasses
entrypoint.sh— the backend owns alembic, and tworacing migrators is a real hazard.
deploy.shnow rollsdaemonalongsidebackend. Without that the servicewould never be created at all: a single-service deploy runs
up -d --no-deps <targets>, so adaemonpresent only in compose would havebeen silently skipped and the bot would never have started.
replicas: 1in the Swarm stack is load-bearing — Discord permits exactly onegateway connection per bot token, so the pin replaces
core/singleton.claim_leaderfor this workload.order: stop-first, sincestart-first would briefly run two gateways.
autoscale.shtargets${STACK_NAME}_webonly, so it cannot scale this.No new secret to provision
THERMOGRAPH_INTERNAL_TOKENis optional. Unset, both ends derive the sametoken from
THERMOGRAPH_AUTH_SECRETvia HMAC-SHA256 under a fixeddomain-separation label. A key-gap audit confirms that secret is already set in
every environment, so this stands up with no vault edit and no operator step.
Set the var explicitly only to rotate this surface independently.
HMAC under a distinct label rather than reusing the auth secret directly, so a
leak of this token can't be replayed as the session-signing key it came from.
The derivation is pinned to a shared cross-language test vector asserted on
both sides (
config/derive_test.goandtest_internal_token_derivation.py) — ifeither drifts, CI fails, rather than every callback 401ing in a way that reads
like an auth bug.
With neither secret set, both ends fail closed: the router 404s and the daemon
refuses to start.
Security
hmac.compare_digestfor the token; the whole router 404s when no token can beestablished — fail closed, never default open. Caddy only routes
/api/*,/digestand/discord/interactionsto the backend, so/internal/*was neverpublicly reachable; the token is defence in depth. The router mounts before the
catch-all frontend proxy so
/internal/*can't fall through to it.Behaviour preserved, with the reasoning carried into the Go comments
Non-privileged intents (no
MESSAGE_CONTENT, so no portal review); fatal closecodes 4004/4010–4014 stop rather than loop; the bot-author and self-author
mention-loop guard;
allowed_mentionslocked to{"parse":[],"replied_user":true}so a crafted query can't turn a reply into an
@everyoneping; the first crontick deferred a full interval rather than firing at boot, since warm-cities
already runs at deploy time; and no overlapping warm-cities run, which would
double-spend the archive-fetch quota.
Three deliberate improvements over the Python
session closed 1000/1001, so the Python's default close silently defeated its
own resume.
MESSAGE_CREATEis handled on a bounded worker pool rather than anunbounded thread hand-off, so a flood of mentions can't spawn unbounded work
against the backend.
clean return is the planned-reconnect path, which resets backoff and redials
with no sleep — a gateway stuck sending a bad HELLO would have become a tight
reconnect loop against Discord.
A
.dockerignoreis added because a disposablebackend/.venvwas beingswallowed by
COPY . /app/and duplicated by thechownlayer, inflating theimage to 1.8 GB; it now builds at 578 MB.
Verification
build,vet,test -raceclean across all 4 packages;gofmtclean.29 gateway tests cover every behaviour the deleted
test_discord_bot.pyasserted.
(fail-closed, auth, flag drop, per-job 409 guard) and 6 derivation tests.
docker buildsucceeds; binary runs as uid 10001 and exits 1 with a clearmessage when it has nothing to derive from — verified in-image.
shellcheck -x: 0 findings (the guard from #19 covers thedeploy.shchangehere).
main, so it includes the Open-Meteo migration and thedomain-sha tag keying. That keying composes correctly:
backend/daemon/livesunder
backend/, so a daemon-only change re-keys the backend image tag.Deploy note
On beta the gateway is inert:
THERMOGRAPH_DISCORD_BOT/_BOT_TOKENare seton prod only, so the daemon runs cron-only there. The gateway path first
activates when this reaches prod.
web/app.py started two long-lived background jobs under a leader election: the Discord gateway bot and an APScheduler. Both are stateful I/O loops -- reconnect, RESUME, heartbeat, backoff, interval timers -- living inside an async web app that also has to serve requests. This moves them into a single Go binary. Go owns ONLY the stateful I/O. It owns no climate or grading logic: anything needing data calls back into Python over a new internal-only HTTP surface (/internal/discord/grade, /internal/jobs/warm-cities, /internal/jobs/indexnow). Grading depends on polars and the parquet cache; reimplementing it in Go would make the bot's grades drift from the API's, and the slash-command path deliberately shares one grade builder so the two can never disagree. The grade route returns gateway-ready JSON -- including the ephemeral-flag drop that discord_bot.py used to do -- and Go relays those bytes verbatim without parsing the embed. Packaging: the binary is built by a golang:1.26 stage in the backend Dockerfile and shipped in the SAME image, run as a second compose service off the SAME tag. The daemon and backend share the /internal/* contract, so they must never skew versions; one image makes that structural rather than a convention. Its entrypoint bypasses entrypoint.sh -- the backend owns alembic, and two racing migrators is a real hazard. replicas: 1 in the Swarm stack is load-bearing. Discord permits exactly one gateway connection per bot token; the pin replaces core/singleton.claim_leader for this workload. update_config uses order: stop-first, since start-first would briefly run two gateways. autoscale.sh targets ${STACK_NAME}_web only, so it cannot scale this. Security: the internal routes compare the token with hmac.compare_digest and the whole router 404s when THERMOGRAPH_INTERNAL_TOKEN is unset -- fail closed, never default open. Caddy only routes /api/*, /digest and /discord/interactions to the backend, so /internal/* was never publicly reachable; the token is defence in depth. The router mounts before the catch-all frontend proxy so /internal/* cannot fall through to it. The daemon refuses to start without the token. Behaviour preserved from the Python, with the reasoning carried into the Go comments: non-privileged intents (no MESSAGE_CONTENT, so no portal review); fatal close codes 4004/4010-4014 stop rather than loop; the bot-author and self-author mention-loop guard; allowed_mentions locked to {"parse":[], "replied_user":true} so a crafted query cannot turn a reply into an @everyone ping; the first cron tick deferred one full interval rather than firing at boot, since warm-cities already runs at deploy time; and no overlapping warm-cities run, which would double-spend the archive-fetch quota. Two deliberate improvements over the Python. A close intended for RESUME now uses 4000 rather than 1000 -- Discord invalidates a session closed 1000/1001, so the Python's default close silently defeated its own resume. And MESSAGE_CREATE is handled on a bounded worker pool rather than an unbounded thread hand-off, so a flood of mentions cannot spawn unbounded work against the backend. A .dockerignore is added because a disposable backend/.venv was being swallowed by COPY . /app/ and duplicated again by the chown layer, inflating the image to 1.8 GB; it builds at 578 MB. Tests: 29 Go gateway tests covering every behaviour the deleted test_discord_bot.py asserted, plus cron/config/apiclient suites; 10 new Python tests for the internal routes (fail-closed, auth, flag drop, per-job 409 guard). Full suite 359 passed / 7 skipped; go build, vet and test -race clean.c3b906a9edtoadf824b33f