HTTP/Worker Service Split
API-et kjorer som to separate prosesser pa Railway. Begge bruker samme kodebase, men SERVICE_MODE-variabelen bestemmer hva hver prosess gjor.
Oversikt
heading.anchorLabelKlienter (iOS, Android, Web) │ ▼┌─────────────────────┐ ┌─────────────────────────┐│ api │ │ api-worker ││ SERVICE_MODE=web │ │ SERVICE_MODE=worker ││ │ │ ││ HTTP server │ │ BullMQ workers ││ Fastify routes │ Redis │ 6 cron jobs ││ SSE connections │◄──────►│ AI-kall (Anthropic) ││ Queue producers │ │ Push-notifikasjoner ││ QueueEvents │ │ Minimal health endpoint ││ Admin dashboard │ │ │└─────────────────────┘ └─────────────────────────┘ │ │ └──────────┬───────────────────┘ │ ┌──────┴──────┐ │ Redis │ │ Supabase │ └─────────────┘Hvorfor splitte
heading.anchorLabel| Problem med single-process | Losning med split |
|---|---|
| AI-kall (10-30s) blokkerer event loop | Worker prosesserer AI separat, HTTP forblir responsiv |
| Kan ikke skalere HTTP uten a duplisere workers | Skaler web-replicas uavhengig av worker |
| Crons kjorer pa alle replicas | Crons kjorer kun pa worker |
| En treg AI-jobb pavirker SSE-heartbeats | Isolerte prosesser, isolerte problemer |
Deklarativ service-konfigurasjon
heading.anchorLabelKonfigurasjon av hva hver mode kjorer styres av en enkelt fil: apps/api/src/lib/service-config.ts.
const SERVICE_CONFIGS = { all: { http: true, workers: true, crons: true, adminRoutes: true }, web: { http: true, workers: false, crons: false, adminRoutes: true }, worker: { http: false, workers: true, crons: true, adminRoutes: false },}Forbrukere sjekker serviceConfig.workers, serviceConfig.crons osv. — aldri SERVICE_MODE-strengen direkte. Dette gir:
- Ett sted a endre — ny mode = ny linje i konfig-objektet
- Positiv logikk —
if (serviceConfig.crons)istedenforif (SERVICE_MODE !== 'web') - Kompilator-sikkerhet — TypeScript validerer at alle flagg finnes
SSE cross-process bridge
heading.anchorLabelSSE-endepunkter (enrichment-status, guidance-status, summary-status) kjorer pa web-prosessen, mens BullMQ workers prosesserer jobber pa worker-prosessen. For at SSE skal motta oppdateringer pa tvers brukes BullMQ sin innebygde mekanisme:
Flyten
heading.anchorLabel1. Klient → POST /impulses (web) → push() → Redis Queue2. Klient → GET /impulses/:id/enrichment/status (web) → subscribe() → EventEmitter3. Worker plukker opp jobb → processJob() a. updateJob('processing') → lokal EventEmitter (kun worker) b. job.updateProgress({status:'processing'}) → Redis Stream4. Web: QueueEvents lytter → 'progress' event → updateJob() → EventEmitter → SSE5. Worker fulllforer → BullMQ markerer completed i Redis6. Web: QueueEvents → 'completed' event → updateJob() → EventEmitter → SSEEvent-typer og leveringsmekanisme
heading.anchorLabel| Event | Mekanisme | Kilde |
|---|---|---|
queued | In-process (push() pa web) | Web |
processing | job.updateProgress() → QueueEvents progress | Worker → Redis → Web |
retrying | job.updateProgress() → QueueEvents progress | Worker → Redis → Web |
completed | QueueEvents completed (eksisterte allerede) | Worker → Redis → Web |
failed | QueueEvents failed (eksisterte allerede) | Worker → Redis → Web |
SSE-endepunktene er uendret — de bruker subscribe() som for. Bridgen er transparent.
Cron-jobber
heading.anchorLabelAlle 6 cron-jobber kjorer kun nar serviceConfig.crons === true (modes: all, worker).
| Cron | Intervall | Hva |
|---|---|---|
| Gift expiry | 6 timer | Utloper uinnloste gavekoder |
| Subscription revalidation | 6 timer | Re-syncer stale subscriptions mot Apple |
| Stale impulse reaper | 5 min | Rydder stuck impulses |
| Account deletion reaper | 30 min | Hard-delete av slettede kontoer |
| Analytics cache reaper | 6 timer | Fjerner utlopte dashboard-cacher |
| Reminder producer | 1 time | Sender re-engagement push-notifikasjoner |
Railway-oppsett
heading.anchorLabelServices
heading.anchorLabelBegge services bruker Railway sin native environment-modell med branch-mapping:
| Service | Staging (branch: main) | Production (branch: production) |
|---|---|---|
api | SERVICE_MODE=web | SERVICE_MODE=web |
api-worker | SERVICE_MODE=worker | SERVICE_MODE=worker |
Begge services i begge environments: healthcheck pa /health/8bde..., Railway-tildelt port.
Env vars og Doppler
heading.anchorLabelDoppler styrer alle app-secrets med en config per environment (stg, prd). Shared variables synces automatisk til begge services.
Infrastruktur-vars settes direkte i Railway per service (ikke i Doppler):
| Var | api | api-worker |
|---|---|---|
SERVICE_MODE | web | worker |
QUEUE_CONCURRENCY_MULTIPLIER | — | 2 eller 3 |
RAILPACK_START_CMD | via railway.toml | node apps/api/dist/server.js |
RAILPACK_BUILD_CMD | via railway.toml | pnpm --filter @im/shared build && pnpm --filter api build |
Env vars per service-mode
heading.anchorLabelIkke alle Doppler-vars er relevante for begge services. Overflodige vars ignoreres, men her er oversikten:
| Kategori | Vars | Brukes av |
|---|---|---|
| Infrastruktur | SUPABASE_URL/KEY, REDIS_URL, SENTRY_DSN | Begge |
| AI-workloads | ANTHROPIC_API_KEY, VOYAGE_API_KEY, AI_MODEL_* | Kun worker |
| Push-notifikasjoner | APNS_* (5 vars) | Kun worker |
| HTTP-sikkerhet | API_CLIENT_KEY, DEV_API_TOKEN, HEALTH_SECRET | Kun api |
| CORS | WEB_URL, LANDING_URL, CONSOLE_URL | Kun api |
| Admin dashboard | CONSOLE_ADMIN_TOKEN, CONSOLE_SENTRY_* | Kun api |
| Subscriptions | ASC_*, APPLE_APP_SHARED_SECRET | Begge |
Deploy-flyt
heading.anchorLabelPush til main → api (staging) + api-worker (staging) deployer automatiskPush til production → api (prod) + api-worker (prod) deployer automatiskIngen manuell deploy nodvendig. CI-workflowen (deploy-production.yml) kjorer DB-migrasjoner; Railway deployer services basert pa branch.
Lokal utvikling
heading.anchorLabelLokalt brukes SERVICE_MODE=all (default) — alt kjorer i en prosess som for. Ingen endring i utvikleropplevelsen.
For a teste splitten lokalt:
# Terminal 1 — HTTPSERVICE_MODE=web pnpm --filter api dev
# Terminal 2 — WorkerSERVICE_MODE=worker pnpm --filter api devFeilsoking
heading.anchorLabelSSE-events mangler
heading.anchorLabelSjekk at begge services er koblet til samme Redis-instans. QueueEvents bruker Redis Streams for cross-process kommunikasjon.
# Sjekk Redis-tilkobling pa workerrailway service link api-worker && railway logs | grep "Redis"Crons kjorer ikke
heading.anchorLabelVerifiser at worker er oppe og SERVICE_MODE=worker:
railway service link api-worker && railway logs | grep "cron\|reaper\|producer"Duplikate crons
heading.anchorLabelForhindres av distributed cron locks (se nedenfor). Hvis en cron likevel kjorer dobbelt, sjekk Redis-tilkobling — uten Redis faller lock-mekanismen tilbake til unconditional execution.
Distributed Cron Locks
heading.anchorLabelAlle 6 cron-jobber er wrappet med withCronLock() (apps/api/src/lib/cron-lock.ts). Dette sikrer at kun en worker-replica kjorer en gitt cron om gangen.
Hvordan det fungerer
heading.anchorLabelWorker A: withCronLock('gift-expiry', 300, fn) → SET cron:lock:gift-expiry <hostname> NX EX 300 → OK (acquired) → kjor fn → DEL lock
Worker B: withCronLock('gift-expiry', 300, fn) (samtidig) → SET cron:lock:gift-expiry <hostname> NX EX 300 → null (lock exists) → skip- NX — sett kun hvis nokkel ikke eksisterer (atomisk)
- EX — auto-expire etter TTL sekunder (crash safety)
- DEL etter execution — frigjor locken umiddelbart sa neste intervall kan kjore
TTL per cron
heading.anchorLabelTTL er satt lengre enn forventet kjoretid, men kortere enn intervallet:
| Cron | Intervall | Lock TTL | Begrunnelse |
|---|---|---|---|
| Gift expiry | 6 timer | 300s (5 min) | Enkle DB-updates |
| Subscription revalidation | 6 timer | 600s (10 min) | Apple API-kall per subscription |
| Stale impulse reaper | 5 min | 240s (4 min) | DB-scan + updates |
| Account deletion reaper | 30 min | 1500s (25 min) | Hard-deletes, kan ta tid |
| Analytics cache reaper | 6 timer | 300s (5 min) | Enkel DELETE query |
| Reminder producer | 1 time | 3000s (50 min) | Scanner alle brukere + APNS |
Feilhaandtering
heading.anchorLabel- Redis utilgjengelig: Kjorer cron uten lock (bedre duplikat enn ingen kjoring)
- Worker crash: Lock auto-expires etter TTL
- Normal completion: Lock frigis umiddelbart med DEL
Konvensjon for nye crons
heading.anchorLabelAlle nye cron-jobber SKAL bruke withCronLock:
import { withCronLock } from '../../lib/cron-lock.js'
const run = () => withCronLock('my-new-cron', ttlSeconds, async () => { await myFunction()}).catch(err => logger.error({ err }, 'Cron failed'))Skalering
heading.anchorLabelArkitekturen stotter:
- Flere web-replicas — Railway scale, alle stateless, deler Redis
- Flere worker-replicas — BullMQ distribuerer jobber automatisk, cron locks forhindrer duplikater
- Nye modes — legg til en linje i
SERVICE_CONFIGS(f.eks.cron-onlyfor dedikert cron-service) - Concurrency-justering —
QUEUE_CONCURRENCY_MULTIPLIERenv var, ingen deploy