Services
Services
Twitch Ingress
Service Elixir/BEAM qui possède le Conduit EventSub Twitch et ses shards WebSocket, filtre les payloads entrants et transmet les événements normalisés à NATS.
Sur cette page
Twitch Ingress conserve les shards WebSocket d’un unique EventSub Conduit Twitch, les maintient après les resets, filtre les payloads entrants et publie les événements retenus sur NATS sous forme normalisée. C’est le seul service écrit en Elixir sur la VM BEAM ; le reste est en Go.
Le choix du langage est justifié dans ADR 0006. Le substrat de communication (NATS) derrière lequel il se trouve est justifié dans ADR 0003. Le matériel sur lequel il s’exécute est décrit dans ADR 0004.
Responsabilités
- Maintenir un EventSub Conduit Twitch composé d’une petite flotte de shards WebSocket. Twitch répartit les événements entre les shards de son côté ; nous devons seulement les garder connectés.
- Maintenir le WebSocket de chaque shard : gérer la séquence
session_welcome/reconnect, se reconnecter avec backoff après fermeture ou reset, et redémarrer isolément un shard défaillant. - Conserver le jeton d’accès de l’application Twitch utilisé pour les appels Helix (gestion du Conduit et association des shards), et en obtenir un nouveau peu avant son expiration.
- Filtrer les payloads Twitch entrants et publier les survivants sur NATS comme événements normalisés. Les messages de chat ont exactement trois issues : ceux des identifiants utilisateur spéciaux (liste du secret store) vont toujours sur la voie premium ; ceux qui commencent par
!vont sur la voie correspondant au statut du broadcaster (premium ou standard) ; le chat ordinaire publie sa première ligne immédiatement et regroupe les répétitions identiques en un seul événement qui liste leurs expéditeurs (squash). Les événements d’un broadcaster banni sont supprimés. - Résoudre le statut du broadcaster par requête-réponse NATS auprès du service propriétaire de ces données (l’ingress ne lit jamais MySQL directement), derrière un cache read-through en processus évincé par des clés d’invalidation NATS.
- Répartir la propriété des shards entre les répliques ingress du cluster BEAM afin qu’un seul nœud possède le WSS de chaque shard, avec réaffectation en quelques secondes lorsqu’un nœud quitte le cluster.
Ce service ne traite pas les commandes (commandes de chat ou d’opérateur), ne fait pas de persistance, de logique métier ni de comptabilisation de limites de débit. Tout ce qui doit réagir aux événements vit dans les services Go en aval et s’abonne aux sujets NATS publiés par ce service.
Forme externe
flowchart LR
subgraph Ingress [Twitch Ingress - Elixir / BEAM cluster]
direction TB
N1[ingress node A]
N2[ingress node B]
N1 <-. Erlang distribution<br/>between pods .-> N2
end
NATS[(NATS<br/>core + JetStream)]
N1 -- publish events --> NATS
N2 -- publish events --> NATS
Les nœuds ingress forment un seul cluster BEAM via la distribution Erlang. Aucun port public n’est exposé. Les seuls éléments qui quittent le cluster sont les messages NATS publiés.
Forme interne (arbre de supervision OTP)
flowchart TB
App[BagelBot.Ingress.Application<br/>top-level supervisor]
Cluster[Cluster.Supervisor<br/>libcluster - node discovery]
Registry[Horde.Registry<br/>shard_id to PID, cluster-wide]
DynSup[Horde.DynamicSupervisor<br/>spawns and supervises shards]
NatsConn[Gnat.ConnectionSupervisor<br/>NATS publisher]
Token[Twitch.AppToken<br/>app access token]
App --> Cluster
App --> Registry
App --> DynSup
App --> NatsConn
App --> Token
DynSup --> Sh1[ShardSession - shard 1<br/>GenServer]
DynSup --> Sh2[ShardSession - shard 2<br/>GenServer]
DynSup --> ShN[ShardSession - shard N<br/>GenServer]
Sh1 -. holds .-> WS1[EventSub WSS shard]
Sh2 -. holds .-> WS2[EventSub WSS shard]
ShN -. holds .-> WSN[EventSub WSS shard]
BagelBot.Ingress.Applicationest le superviseur de premier niveau. Stratégie de redémarrage ::one_for_one.Cluster.Supervisor(libcluster) découvre les nœuds pairs et appelleNode.connect/1sur ceux-ci. La stratégie suit l’environnement : DNS Kubernetes lorsqueBAGELBOT_K8S_HEADLESS_SERVICEest défini, EPMD avecBAGELBOT_CLUSTER_HOSTS, sinon Gossip.Horde.Registryest un registre distribué à l’échelle du cluster, fondé sur un CRDT. Chaque shard est enregistré sous une clé telle que{:shard, shard_id}. La recherche inter-nœuds est un appelHorde.Registry.lookup/2.Horde.DynamicSupervisorcrée les shards et les réattribue aux nœuds survivants lorsqu’un nœud tombe.ShardSessionest unGenServerpar shard. Il possède un WebSocket, pilote la machine d’état du protocole EventSub, exécute le filtre sur chaque payload entrant et publie les survivants sur NATS.Gnat.ConnectionSupervisorpossède les connexions NATS (Gnat est le client NATS Elixir). Le bus d’événements et le plan RPC utilisent des connexions et des identités distinctes.Twitch.AppTokenest un processus unique qui conserve le jeton d’accès de l’application. Il en obtient un nouveau lorsque le jeton en cache expire dans moins d’une minute.
Le filtre lui-même est un module de fonction pure (sans état ni enfant du superviseur). Chaque ShardSession l’appelle en ligne avant de décider de publier.
Stratégies de redémarrage
| Processus | Stratégie | Notes |
|---|---|---|
Application |
:one_for_one |
La panne d’un sous-système ne fait pas tomber ses frères. |
Horde.DynamicSupervisor |
:one_for_one |
Le crash d’un shard ne redémarre que ce shard. |
ShardSession |
:transient |
Un arrêt normal (shard supprimé) ne redémarre pas ; un crash redémarre avec backoff. |
Twitch.AppToken |
:permanent |
Toujours redémarré ; sans le jeton d’application, aucun shard ne peut s’associer. |
Gnat.ConnectionSupervisor |
:permanent |
Toujours redémarré ; sans NATS, l’ingress est inutilisable. |
Le backoff de reconnexion d’un shard est exponentiel, avec jitter, et plafonné à 60 secondes.
Partitionnement et propriété
Twitch gère de son côté le routage des événements entre les shards du Conduit ; nous ne choisissons pas la destination de chaque chaîne. Nous possédons le maintien de chaque WebSocket et la réattribution de cette propriété entre les nœuds BEAM lorsque le cluster change.
Le déroulement est le suivant :
- Au démarrage, le cluster lit le nombre de shards du Conduit et garantit qu’un
ShardSessionexiste pour chaque shard, démarré viaHorde.DynamicSupervisor.start_childet enregistré dansHorde.Registrysous{:shard, shard_id}. - Lorsqu’un nœud quitte le cluster,
Horderéattribue ses shards aux nœuds survivants. Le nouveau propriétaire rouvre le WebSocket et le réenregistre auprès de Twitch via l’API EventSub. - Lorsqu’un nœud rejoint le cluster,
Hordelui migre des shards selonIngress.ShardDistribution: round-robin par identifiant de shard entre les nœuds vivants (triés par nom), si bien que cinq shards sur deux nœuds se répartissent toujours en 3/2. Le placement est déterministe ; la redistribution active ne déplace que les shards dont la cible a réellement changé.
Nous n’utilisons délibérément pas NATS KV pour cela. Le registre du cluster BEAM fait autorité, vit en mémoire et se met à jour de façon synchrone entre les nœuds. Voir ADR 0006 pour le raisonnement.
Contrats NATS
L’ingress publie les événements et l’état, émet des appels requête-réponse pour le statut du broadcaster et s’abonne aux clés d’invalidation du cache, à data.users.changed (éviction des essais) et à ses propres sujets RPC d’administration.
Préfixes des sujets :
twitch.ingress.event.<lane>: événements sortants normalisés publiés après filtrage. Exactement trois sujets :premium/standard(répartis selon le statut du broadcaster) etstream(uniquement les événements stream online/offline) ; letypeEventSub voyage dans le payload, pas dans le sujet.twitch.ingress.status.*: signaux sortants de cycle de vie et de santé.bagel.rpc.broadcaster.status.get: requête-réponse pour consulter le statut auprès du service Go propriétaire.bagel.cache.invalidate.status: entrée qui évince les entrées du cache de statut en processus.twitch.ingress.admin.shards.get: requête-réponse entrante traitée parIngress.AdminRpc, qui fournit à la console Admin un instantané d’état des shards du cluster. Un groupe de files garantit qu’une seule réplique répond ; la réponse couvre chaque shard via le registre Horde.twitch.ingress.admin.shards.scale,.shards.autoscaleettwitch.ingress.admin.trials.*(list,add,remove,set_enabled) : requête-réponse entrante pour la mise à l’échelle des shards et les chaînes d’essai.bagel.rpc.ingress.conduit.get: requête-réponse entrante renvoyant l’identifiant du Conduit actif (utilisé par Twitch Outgress). La santé est servie surbagel.rpc.health.ingress.
Sortie : événements
| Sujet | Payload (JSON) | Notes |
|---|---|---|
twitch.ingress.event.premium |
chat: enveloppe aplatie (type, lane, identité du broadcaster et du chatteur, text, msg_id, horodatage) ; non-chat: payload EventSub tel que livré par Twitch, plus {type, lane, received_at, shard_id, msg_id} |
Broadcasters premium et identifiants spéciaux. |
twitch.ingress.event.standard |
mêmes formes que premium | Broadcasters standard et événements sans broadcaster extractible. |
twitch.ingress.event.stream |
payload EventSub plus {type, lane, received_at, shard_id, msg_id} |
stream.online / stream.offline uniquement, quel que soit le statut du broadcaster. Chacun est aussi publié sur le sujet premium ou standard du broadcaster. |
Les notifications de chat et tous les autres types EventSub abonnés circulent sur les sujets premium/standard ; les consommateurs distribuent selon le champ type du payload. Les payloads contiennent msg_id afin que les consommateurs dédupliquent les rediffusions Twitch.
Sortie : état
| Sujet | Payload |
|---|---|
twitch.ingress.status.shard.up |
{shard_id, node, session_id, since} |
twitch.ingress.status.shard.bound |
{shard_id, node, session_id, kind, at} ; kind vaut fresh (connexion complète + association Helix) ou moved (handshake session_reconnect terminé) |
twitch.ingress.status.shard.down |
{shard_id, node, reason} ; reason contient reconnecting et duplicate_resolved (un doublon issu de la guérison d’un netsplit s’arrête) |
twitch.ingress.status.authz.granted / authz.revoked |
{user_id, user_login, at}, à partir des événements Twitch user.authorization |
twitch.ingress.status.authz.subrevoked |
{broadcaster_id, type, status, at}, lorsque Twitch révoque un abonnement |
Les sujets d’état partagent le flux JetStream TWITCH_INGRESS avec les voies premium et stream, qui conserve les messages 10 secondes.
Configuration
Pilotée par l’environnement. Toutes les valeurs arrivent comme variables d’environnement et sont lues une fois au démarrage.
| Variable | Rôle | Exemple |
|---|---|---|
RELEASE_NODE |
Nom long Erlang de ce nœud (le manifeste définit ingress@$(POD_IP)). |
ingress@10.42.0.7 |
RELEASE_COOKIE |
Cookie partagé pour la distribution. Le manifeste le lit dans la clé de secret BAGELBOT_ERLANG_COOKIE. |
(opaque) |
BAGELBOT_K8S_HEADLESS_SERVICE |
Service headless pour la découverte DNS Kubernetes ; prioritaire sur BAGELBOT_CLUSTER_HOSTS. |
(non défini) |
BAGELBOT_CLUSTER_HOSTS |
Noms longs des pairs séparés par des virgules pour la stratégie EPMD de libcluster. | ingress-a@10.42.0.7,ingress-b@10.42.0.8 |
NATS_HOST / NATS_PORT |
Point d’accès NATS (port par défaut 4222). |
127.0.0.1 |
NATS_JWT / NATS_NKEY_SEED |
Identifiants du bus. NATS_RPC_JWT / NATS_RPC_NKEY_SEED configurent la connexion RPC distincte. |
(opaque) |
TWITCH_CLIENT_ID |
Identifiant client pour les appels à l’API Twitch. | (opaque) |
TWITCH_CLIENT_SECRET |
Secret client de l’application. | (opaque) |
TWITCH_CONDUIT_ID |
Conduit possédé par cet ingress. | conduit_abc123 |
TWITCH_CONDUIT_SHARD_COUNT |
Nombre souhaité de shards dans le Conduit (2 par défaut ; TWITCH_CONDUIT_MAX_SHARDS plafonne la mise à l’échelle, 11 par défaut). |
4 |
TWITCH_EVENTSUB_WSS_URL |
Endpoint WebSocket EventSub Twitch, fixé par la configuration. | wss://eventsub.wss.twitch.tv/ws |
TWITCH_SPECIAL_USER_IDS |
Identifiants de chat toujours envoyés sur la voie premium, depuis le secret store. | 1001,1002 |
NATS_SUBJECT_LANE_PREMIUM |
Sujet de la voie premium (tous les types d’événements). | twitch.ingress.event.premium |
NATS_SUBJECT_LANE_STANDARD |
Sujet de la voie standard (tous les types d’événements). | twitch.ingress.event.standard |
NATS_SUBJECT_LANE_STREAM |
Voie dédiée aux seuls événements stream.online / stream.offline. | twitch.ingress.event.stream |
NEW_RELIC_LICENSE_KEY |
Active la surveillance New Relic ; sans clé, l’agent ne fait rien. | (opaque) |
NEW_RELIC_APP_NAME |
Nom de l’application New Relic. | itsbagelbot-twitch-ingress |
NATS_BROADCASTER_STATUS_SUBJECT |
Sujet requête-réponse des consultations de statut broadcaster. | bagel.rpc.broadcaster.status.get |
NATS_CACHE_INVALIDATION_SUBJECT |
Sujet contenant les clés d’invalidation du cache broadcaster. | bagel.cache.invalidate.status |
NATS_ADMIN_SUBJECT |
Sujet requête-réponse fournissant l’instantané d’état des shards. | twitch.ingress.admin.shards.get |
BROADCASTER_CACHE_TTL_SECONDS |
TTL du cache de statut broadcaster en processus. | 300 |
LOG_LEVEL |
debug / info / warn / error. |
info |
Déploiement
- Réplicas : 3, répartis un par hôte (
topologySpreadConstraintssurkubernetes.io/hostname) et tenus à l’écart des nœuds de rôle plan de contrôle et worker (deploy/k8s/twitch-ingress.yaml). - Runtime : Elixir 1.17+ (
mix.exs), construit avec Elixir 1.20 sur OTP 28. Publié comme release Mix (mix release) sur une image de basedebian:trixie-slim. - Modèle de processus : un conteneur par nœud. BEAM est le runtime de concurrence ; nous n’exécutons pas plusieurs VM BEAM par pod.
- Réseau : la distribution Erlang reste interne au cluster. Aucun port public ; NATS est joint par le réseau du cluster.
- Contrôles de santé : les sondes de vivacité et de disponibilité de Kubernetes exécutent
/app/bin/ingress pid. Un écouteur HTTP distinct (STATUS_PORT,8080par défaut) sert/healthz,/readyzet/status. - Mises à jour progressives : un nœud à la fois.
SIGTERMdéclenche:init.stop/0, qui exécute les callbacks d’arrêt de l’application et laisse Horde vider les shards vers les nœuds survivants avant la sortie.
Observabilité
- Journaux :
Loggervers stdout avec les métadonnéesshard_idetnode;LOG_LEVELfixe le niveau. - Métriques : des compteurs en processus sont envoyés chaque seconde à New Relic sous forme de métriques
Custom/Ingress/*, et les événements notables des shards sont signalés comme événements personnalisésIngressEvent. SansNEW_RELIC_LICENSE_KEY, l’agent ne fait rien. - Traces : une part échantillonnée des notifications (
INGRESS_TRACE_SAMPLE_RATE) s’exécute comme transaction New RelicIngress, avec l’attente dans la file du dispatcher enregistrée comme attribut.
Modes de panne et réponses du service
| Panne | Réponse |
|---|---|
| Le WebSocket d’un shard tombe | ShardSession intercepte la fermeture, redémarre via le superviseur et se reconnecte avec backoff. Les shards frères restent intacts. |
| Twitch renvoie 401 pour un shard | L’appel Helix abandonne le jeton d’application en cache (Twitch.AppToken) ; la tentative suivante en obtient un nouveau. Si l’appel échoue durablement, le shard crashe et le superviseur le redémarre. |
| Un nœud ingress tombe | Horde réattribue ses shards aux nœuds survivants. Les nouveaux propriétaires rouvrent les WebSockets et les réenregistrent auprès de Twitch ; des événements d’état sont émis. |
| NATS est inaccessible | Gnat.ConnectionSupervisor se reconnecte. Les événements sortants sont bufferisés (avec limite) puis supprimés en cas de débordement avec un journal de niveau avertissement ; nous préférons la perte à une croissance mémoire illimitée. |
| Partition de la distribution Erlang | Chaque côté devient un cluster sans quorum et peut exécuter des shards en double. Après rétablissement, la fusion du registre signale le doublon et la résolution est fondée sur la santé : la copie liée au Conduit conserve le shard (en reprenant l’enregistrement si le registre avait choisi la copie non liée), tandis que la copie redondante s’arrête correctement et publie shard.down avec la raison duplicate_resolved. |
| Un seul payload Twitch mal formé | Le shard crashe, le superviseur le redémarre et Twitch renvoie l’événement via le Conduit. Aucun raz-de-marée de retries sur les payloads invalides. |
Références
- ADR 0001 : la réécriture en microservices, qui a initialement fait apparaître le problème des sockets Twitch zombies que ce service résout.
- ADR 0003 : NATS comme substrat de communication et espace de sujets publié par ce service.
- ADR 0004 : la flotte où s’exécute ce service.
- ADR 0006 : le choix du langage et du runtime de ce service.