Une chaise vide surdimensionnée marquée 101 bloque un convoyeur de cartes d'événements.
Une valeur de séquence consommée par un rollback ne devient jamais un événement. Un worker qui attend chaque identifiant consécutif peut se bloquer.

Les Server-Sent Events transportent des événements sur HTTP. Un rejeu fiable dépend du journal de la base de données, de l’ordre dans lequel les transactions sont validées et du curseur que le consommateur persiste. Dans notre flux adossé à PostgreSQL, les bugs les plus difficiles sont venus de ces trois frontières : un numéro de séquence manquant, un consommateur déconnecté et un curseur avancé avant que le traitement ait pu se terminer.

Ce retour d’expérience décrit la première construction d’un flux qui transporte des événements de sortie de stratégie depuis un screener adossé à PostgreSQL vers des consommateurs trader_bot distants. Il couvre le journal en ajout seul, le passage d’une séquence PostgreSQL à un compteur transactionnel, et cinq correctifs de durcissement. Ce sont des leçons d’infrastructure destinées aux développeurs, pas une offre de service ni des instructions pour trader ce que produit le flux.

Mis à jour le 1er novembre 2026 : les détails d’implémentation ont été vérifiés par rapport au code actuel. La migration vers le compteur, la mise en quarantaine côté consommateur, le marqueur de reprise persistant et les limites de rejeu décrits ci-dessous corrigent le récit d’origine. Une section de mise à jour distincte présente les évolutions ultérieures du protocole. Cette revue ne vérifie pas quelle révision est déployée.

Pourquoi SSE plutôt que WebSockets

SSE convient à un flux dont les données applicatives vont du serveur vers le consommateur. Le consommateur a besoin d’un flux d’événements ordonné et d’un moyen de reprendre après une déconnexion, plutôt que d’un canal de messages bidirectionnel. SSE fournit un format de transmission standard ; notre serveur et notre consommateur Python implémentent autour de lui les règles de rejeu durable et de traitement.

Le champ SSE id: identifie chaque événement, et Last-Event-ID transporte la position de reprise à la reconnexion. Le serveur doit conserver les événements et implémenter le rejeu ; l’en-tête seul ne récupère aucune donnée. Le EventSource du navigateur se reconnecte automatiquement, tandis que notre consommateur Python se reconnecte explicitement et envoie son curseur sauvegardé.

Un commentaire :ping toutes les 15 secondes maintient active une connexion sinon inactive. SSE fonctionne sur HTTP, y compris HTTP/2 ; il n’est pas limité à HTTP/1.1. Désactivez la mise en tampon du proxy et configurez les délais d’expiration pour le streaming. Notre client Python envoie un en-tête d’autorisation Bearer ; le constructeur natif EventSource du navigateur ne permet pas d’ajouter des en-têtes de requête arbitraires.

Dans cette implémentation, chaque connexion et sa file d’attente bornée appartiennent à un seul processus serveur. Les consignes de déploiement utilisent un routage à affinité de session, et la haute disponibilité sort du périmètre initial. C’est un choix d’implémentation, pas une exigence de SSE : la reconnexion à une autre instance peut fonctionner lorsque les instances partagent un journal et un contrat de rejeu adaptés.

Le journal d’événements en ajout seul

Un journal d’événements immuable conserve séparément chaque changement significatif, si bien que le rejeu ne dépend pas du dernier état d’une ligne modifiable. Le screener écrit des opérations INSERT et UPDATE dans stock_market.signal. Un trigger ajoute un instantané à stock_market.signal_feed_event dans la même transaction, et le flux lit ce journal par feed_seq.

Interroger périodiquement la table modifiable par created_at peut manquer une validation tardive située derrière le curseur, et des mises à jour ultérieures peuvent écraser des états qu’un consommateur n’a pas vus. Le journal d’événements évite cette fusion. Son event_time détermine l’éligibilité à la fenêtre de rejeu, plutôt que l’heure de création d’origine de la ligne de signal.

Le journal conserve les instantanés d’événements tant qu’ils sont retenus. Le rejeu renvoie les événements postérieurs au curseur, dans la fenêtre de temps configurée et dans le périmètre de la clé. Un consommateur à périmètre restreint peut légitimement voir des identifiants globaux non consécutifs, parce que d’autres événements appartiennent à d’autres périmètres. Ni l’immuabilité du journal ni l’allocation contiguë ne garantissent un traitement exactement une fois par le consommateur.

Le trigger envoie aussi une charge utile pg_notify minimale sur signal_feed pour réveiller le worker. Les notifications sont livrées après la validation, mais elles ne forment pas une file durable : le journal reste la référence. Un canal historique distinct, signal_inserted, transporte toujours la ligne complète pour les anciens consommateurs ; la notification minimale du flux ne lève pas la contrainte de taille de charge utile de ce canal historique.

Quand les séquences ne suffisent pas

Les séquences PostgreSQL allouent des valeurs distinctes sans garantir une numérotation sans trous ni l’ordre de validation. Une séquence est donc insuffisante pour le contrat de ce worker : ne livrer que le prochain événement validé consécutif. Le problème vient du contrat, pas d’un défaut général des séquences, et des flux d’événements conçus pour tolérer les trous peuvent les utiliser autrement.

La première version utilisait une SEQUENCE pour feed_seq. Un rollback ne récupère pas une valeur obtenue avec nextval() ; il ne s’agit pas d’une validation immédiate de la transaction englobante. Si 101 est consommé par une transaction annulée, les événements 100 et 102 peuvent exister alors que 101 n’existera jamais.

Une chaise vide numérotée 101 porte un tampon rolled-back à côté des cartes d'événements 100 et 102.
Le worker initial exigeait des identifiants validés consécutifs. Le trou laissé par le rollback était une incompatibilité de contrat, pas un défaut des séquences PostgreSQL.

Notre worker attend hwm + 1, si bien qu’un trou permanent peut le bloquer. Passer simplement à WHERE feed_seq > cursor ne résout pas l’ordre de validation : une transaction peut allouer 101, une autre peut allouer et valider 102, et 101 peut être validé plus tard. Avancer au-delà de 102 ferait alors sauter le 101 tardif. L’ordre d’allocation et l’ordre de validation sont deux faits différents.

La solution de remplacement utilise une table à une seule ligne, stock_market.signal_feed_counter. Le trigger exécute UPDATE signal_feed_counter SET seq = seq + 1 WHERE id = 1 RETURNING seq dans la même transaction que l’insertion de l’événement. Le verrou de ligne est conservé jusqu’à la fin de la transaction, si bien que l’écrivain suivant ne peut pas allouer sa valeur tant que le précédent n’a pas validé ou annulé.

Un rollback annule ensemble la mise à jour du compteur et l’insertion de l’événement. Une valeur a pu être renvoyée à l’intérieur de la transaction échouée, mais aucun incrément validé ne subsiste. Les nouvelles allocations validées forment donc un préfixe contigu, à condition que chaque écrivain suive le même chemin transactionnel. Le prix à payer est la sérialisation des écritures sur une ligne partagée : gardez ces transactions courtes et mesurez la contention avant de supposer que cela passera à l’échelle.

La migration verrouille la table des signaux, remplace le trigger et initialise le compteur à partir de MAX(feed_seq) dans le journal d’événements existant, ou à zéro s’il est vide. Elle ne l’initialise pas à partir de la valeur courante de l’ancienne séquence. Les événements existants gardent leurs identifiants, et les trous historiques ne sont pas réparés ; le nouvel invariant d’allocation s’applique à partir de cette frontière de migration.

Une bande de papier franchit une barrière de validation pendant qu'une seconde attend derrière elle.
Le verrou du compteur partagé sérialise les écrivains jusqu’à la validation ou au rollback. Cela ordonne les nouvelles allocations d’événements, au prix d’une contention.

La passe de durcissement : cinq correctifs

Le travail de durcissement a traité cinq catégories de défaillance : la troncature du rejeu, la contre-pression partagée, les événements empoisonnés, les déconnexions du courtier et le démarrage sans curseur. Chacune exige une réponse explicite. Une connexion qui fonctionne ne prouve pas suffisamment qu’un consommateur a traité chaque événement requis, ni qu’une action externe a abouti.

1. Détection des trous de troncature. La fenêtre d’éligibilité au rejeu est de 72 heures par défaut, configurable indépendamment de la rétention physique. Si l’historique requis n’est plus rejouable, le serveur envoie system avec reason: replay_truncated et une borne missed_before_seq. Le consommateur bloque les nouvelles entrées, continue de recevoir les sorties, lève une alerte CRITICAL et écrit un marqueur persistant .needs-reconcile. Réconciliez l’état manquant, puis supprimez le marqueur et redémarrez ; un simple redémarrage ne rétablit pas le fonctionnement normal.

2. Isolation de la diffusion. Chaque client dispose d’une file d’attente bornée. Une mise en file non bloquante et un débordement qui supprime le plus ancien empêchent un client lent de retenir tous les abonnés. Le flux signale reason: dropped avec un compteur, et le consommateur se reconnecte à partir de son curseur. La reprise dépend toujours de l’historique conservé et d’une capacité client suffisante ; faire déborder à répétition le même rejeu ne règle pas le goulot d’étranglement sous-jacent.

3. Traitement des événements empoisonnés. La diffusion côté serveur intercepte les échecs de livraison client par client, si bien que l’exception d’un client n’interrompt pas la livraison à tous les autres. Le mécanisme de nouvelle tentative et de quarantaine se trouve dans le consommateur : des échecs répétés du gestionnaire sur le même événement conservent le curseur jusqu’au seuil configuré, puis lèvent une alerte CRITICAL et avancent au-delà de cet événement. Cela saute délibérément un événement non traité pour sortir d’une boucle de plantages. Cela exige une investigation et, si nécessaire, une réconciliation ; ce n’est ni une garantie d’absence de perte ni une quarantaine générale côté serveur pour les lignes malformées.

4. Préserver le curseur pendant les déconnexions du courtier. Avant de traiter un signal, le consommateur vérifie que le courtier est prêt. Si son attente de reconnexion expire, il sort de la boucle de flux et se reconnecte sans avancer le curseur pour ce signal. L’événement peut être rejoué tant que l’historique reste disponible. Cette vérification ne rend pas atomiques une action chez le courtier et la persistance du curseur : un double traitement reste possible, et les contrôles d’ordres et de positions au niveau applicatif restent nécessaires.

5. Démarrage à partir de maintenant par défaut. Une connexion sans curseur démarre au niveau le plus haut atteint à cet instant ; un ?cursor=0 explicite demande le rattrapage éligible. Cela évite d’envoyer automatiquement l’historique ancien à chaque nouvel abonné, mais un curseur perdu signifie aussi que les événements survenus pendant l’interruption ne sont pas rejoués. Le chemin de rejeu temps réel actuel envoie le rattrapage directement, tandis que le rejeu différé utilise un ordonnanceur borné et peut déborder sur un rattrapage volumineux.

Le curseur durable fait partie du contrat de reprise. La CLI actuelle exige --feed-cursor-file avec --feed-url ; stockez-le sur un volume persistant et accessible en écriture. Un fichier absent est traité comme un premier démarrage et part de maintenant. Un fichier existant mais vide, corrompu ou illisible déclenche en revanche un mode persistant de clôture seule, parce que la position de reprise a été perdue.

Résilience côté consommateur : l’éventail des défaillances

Le consommateur distingue les nouvelles tentatives de transport des situations qui exigent l’intervention d’un opérateur. Les heartbeats détectent une connexion silencieuse, un débordement demande un rejeu, et un historique expiré bloque les nouvelles entrées. Une reconnexion peut répéter un événement dont le gestionnaire s’est exécuté avant la sauvegarde du curseur, si bien que le rejeu doit s’accompagner d’une gestion des doublons au niveau applicatif.

SituationRéponse automatiqueLimite de la reprise
HTTP 503Nouvelle tentative avec temporisation plafonnéeLe point de contrôle de santé renvoie 503 tant que l’écouteur de la base de données est arrêté
Délai de lecture expiréReconnexion à partir du dernier curseur traitéDélai de lecture de 35 secondes par défaut ; heartbeat d’inactivité de 15 secondes
Débordement de fileReconnexion après un événement système de perteLe rejeu doit rester disponible et le consommateur doit rattraper son retard
Rejeu tronqué ou curseur corrompuMode persistant de clôture seule et alerte CRITICALRéconcilier, supprimer le marqueur, puis redémarrer
HTTP 401 ou 403Alerte CRITICAL et arrêtCorriger les identifiants avant de redémarrer
Échec répété du gestionnaireNouvelle tentative, puis quarantaine et avancée du curseurL’événement sauté doit faire l’objet d’une investigation

Pour les événements sains, le consommateur suit un modèle de traitement au moins une fois tant que le rejeu est disponible. Il écrit le curseur après le retour du gestionnaire, en utilisant un fichier temporaire, une synchronisation du fichier et un remplacement atomique. Les échecs d’écriture du curseur sont journalisés ; ils peuvent entraîner le rejeu d’un événement plus ancien après un redémarrage. Le retour du gestionnaire ne prouve pas qu’un ordre chez le courtier a été exécuté.

Le bot utilise des contrôles de position existants et une garde sur les ordres ouverts pour limiter les exécutions en double. Ces contrôles relèvent de l’application, pas de SSE. La quarantaine, l’historique expiré et le curseur manquant sont des exceptions explicites à toute affirmation générale selon laquelle chaque événement finira par être traité.

Le chemin --recovery par la base de données est une solution de repli indépendante qui relit l’état récent des signaux. Il exige un accès à la base de données et une profondeur d’historique suffisante. Il ne peut pas reconstruire chaque instantané intermédiaire écrasé à partir d’une table modifiable, et ne doit donc pas être présenté comme l’équivalent d’un rejeu du journal d’événements immuable.

L’évolution de la documentation

Le runbook d’exploitation doit décrire le système tel qu’il est implémenté, y compris les limites de rejeu, le stockage du curseur et la gestion des défaillances. Notre plan de conception d’origine a été intégré à doc/SIGNAL_FEED.md une fois le flux en place. Le runbook réunit l’architecture et les procédures de reprise, en séparant l’historique de conception des instructions qu’un opérateur doit suivre pendant un incident.

Gardez une seule description de référence du comportement actuel et conservez les plans dépassés comme un historique clairement obsolète. Mettre à jour le runbook compte quand un protocole évolue : une note de conception qui dit que le flux ne transporte que des signaux ne décrit plus un flux qui transporte aussi des événements de passation de stratégie.

La documentation a besoin des mêmes frontières que le code : quel comportement relève de SSE, lequel relève de la base de données, et lequel dépend du consommateur. Une instruction de reprise qui omet le marqueur persistant de clôture seule peut amener un opérateur à redémarrer un bot sans réellement résoudre son état.

Ce que nous avons appris

Un rejeu fiable exige un ordre d’événements validés, un journal conservé et un curseur consommateur correctement persisté. SSE transporte les trames ; il ne fournit pas ces garanties. Un compteur transactionnel satisfait le contrat de livraison consécutive de ce worker en sérialisant les écrivains, tandis que les règles de nouvelle tentative et de reprise du consommateur déterminent ce qui se passe une fois qu’une trame a quitté le serveur.

Le schéma utile est un journal d’instantanés immuable, avec une frontière de rejeu explicite et un contrat de traitement documenté. Il ne dispense pas d’évaluer le débit, la rétention, l’idempotence et la reprise après défaillance. Une séquence PostgreSQL reste appropriée quand les trous sont acceptables et que la conception traite séparément la visibilité et l’ordre de validation.

Testez le rollback, les validations tardives, le débordement de file, la corruption du stockage du curseur et l’interruption du traitement avant de vous reposer sur le flux. Mesurez la contention sur le compteur partagé sous une charge représentative. La simplicité du transport allège une partie de l’implémentation ; elle ne supprime pas la nécessité de tester les états dans lesquels la livraison s’arrête ou se répète.


Cet article décrit une méthodologie d’infrastructure à des fins uniquement informatives et pédagogiques. Il ne constitue en aucun cas un conseil financier, une recommandation de trading ou une orientation d’investissement. Le comportement passé d’un système ne garantit pas sa fiabilité future.

Qu’est-ce qui a changé après la première construction ?

À la date de la revue du code du 1er novembre 2026, le flux transporte aussi des événements strategy_migrate dans le même journal ordonné que les signaux. Un journal durable distinct de migration de stratégie et un point d’accès de rattrapage permettent aux consommateurs de récupérer les instructions de passation indépendamment de la fenêtre de rejeu SSE. Il s’agit d’une extension ultérieure du protocole, qui ne faisait pas partie de la conception d’origine limitée aux signaux.

Le rattrapage temps réel est désormais transmis directement avant les événements en direct mis en file ; les clients différés planifient le rejeu avec une quantité bornée de travail en attente. Le consommateur persiste l’état de clôture seule et l’état des nouvelles tentatives sur événement empoisonné à côté de son curseur. Ces détails décrivent l’implémentation revue ; ils ne constituent pas une mesure de disponibilité ou de débit.

Sources et vérification

La documentation PostgreSQL des fonctions de séquence explique pourquoi les valeurs allouées ne sont pas récupérées lors d’un rollback. La documentation de NOTIFY décrit la livraison transactionnelle et les limites des notifications. La spécification SSE du WHATWG définit les identifiants d’événements, le comportement de reconnexion et l’interface EventSource du navigateur.

Les affirmations propres à l’implémentation ont été vérifiées le 1er novembre 2026 par rapport au serveur du flux, à sa migration vers le compteur transactionnel, à la boucle du consommateur et au runbook actuel du flux de signaux. Cet article rend compte d’une revue d’implémentation, pas d’un nouvel audit de production. Aucune affiliation avec les projets ou prestataires cités, ni aucune approbation de leur part, n’est sous-entendue.

Questions fréquentes (FAQ)

Pourquoi utiliser SSE plutôt que WebSockets ?

SSE fournit un format d’événements standard et unidirectionnel sur HTTP. Dans cette implémentation, le serveur conserve les événements et implémente le rejeu par curseur, tandis que le consommateur Python gère les reconnexions et la persistance du curseur. Last-Event-ID identifie le point de reprise demandé ; il ne garantit pas à lui seul la reprise.

Quel est le compromis du choix de SSE ?

Notre implémentation garde les files d’attente et l’état des connexions dans un seul processus et utilise un routage à affinité de session. Un stockage de rejeu partagé peut permettre d’autres architectures de déploiement, mais la haute disponibilité sortait du périmètre de la première construction. La mise en tampon du proxy, les délais d’expiration et les limites des clients doivent être configurés pour des réponses de longue durée.

Pourquoi remplacer les séquences PostgreSQL par un compteur sans trous ?

Notre worker exige le prochain événement validé consécutif. Les séquences PostgreSQL peuvent présenter des trous dus aux rollbacks et n’imposent pas l’ordre de validation. Mettre à jour un compteur partagé dans la transaction de l’événement sérialise les écrivains jusqu’à la validation ou au rollback, au prix d’une contention sur une seule ligne.

Comment un consommateur reprend-il après une période hors ligne ?

Un consommateur doté d’un curseur sauvegardé demande les événements ultérieurs dans le périmètre de sa clé et dans la fenêtre de rejeu, de 72 heures par défaut. Un rejeu tronqué déclenche un mode persistant de clôture seule et une réconciliation. Un curseur absent fait démarrer à partir de maintenant ; un curseur existant mais corrompu ou illisible déclenche le mode de clôture seule.

Les problèmes rencontrés venaient-ils vraiment de SSE ?

Les principales défaillances décrites ici concernent l’ordre de validation dans la base de données, l’historique conservé, l’isolation des files, la disponibilité du courtier et l’état du consommateur. Ces problèmes restent pertinents avec d’autres transports. SSE standardise le découpage des événements, mais les garanties de livraison et de traitement exigent toujours une conception au niveau applicatif.

Autres recherches KreamEdge


0 commentaire

Laisser un commentaire

Emplacement de l’avatar

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *